From c1858e76d4ba17cdf240bc26e249e6aeb23bbee1 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Sat, 18 Jul 2026 21:51:54 +0300 Subject: [PATCH] Added stops departure and arrival datetime --- .../migration.sql | 2 + .../migration.sql | 4 + apps/edr-passenger-api/prisma/schema.prisma | 2 + .../src/modules/packages/packages.dto.ts | 9 + .../src/modules/packages/packages.service.ts | 27 +- .../src/modules/schedules/routes.dto.ts | 4 + .../src/modules/schedules/routes.service.ts | 6 + .../src/modules/schedules/schedules.dto.ts | 3 + .../modules/schedules/schedules.service.ts | 147 +++++-- .../src/modules/tasks/tasks.service.ts | 73 ++++ .../edr-passenger-web/backoffice/package.json | 1 + .../backoffice/src/app/routes/page.tsx | 82 +++- .../backoffice/src/app/schedules/page.tsx | 377 ++++++++++++++++-- .../src/components/layout/Sidebar.tsx | 14 +- .../src/components/ui/DateTimePicker.tsx | 358 +++++++++++++++++ pnpm-lock.yaml | 11 + 16 files changed, 1026 insertions(+), 94 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260718000001_add_route_stop_planned_times/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260718000002_change_route_stop_planned_times_to_datetime/migration.sql create mode 100644 apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx diff --git a/apps/edr-passenger-api/prisma/migrations/20260718000001_add_route_stop_planned_times/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260718000001_add_route_stop_planned_times/migration.sql new file mode 100644 index 000000000..012ec84db --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260718000001_add_route_stop_planned_times/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedArrivalTime" INTEGER; +ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedDepartureTime" INTEGER; diff --git a/apps/edr-passenger-api/prisma/migrations/20260718000002_change_route_stop_planned_times_to_datetime/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260718000002_change_route_stop_planned_times_to_datetime/migration.sql new file mode 100644 index 000000000..4ff235be2 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260718000002_change_route_stop_planned_times_to_datetime/migration.sql @@ -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); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index fb9db346f..838ee8826 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1050,6 +1050,8 @@ model RouteStop { sequence Int distanceKm Float? checkinMinutesBefore Int? + plannedArrivalTime DateTime? + plannedDepartureTime DateTime? createdAt DateTime @default(now()) route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts index d7257f179..95b3a916b 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -128,4 +128,13 @@ export class BookPackageDto { /** Number of child passengers (<5 years). Derived from passengers array if omitted. */ @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; } diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index 9ddc1fb0a..43d96f86c 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -6,6 +6,7 @@ import { Currency } from '@prisma/client'; import { BookingsService } from '../bookings/bookings.service'; import { GuestBookingService } from '../bookings/guest-booking.service'; import { AuditService } from '../../common/audit.service'; +import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils'; /** Package-specific fare rules */ const PKG_MAX_ADULTS = 5; @@ -382,7 +383,10 @@ export class PackagesService { async book(dto: BookPackageDto, passengerId?: string) { const pkg = await this.prisma.travelPackage.findUnique({ 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.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 { ...booking, fareBreakdown: { diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index f2db36e7c..fefa26ad1 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -7,6 +7,8 @@ export class RouteStopInputDto { @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: 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 { @@ -37,6 +39,8 @@ export class AddRouteStopDto { @ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: 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: '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 { diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 58e647180..dcbdbc3b0 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -37,6 +37,8 @@ export class RoutesService { sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : 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, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, + plannedArrivalTime: s.plannedArrivalTime ?? null, + plannedDepartureTime: s.plannedDepartureTime ?? null, })), }); } @@ -225,6 +229,8 @@ export class RoutesService { sequence: dto.sequence, distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null, checkinMinutesBefore: dto.checkinMinutesBefore ?? null, + plannedArrivalTime: dto.plannedArrivalTime ?? null, + plannedDepartureTime: dto.plannedDepartureTime ?? null, }, }); } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 675168cf9..f56883464 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -60,6 +60,9 @@ export class UpdateScheduleDto { @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({ 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 { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 7cca548aa..ecdf51666 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -133,26 +133,62 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + const hasRouteTimes = route.stops.some( + s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null, + ); - 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(), + if (hasRouteTimes) { + // Extract EAT time-of-day from a template DateTime and anchor to the schedule's EAT date. + const EAT_MS = 3 * 60 * 60 * 1000; + const depEATMs = dep.getTime() + EAT_MS; + const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000); + const eatMidnightUTC = dep.getTime() - depMsIntoDay; + + const templateToScheduleUTC = (templateDt: Date): Date => { + // Pull the time-of-day in EAT from the template DateTime + const templateEATMs = templateDt.getTime() + EAT_MS; + const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000); + const candidate = new Date(eatMidnightUTC + timeOfDayMs); + // Overnight: if the stop time lands before departure, move to next day + if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000); + 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)); @@ -304,26 +340,59 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + const hasRouteTimes = route.stops.some( + s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null, + ); - 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(), + if (hasRouteTimes) { + const EAT_MS = 3 * 60 * 60 * 1000; + const depEATMs = dep.getTime() + EAT_MS; + const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000); + const eatMidnightUTC = dep.getTime() - depMsIntoDay; + + const templateToScheduleUTC = (templateDt: Date): Date => { + const templateEATMs = templateDt.getTime() + EAT_MS; + const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000); + const candidate = new Date(eatMidnightUTC + timeOfDayMs); + if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000); + 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 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); } diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 2355485d4..4767c7eba 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -146,6 +146,7 @@ export class TasksService { await Promise.all([ this.sendPaymentReminders(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. // ───────────────────────────────────────────────────────────────────────── diff --git a/apps/edr-passenger-web/backoffice/package.json b/apps/edr-passenger-web/backoffice/package.json index 858fcd781..9d4327a42 100644 --- a/apps/edr-passenger-web/backoffice/package.json +++ b/apps/edr-passenger-web/backoffice/package.json @@ -19,6 +19,7 @@ "lucide-react": "^0.446.0", "next": "^14.2.0", "react": "^18.3.1", + "react-day-picker": "^9.14.0", "react-dom": "^18.3.1", "recharts": "^2.12.0", "socket.io-client": "^4.8.3", diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index feca7e3b5..5fa655a5e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -10,6 +10,14 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { routesApi } from '@/lib/api/routes'; 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 { stationId: string; @@ -17,6 +25,8 @@ interface RouteStop { distanceKm?: number; distanceFromOrigin?: number; checkinMinutesBefore?: number; + plannedArrivalTime?: string; + plannedDepartureTime?: string; } type Tab = 'routes' | 'coaches'; @@ -175,6 +185,8 @@ export default function RoutesPage() { const [destinationDistance, setDestinationDistance] = useState(undefined); const [originCheckinMinutes, setOriginCheckinMinutes] = useState(undefined); const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState(undefined); + const [originDepartureTime, setOriginDepartureTime] = useState(''); + const [destinationArrivalTime, setDestinationArrivalTime] = useState(''); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null }); const [search, setSearch] = useState(''); const queryClient = useQueryClient(); @@ -245,18 +257,27 @@ export default function RoutesPage() { // distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm) 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) => ({ stationId: stop.stationId, sequence: idx + 2, distanceKm: stop.distanceFromOrigin || 0, checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined, + plannedArrivalTime: stop.plannedArrivalTime ? eatToISO(stop.plannedArrivalTime) : undefined, + plannedDepartureTime: stop.plannedDepartureTime ? eatToISO(stop.plannedDepartureTime) : undefined, })), { stationId: destinationStationId, sequence: sortedMiddleStops.length + 2, distanceKm: destinationDistance || 0, checkinMinutesBefore: destinationCheckinMinutes ?? undefined, + plannedArrivalTime: destinationArrivalTime ? eatToISO(destinationArrivalTime) : undefined, }, ]; @@ -387,8 +408,10 @@ export default function RoutesPage() { const destStop = routeStops[routeStops.length - 1]; setOriginStationId(originStop.stationId); setOriginCheckinMinutes(originStop.checkinMinutesBefore ?? undefined); + setOriginDepartureTime(originStop.plannedDepartureTime ? isoToEAT(originStop.plannedDepartureTime) : ''); setDestinationStationId(destStop.stationId); setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined); + setDestinationArrivalTime(destStop.plannedArrivalTime ? isoToEAT(destStop.plannedArrivalTime) : ''); setDestinationDistance(destStop.distanceKm || 0); setStops(routeStops.slice(1, -1).map((s: any) => ({ stationId: s.stationId, @@ -396,6 +419,8 @@ export default function RoutesPage() { distanceKm: s.distanceKm, distanceFromOrigin: s.distanceKm || 0, checkinMinutesBefore: s.checkinMinutesBefore ?? undefined, + plannedArrivalTime: s.plannedArrivalTime ? isoToEAT(s.plannedArrivalTime) : '', + plannedDepartureTime: s.plannedDepartureTime ? isoToEAT(s.plannedDepartureTime) : '', }))); } setShowModal(true); @@ -433,8 +458,10 @@ export default function RoutesPage() { setEditingRoute(null); setOriginStationId(''); setOriginCheckinMinutes(undefined); + setOriginDepartureTime(''); setDestinationStationId(''); setDestinationCheckinMinutes(undefined); + setDestinationArrivalTime(''); setDestinationDistance(undefined); setStops([]); setShowModal(true); @@ -519,7 +546,7 @@ export default function RoutesPage() { setSearch(''); }} title={`${editingRoute ? 'Edit' : 'Add'} Route`} - size="lg" + size="xl" >
{editingRoute && ( @@ -665,7 +692,7 @@ export default function RoutesPage() {
- Drag to rearrange · Cutoff min overrides route check-in window per stop (leave blank to inherit) + Drag to rearrange · Cutoff overrides check-in · Arr/Dep time sets default times (auto-filled on schedule creation)
@@ -683,7 +710,7 @@ export default function RoutesPage() { Select origin station above )}
-
+
-
0 km
+
+ +
+
0 km
{stops.map((stop, index) => ( @@ -729,7 +764,7 @@ export default function RoutesPage() { ))}
-
+
-
+
updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)} min={1} title="Check-in cutoff override (minutes) for this stop" />
+
+ updateStop(index, 'plannedDepartureTime', v)} + placeholder="Dep time" + label="Planned Departure" + /> +
+
+ updateStop(index, 'plannedArrivalTime', v)} + placeholder="Arr time" + label="Planned Arrival" + /> +
-
+
{destinationStationId && ( )}
-
+
+
+ {destinationStationId && ( + + )} +
+
{destinationStationId && ( + 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 { id: string; trainId: string; @@ -24,6 +43,13 @@ interface Schedule { destinationStation?: { id: string; name: string }; coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>; isPackageOnly?: boolean; + stopTimes?: Array<{ + sequence: number; + stationId: string; + plannedDepartureAt: string | null; + plannedArrivalAt: string | null; + station?: { name: string }; + }>; } interface Train { @@ -72,6 +98,8 @@ export default function SchedulesPage() { const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); 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({ queryKey: ['route-coaches', addForm.routeId], @@ -79,12 +107,42 @@ export default function SchedulesPage() { enabled: !!addForm.routeId, }); + const { data: addRouteDetail } = useQuery({ + queryKey: ['route-detail', addForm.routeId], + queryFn: () => apiClient.get(`/routes/${addForm.routeId}`), + enabled: !!addForm.routeId, + }); + + const { data: editRouteDetail } = useQuery({ + queryKey: ['route-detail', editingSchedule?.routeId], + queryFn: () => apiClient.get(`/routes/${editingSchedule!.routeId}`), + enabled: !!editingSchedule?.routeId, + }); + useEffect(() => { if (!addForm.routeId) { setAddCoachRows([]); return; } 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 })) : []); }, [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 const { data: routeTemplate, isLoading: templateLoading } = useQuery({ queryKey: ['route-coaches', bulkForm.routeId], @@ -110,6 +168,24 @@ export default function SchedulesPage() { 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({ search: '', trainId: '', @@ -175,6 +251,7 @@ export default function SchedulesPage() { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); + setAddStopTimes([]); setError(null); }, onError: (err: any) => { @@ -189,6 +266,7 @@ export default function SchedulesPage() { queryClient.invalidateQueries({ queryKey: ['schedules'] }); setShowEditModal(false); setEditingSchedule(null); + setEditStopTimes([]); setError(null); }, onError: (err: any) => { @@ -255,15 +333,30 @@ export default function SchedulesPage() { const handleAddSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - const dep = new Date(addForm.departureAt); - const arr = new Date(addForm.arrivalAt); - if (arr <= dep) { setError('Arrival must be after departure'); return; } + if (!addForm.departureAt || !addForm.arrivalAt) { + setError('Please select departure and arrival date & time'); + 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); await createScheduleMutation.mutateAsync({ trainId: addForm.trainId, routeId: addForm.routeId, - departureAt: dep.toISOString(), - arrivalAt: arr.toISOString(), + departureAt: eatToISO(addForm.departureAt), + arrivalAt: eatToISO(addForm.arrivalAt), + ...(plannedTimes ? { plannedTimes } : {}), ...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }), }); }; @@ -274,24 +367,35 @@ export default function SchedulesPage() { if (!editingSchedule) return; - // Convert local datetime-local values to UTC for API - const depLocal = new Date(editForm.departureAt); - const arrLocal = new Date(editForm.arrivalAt); - - if (arrLocal <= depLocal) { + if (!editForm.departureAt || !editForm.arrivalAt) { + setError('Please select departure and arrival date & time'); + return; + } + + if (new Date(editForm.arrivalAt + ':00Z') <= new Date(editForm.departureAt + ':00Z')) { setError('Arrival time must be after departure time'); 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 = { - departureAt: depLocal.toISOString(), - arrivalAt: arrLocal.toISOString(), + departureAt: eatToISO(editForm.departureAt), + arrivalAt: eatToISO(editForm.arrivalAt), status: editForm.status, isPackageOnly: editForm.isPackageOnly, coaches: editForm.coachIds.map((coachId: string, idx: number) => ({ coachId, positionNumber: idx + 1, })), + ...(editPlannedTimes ? { plannedTimes: editPlannedTimes } : {}), }; await updateScheduleMutation.mutateAsync({ @@ -327,26 +431,26 @@ export default function SchedulesPage() { const handleEditClick = (schedule: 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({ - departureAt: depStr, - arrivalAt: arrStr, + departureAt: isoToEAT(schedule.departureAt), + arrivalAt: isoToEAT(schedule.arrivalAt), status: schedule.status, coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [], 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); setShowEditModal(true); }; @@ -672,7 +776,7 @@ export default function SchedulesPage() { { 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" size="lg" > @@ -699,14 +803,114 @@ export default function SchedulesPage() {
- setAddForm({ ...addForm, departureAt: e.target.value })} required /> + setAddForm({ ...addForm, departureAt: v })} + placeholder="Select departure" + />
- setAddForm({ ...addForm, arrivalAt: e.target.value })} required /> + setAddForm({ ...addForm, arrivalAt: v })} + placeholder="Select arrival" + />
+ {addStopTimes.length > 0 && ( +
+
+
+ +

+ Set planned times for each stop. Leave all blank to auto-generate from distance. +

+
+ +
+
+ + + + + + + + + + + {addStopTimes.map((stop, i) => { + const isFirst = i === 0; + const isLast = i === addStopTimes.length - 1; + return ( + + + + + + + ); + })} + +
#StationPlanned ArrivalPlanned Departure
{stop.sequence}{stop.stationName} + {isFirst ? ( + + ) : ( + { + const updated = [...addStopTimes]; + updated[i] = { ...updated[i], plannedArrivalAt: v }; + setAddStopTimes(updated); + }} + placeholder="Pick arrival" + /> + )} + + {isLast ? ( + + ) : ( + { + const updated = [...addStopTimes]; + updated[i] = { ...updated[i], plannedDepartureAt: v }; + setAddStopTimes(updated); + }} + placeholder="Pick departure" + /> + )} +
+
+
+ )} +
@@ -1006,6 +1210,7 @@ export default function SchedulesPage() { onClose={() => { setShowEditModal(false); setEditingSchedule(null); + setEditStopTimes([]); setError(null); }} title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} → ${editingSchedule?.destinationStation?.name ?? ''}`} @@ -1022,23 +1227,19 @@ export default function SchedulesPage() {
- setEditForm({ ...editForm, departureAt: e.target.value })} - className="input" - required + onChange={(v) => setEditForm({ ...editForm, departureAt: v })} + placeholder="Select departure" />
- setEditForm({ ...editForm, arrivalAt: e.target.value })} - className="input" - required + onChange={(v) => setEditForm({ ...editForm, arrivalAt: v })} + placeholder="Select arrival" />
@@ -1072,6 +1273,101 @@ export default function SchedulesPage() {
+ {editStopTimes.length > 0 && ( +
+
+
+ +

+ Edit planned times for each stop. All stops must be filled to update. +

+
+ +
+
+ + + + + + + + + + + {editStopTimes.map((stop, i) => { + const isFirst = i === 0; + const isLast = i === editStopTimes.length - 1; + return ( + + + + + + + ); + })} + +
#StationPlanned ArrivalPlanned Departure
{stop.sequence}{stop.stationName} + {isFirst ? ( + + ) : ( + { + const updated = [...editStopTimes]; + updated[i] = { ...updated[i], plannedArrivalAt: v }; + setEditStopTimes(updated); + }} + placeholder="Pick arrival" + /> + )} + + {isLast ? ( + + ) : ( + { + const updated = [...editStopTimes]; + updated[i] = { ...updated[i], plannedDepartureAt: v }; + setEditStopTimes(updated); + }} + placeholder="Pick departure" + /> + )} +
+
+
+ )} +
@@ -1129,6 +1425,7 @@ export default function SchedulesPage() { onClick={() => { setShowEditModal(false); setEditingSchedule(null); + setEditStopTimes([]); setError(null); }} > diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 62fa655c7..0f9666695 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -80,13 +80,13 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Master Data', items: [ - { name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.stations.view }, - { name: 'Trains', href: '/trains', icon: Train, permission: PERMS.trains.view }, - { name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.coaches.view }, - { name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.seats.view }, - { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view }, - { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, - { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, + { name: 'Stations', href: '/stations', icon: MapPin }, + { name: 'Trains', href: '/trains', icon: Train }, + { name: 'Coaches', href: '/coaches', icon: Grid3x3 }, + { name: 'Seats', href: '/seats', icon: Armchair }, + { name: 'Classes', href: '/classes', icon: Settings }, + { name: 'Routes', href: '/routes', icon: Route }, + { name: 'Schedules', href: '/schedules', icon: Calendar }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx new file mode 100644 index 000000000..6e71fc268 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx @@ -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): 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(parsed?.date); + const [hours12, setHours12] = useState(parsed?.hours12 ?? 12); + const [minutes, setMinutes] = useState(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( +
+ {/* Backdrop */} +
setOpen(false)} + /> + + {/* Panel */} +
+ {/* Header */} +
+

+ {label ?? placeholder} +

+ +
+ + {/* Calendar */} + + orientation === 'left' ? ( + + ) : ( + + ), + DayButton: ({ day, modifiers, className, ...props }) => ( + + 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" + /> + +
+ + : + + {/* Minute spinner */} +
+ + 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" + /> + +
+ + {/* AM / PM */} +
+ + +
+
+
+ + {/* Confirm */} + +
+
, + document.body, + ) : null; + + const displayText = parsed ? formatDisplay(parsed) : placeholder; + + return ( +
+ + {modal} +
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 108b45d8b..6ace365ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -953,6 +953,9 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 + react-day-picker: + specifier: ^9.14.0 + version: 9.14.0(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -22123,6 +22126,14 @@ snapshots: date-fns: 3.6.0 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): dependencies: '@date-fns/tz': 1.5.0