From de1736580220e23402fd372c464da694d0e69d8a Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 11:06:16 +0300 Subject: [PATCH 1/3] Separate ticket generation for portal and back office --- .../src/modules/tickets/tickets.controller.ts | 49 +++++++++++-------- .../backoffice/src/lib/api/index.ts | 2 + 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index d82b24f71..9aa21be44 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -1,7 +1,6 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; -import { JwtGuard } from '../../common/jwt.guard'; import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @@ -24,13 +23,23 @@ export class TicketsController { } @Post('generate/:bookingId') + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: 'Generate ticket for booking (confirmation page)', + description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records. Requires payment to be SUCCEEDED and booking to be CONFIRMED.' + }) + generateTicket(@Param('bookingId') bookingId: string) { + return this.service.generate(bookingId); + } + + @Post('force-generate/:bookingId') @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') @ApiOperation({ - summary: 'Generate ticket for booking (confirmation page)', - description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.' + summary: 'Force-generate ticket for booking (staff only)', + description: 'Staff override: regenerates tickets for a confirmed booking regardless of prior state.' }) - generateTicket(@Param('bookingId') bookingId: string) { + forceGenerateTicket(@Param('bookingId') bookingId: string) { return this.service.generate(bookingId); } @@ -45,8 +54,8 @@ export class TicketsController { } @Get() - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'List all tickets with optional filters' }) @ApiQuery({ name: 'search', required: false }) @ApiQuery({ name: 'status', required: false }) @@ -88,8 +97,8 @@ export class TicketsController { } @Get('by-order/:merchantOrderId') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Get ticket by merchant order ID', description: 'Looks up the booking ID from the PaymentIntent using merchantOrderId, then returns the full ticket information.' @@ -106,8 +115,8 @@ export class TicketsController { } @Post('scan-board/:qrCodeOrRef') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Scan QR code or booking ref and automatically board ticket', description: 'Scans ticket QR code or booking reference and automatically boards the passenger. Handles errors like expired tickets, already used tickets, etc. Designed for mobile boarding interface.' @@ -131,8 +140,8 @@ export class TicketsController { } @Post(':bookingRef/validate') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Validate ticket at gate with audit logging', description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.' @@ -162,24 +171,24 @@ export class TicketsController { } @Get(':ticketId/validation-logs') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Get validation logs for ticket' }) getValidationLogs(@Param('ticketId') ticketId: string) { return this.service.getValidationLogs(ticketId); } @Get('offline/export') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Export tickets for offline validation' }) exportOfflineData(@Query('scheduleId') scheduleId: string) { return this.service.exportOfflineData(scheduleId); } @Post('validate/offline') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Batch import offline validations', description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.' @@ -221,8 +230,8 @@ export class TicketsController { } @Patch(':id/restore') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' }) restore(@Param('id') id: string) { return this.service.restore(id); diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 1b917afe2..095500450 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -48,6 +48,8 @@ export const bookingsApi = { apiClient.post(`/payments/${bookingId}/force-confirm`, data), smartAssign: (bookingId: string) => apiClient.post(`/tickets/smart-assign/${bookingId}`, {}), + forceGenerate: (bookingId: string) => + apiClient.post(`/tickets/force-generate/${bookingId}`, {}), }; // Passengers API From e45c1bcfe2f408384f9579f545a726adc478cc81 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 11:15:30 +0300 Subject: [PATCH 2/3] Ticket generation issue resolution --- .../src/modules/tickets/tickets.controller.ts | 11 ----------- .../backoffice/src/app/bookings/page.tsx | 17 +++++++---------- .../backoffice/src/lib/api/index.ts | 2 -- 3 files changed, 7 insertions(+), 23 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 9aa21be44..cc53be263 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -32,17 +32,6 @@ export class TicketsController { return this.service.generate(bookingId); } - @Post('force-generate/:bookingId') - @PassengerStaff(PASSENGER_PERMS.tickets.generate) - @ApiBearerAuth('IAM-auth') - @ApiOperation({ - summary: 'Force-generate ticket for booking (staff only)', - description: 'Staff override: regenerates tickets for a confirmed booking regardless of prior state.' - }) - forceGenerateTicket(@Param('bookingId') bookingId: string) { - return this.service.generate(bookingId); - } - @Patch('update-seats/:bookingId') @SetMetadata('isPublic', true) @ApiOperation({ diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 07013269a..66b5c6c5a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -81,15 +81,6 @@ function BookingsPageContent() { const forceConfirmMutation = useMutation({ mutationFn: ({ bookingId, data }: { bookingId: string; data: { paymentReference?: string; paymentMethod?: string; notes?: string } }) => bookingsApi.forceConfirm(bookingId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['bookings'] }); - setSuccessMessage('Payment confirmed and ticket generated successfully'); - setTimeout(() => setSuccessMessage(''), 4000); - setSelectedBooking(null); - setGenerateTicketBooking(null); - setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); - setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); - }, }); const deleteMutation = useMutation({ @@ -649,7 +640,13 @@ function BookingsPageContent() { setGenerateTicketTouched({ paymentReference: true, paymentMethod: true }); if (!generateTicketForm.paymentReference || !generateTicketForm.paymentMethod) return; forceConfirmMutation.reset(); - smartAssignMutation.mutate(generateTicketBooking.id); + smartAssignMutation.reset(); + forceConfirmMutation.mutate( + { bookingId: generateTicketBooking.id, data: { paymentReference: generateTicketForm.paymentReference, paymentMethod: generateTicketForm.paymentMethod, notes: generateTicketForm.notes } }, + { + onSuccess: () => smartAssignMutation.mutate(generateTicketBooking.id), + }, + ); }} disabled={forceConfirmMutation.isPending || smartAssignMutation.isPending} > diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 095500450..1b917afe2 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -48,8 +48,6 @@ export const bookingsApi = { apiClient.post(`/payments/${bookingId}/force-confirm`, data), smartAssign: (bookingId: string) => apiClient.post(`/tickets/smart-assign/${bookingId}`, {}), - forceGenerate: (bookingId: string) => - apiClient.post(`/tickets/force-generate/${bookingId}`, {}), }; // Passengers API From 32e1c5e5704857816100a255994a3e1ba52be005 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 11:44:24 +0300 Subject: [PATCH 3/3] Schedule date and time picket updates --- .../backoffice/src/app/schedules/page.tsx | 128 +++--- .../src/components/ui/DateTimePicker.tsx | 414 ++++-------------- 2 files changed, 139 insertions(+), 403 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index b9b2765a7..0f37825fb 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -12,6 +12,7 @@ import { routeCoachTemplatesApi } from '@/lib/api'; import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; import { formatDateTime } from '@/lib/utils'; +import DateTimePicker from '@/components/ui/DateTimePicker'; interface Schedule { id: string; @@ -228,6 +229,20 @@ export default function SchedulesPage() { }, }); + /** Parse a datetime-local string ("YYYY-MM-DDTHH:mm") as EAT (UTC+3) and return an ISO string. */ + const eatLocalToISO = (local: string): string => { + if (!local) return ''; + return new Date(local + ':00+03:00').toISOString(); + }; + + /** Convert a UTC ISO string to a datetime-local value in EAT (UTC+3). */ + const isoToEATLocal = (iso: string): string => { + if (!iso) return ''; + const utcMs = new Date(iso).getTime(); + const eatMs = utcMs + 3 * 60 * 60 * 1000; + return new Date(eatMs).toISOString().slice(0, 16); + }; + const handleBulkSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -240,7 +255,7 @@ export default function SchedulesPage() { const payload: any = { trainId: bulkForm.trainId, routeId: bulkForm.routeId, - startDateTime: bulkForm.startDateTime, + startDateTime: eatLocalToISO(bulkForm.startDateTime), durationHours: parseInt(bulkForm.durationHours), repeatEveryDays: parseInt(bulkForm.repeatEveryDays), forNextDays: parseInt(bulkForm.forNextDays), @@ -257,8 +272,8 @@ 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); + const dep = new Date(eatLocalToISO(addForm.departureAt)); + const arr = new Date(eatLocalToISO(addForm.arrivalAt)); if (arr <= dep) { setError('Arrival must be after departure'); return; } const validCoaches = addCoachRows.filter((r) => r.coachId); await createScheduleMutation.mutateAsync({ @@ -276,18 +291,18 @@ 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('Departure and arrival times are required'); + return; + } + if (new Date(eatLocalToISO(editForm.arrivalAt)) <= new Date(eatLocalToISO(editForm.departureAt))) { setError('Arrival time must be after departure time'); return; } const payload: any = { - departureAt: depLocal.toISOString(), - arrivalAt: arrLocal.toISOString(), + departureAt: eatLocalToISO(editForm.departureAt), + arrivalAt: eatLocalToISO(editForm.arrivalAt), status: editForm.status, isPackageOnly: editForm.isPackageOnly, coaches: editForm.coachIds.map((coachId: string, idx: number) => ({ @@ -328,23 +343,9 @@ 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: isoToEATLocal(schedule.departureAt), + arrivalAt: isoToEATLocal(schedule.arrivalAt), status: schedule.status, coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [], isPackageOnly: schedule.isPackageOnly ?? false, @@ -681,7 +682,7 @@ export default function SchedulesPage() { isOpen={showAddModal} onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }} title="Add Schedule" - size="lg" + size="xl" >
{error &&
{error}
} @@ -703,15 +704,19 @@ export default function SchedulesPage() { -
-
- - setAddForm({ ...addForm, departureAt: e.target.value })} required /> -
-
- - setAddForm({ ...addForm, arrivalAt: e.target.value })} required /> -
+
+ setAddForm({ ...addForm, departureAt: v })} + required + /> + setAddForm({ ...addForm, arrivalAt: v })} + required + />
@@ -839,16 +844,12 @@ export default function SchedulesPage() {
-
- - setBulkForm({ ...bulkForm, startDateTime: e.target.value })} - className="input" - required - /> -
+ setBulkForm({ ...bulkForm, startDateTime: v })} + required + />
@@ -1016,7 +1017,7 @@ export default function SchedulesPage() { setError(null); }} title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} → ${editingSchedule?.destinationStation?.name ?? ''}`} - size="lg" + size="xl" > {editingSchedule && ( @@ -1027,27 +1028,18 @@ export default function SchedulesPage() { )}
-
- - setEditForm({ ...editForm, departureAt: e.target.value })} - className="input" - required - /> -
- -
- - setEditForm({ ...editForm, arrivalAt: e.target.value })} - className="input" - required - /> -
+ setEditForm({ ...editForm, departureAt: v })} + required + /> + setEditForm({ ...editForm, arrivalAt: v })} + required + />
diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx index 6e71fc268..2f3e05bae 100644 --- a/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx @@ -1,358 +1,102 @@ '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'; +/** + * DateTimePicker — label + date / hour / minute / AM-PM all on one row. + * + * Value contract: + * value : "YYYY-MM-DDTHH:mm" (24-hr, EAT local) + * onChange: called with the same shape whenever any part changes + */ interface DateTimePickerProps { - value: string; // YYYY-MM-DDTHH:mm (datetime-local format) - onChange: (value: string) => void; + value: string; + onChange: (v: 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 }; +const HOURS = Array.from({ length: 12 }, (_, i) => String(i === 0 ? 12 : i).padStart(2, '0')); +const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55']; + +function parse(value: string) { + if (!value) return { date: '', h24: 0, min: 0 }; + const [datePart, timePart] = value.split('T'); + const [hStr, mStr] = (timePart ?? '00:00').split(':'); + return { date: datePart ?? '', h24: parseInt(hStr ?? '0'), min: parseInt(mStr ?? '0') }; } -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 build(date: string, h24: number, min: number): string { + if (!date) return ''; + return `${date}T${String(h24).padStart(2, '0')}:${String(min).padStart(2, '0')}`; } -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, required, label }: DateTimePickerProps) { + const { date, h24, min } = parse(value); -export default function DateTimePicker({ - value, - onChange, - id, - placeholder = 'Select date & time', - label, -}: DateTimePickerProps) { - const [open, setOpen] = useState(false); - const [mounted, setMounted] = useState(false); + const isPM = h24 >= 12; + const h12 = h24 % 12 === 0 ? 12 : h24 % 12; + const minStr = String(min).padStart(2, '0'); - useEffect(() => { setMounted(true); }, []); + const emit = (newDate: string, newH24: number, newMin: number) => + onChange(build(newDate, newH24, newMin)); - 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 handleHour = (v: string) => { + const h = parseInt(v); + const next24 = isPM ? (h === 12 ? 12 : h + 12) : (h === 12 ? 0 : h); + emit(date, next24, min); }; - 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 handleAmPm = (v: string) => { + const pm = v === 'PM'; + let next24 = h24; + if (pm && h24 < 12) next24 = h24 + 12; + if (!pm && h24 >= 12) next24 = h24 - 12; + emit(date, next24, min); }; - 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} +
+ {label && ( + + )} +
+ {/* Date */} + emit(e.target.value, h24, min)} + /> + {/* Hour */} + + : + {/* Minute */} + + {/* AM / PM */} + +
); }