mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 13:28:11 +00:00
@@ -1,7 +1,6 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||||
import { TicketsService } from './tickets.service';
|
import { TicketsService } from './tickets.service';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
|
||||||
import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards';
|
import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards';
|
||||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
@@ -24,11 +23,10 @@ export class TicketsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('generate/:bookingId')
|
@Post('generate/:bookingId')
|
||||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
@SetMetadata('isPublic', true)
|
||||||
@ApiBearerAuth('IAM-auth')
|
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Generate ticket for booking (confirmation page)',
|
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.'
|
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) {
|
generateTicket(@Param('bookingId') bookingId: string) {
|
||||||
return this.service.generate(bookingId);
|
return this.service.generate(bookingId);
|
||||||
@@ -45,8 +43,8 @@ export class TicketsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(JwtGuard)
|
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'List all tickets with optional filters' })
|
@ApiOperation({ summary: 'List all tickets with optional filters' })
|
||||||
@ApiQuery({ name: 'search', required: false })
|
@ApiQuery({ name: 'search', required: false })
|
||||||
@ApiQuery({ name: 'status', required: false })
|
@ApiQuery({ name: 'status', required: false })
|
||||||
@@ -88,8 +86,8 @@ export class TicketsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('by-order/:merchantOrderId')
|
@Get('by-order/:merchantOrderId')
|
||||||
@UseGuards(JwtGuard)
|
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Get ticket by merchant order ID',
|
summary: 'Get ticket by merchant order ID',
|
||||||
description: 'Looks up the booking ID from the PaymentIntent using merchantOrderId, then returns the full ticket information.'
|
description: 'Looks up the booking ID from the PaymentIntent using merchantOrderId, then returns the full ticket information.'
|
||||||
@@ -106,8 +104,8 @@ export class TicketsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('scan-board/:qrCodeOrRef')
|
@Post('scan-board/:qrCodeOrRef')
|
||||||
@UseGuards(JwtGuard)
|
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Scan QR code or booking ref and automatically board ticket',
|
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.'
|
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 +129,8 @@ export class TicketsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':bookingRef/validate')
|
@Post(':bookingRef/validate')
|
||||||
@UseGuards(JwtGuard)
|
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Validate ticket at gate with audit logging',
|
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.'
|
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 +160,24 @@ export class TicketsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':ticketId/validation-logs')
|
@Get(':ticketId/validation-logs')
|
||||||
@UseGuards(JwtGuard)
|
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Get validation logs for ticket' })
|
@ApiOperation({ summary: 'Get validation logs for ticket' })
|
||||||
getValidationLogs(@Param('ticketId') ticketId: string) {
|
getValidationLogs(@Param('ticketId') ticketId: string) {
|
||||||
return this.service.getValidationLogs(ticketId);
|
return this.service.getValidationLogs(ticketId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('offline/export')
|
@Get('offline/export')
|
||||||
@UseGuards(JwtGuard)
|
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Export tickets for offline validation' })
|
@ApiOperation({ summary: 'Export tickets for offline validation' })
|
||||||
exportOfflineData(@Query('scheduleId') scheduleId: string) {
|
exportOfflineData(@Query('scheduleId') scheduleId: string) {
|
||||||
return this.service.exportOfflineData(scheduleId);
|
return this.service.exportOfflineData(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('validate/offline')
|
@Post('validate/offline')
|
||||||
@UseGuards(JwtGuard)
|
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Batch import offline validations',
|
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.'
|
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 +219,8 @@ export class TicketsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/restore')
|
@Patch(':id/restore')
|
||||||
@UseGuards(JwtGuard)
|
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
|
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
|
||||||
restore(@Param('id') id: string) {
|
restore(@Param('id') id: string) {
|
||||||
return this.service.restore(id);
|
return this.service.restore(id);
|
||||||
|
|||||||
@@ -81,15 +81,6 @@ function BookingsPageContent() {
|
|||||||
const forceConfirmMutation = useMutation({
|
const forceConfirmMutation = useMutation({
|
||||||
mutationFn: ({ bookingId, data }: { bookingId: string; data: { paymentReference?: string; paymentMethod?: string; notes?: string } }) =>
|
mutationFn: ({ bookingId, data }: { bookingId: string; data: { paymentReference?: string; paymentMethod?: string; notes?: string } }) =>
|
||||||
bookingsApi.forceConfirm(bookingId, data),
|
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({
|
const deleteMutation = useMutation({
|
||||||
@@ -649,7 +640,13 @@ function BookingsPageContent() {
|
|||||||
setGenerateTicketTouched({ paymentReference: true, paymentMethod: true });
|
setGenerateTicketTouched({ paymentReference: true, paymentMethod: true });
|
||||||
if (!generateTicketForm.paymentReference || !generateTicketForm.paymentMethod) return;
|
if (!generateTicketForm.paymentReference || !generateTicketForm.paymentMethod) return;
|
||||||
forceConfirmMutation.reset();
|
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}
|
disabled={forceConfirmMutation.isPending || smartAssignMutation.isPending}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { routeCoachTemplatesApi } from '@/lib/api';
|
|||||||
import Pagination from '@/components/ui/Pagination';
|
import Pagination from '@/components/ui/Pagination';
|
||||||
import { usePagination } from '@/lib/use-pagination';
|
import { usePagination } from '@/lib/use-pagination';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
import DateTimePicker from '@/components/ui/DateTimePicker';
|
||||||
|
|
||||||
interface Schedule {
|
interface Schedule {
|
||||||
id: string;
|
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<HTMLFormElement>) => {
|
const handleBulkSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -240,7 +255,7 @@ export default function SchedulesPage() {
|
|||||||
const payload: any = {
|
const payload: any = {
|
||||||
trainId: bulkForm.trainId,
|
trainId: bulkForm.trainId,
|
||||||
routeId: bulkForm.routeId,
|
routeId: bulkForm.routeId,
|
||||||
startDateTime: bulkForm.startDateTime,
|
startDateTime: eatLocalToISO(bulkForm.startDateTime),
|
||||||
durationHours: parseInt(bulkForm.durationHours),
|
durationHours: parseInt(bulkForm.durationHours),
|
||||||
repeatEveryDays: parseInt(bulkForm.repeatEveryDays),
|
repeatEveryDays: parseInt(bulkForm.repeatEveryDays),
|
||||||
forNextDays: parseInt(bulkForm.forNextDays),
|
forNextDays: parseInt(bulkForm.forNextDays),
|
||||||
@@ -257,8 +272,8 @@ 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);
|
const dep = new Date(eatLocalToISO(addForm.departureAt));
|
||||||
const arr = new Date(addForm.arrivalAt);
|
const arr = new Date(eatLocalToISO(addForm.arrivalAt));
|
||||||
if (arr <= dep) { setError('Arrival must be after departure'); return; }
|
if (arr <= dep) { setError('Arrival must be after departure'); return; }
|
||||||
const validCoaches = addCoachRows.filter((r) => r.coachId);
|
const validCoaches = addCoachRows.filter((r) => r.coachId);
|
||||||
await createScheduleMutation.mutateAsync({
|
await createScheduleMutation.mutateAsync({
|
||||||
@@ -276,18 +291,18 @@ 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('Departure and arrival times are required');
|
||||||
const arrLocal = new Date(editForm.arrivalAt);
|
return;
|
||||||
|
}
|
||||||
if (arrLocal <= depLocal) {
|
if (new Date(eatLocalToISO(editForm.arrivalAt)) <= new Date(eatLocalToISO(editForm.departureAt))) {
|
||||||
setError('Arrival time must be after departure time');
|
setError('Arrival time must be after departure time');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
departureAt: depLocal.toISOString(),
|
departureAt: eatLocalToISO(editForm.departureAt),
|
||||||
arrivalAt: arrLocal.toISOString(),
|
arrivalAt: eatLocalToISO(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) => ({
|
||||||
@@ -328,23 +343,9 @@ 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: isoToEATLocal(schedule.departureAt),
|
||||||
arrivalAt: arrStr,
|
arrivalAt: isoToEATLocal(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,
|
||||||
@@ -681,7 +682,7 @@ export default function SchedulesPage() {
|
|||||||
isOpen={showAddModal}
|
isOpen={showAddModal}
|
||||||
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}
|
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}
|
||||||
title="Add Schedule"
|
title="Add Schedule"
|
||||||
size="lg"
|
size="xl"
|
||||||
>
|
>
|
||||||
<form onSubmit={handleAddSubmit} className="space-y-4">
|
<form onSubmit={handleAddSubmit} className="space-y-4">
|
||||||
{error && <div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">{error}</div>}
|
{error && <div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">{error}</div>}
|
||||||
@@ -703,15 +704,19 @@ export default function SchedulesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<DateTimePicker
|
||||||
<label className="label">Departure *</label>
|
label="Departure *"
|
||||||
<input type="datetime-local" className="input" value={addForm.departureAt} onChange={(e) => setAddForm({ ...addForm, departureAt: e.target.value })} required />
|
value={addForm.departureAt}
|
||||||
</div>
|
onChange={(v) => setAddForm({ ...addForm, departureAt: v })}
|
||||||
<div>
|
required
|
||||||
<label className="label">Arrival *</label>
|
/>
|
||||||
<input type="datetime-local" className="input" value={addForm.arrivalAt} onChange={(e) => setAddForm({ ...addForm, arrivalAt: e.target.value })} required />
|
<DateTimePicker
|
||||||
</div>
|
label="Arrival *"
|
||||||
|
value={addForm.arrivalAt}
|
||||||
|
onChange={(v) => setAddForm({ ...addForm, arrivalAt: v })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
@@ -839,16 +844,12 @@ export default function SchedulesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<DateTimePicker
|
||||||
<label className="label">Departure Date & Time *</label>
|
label="Departure Date & Time *"
|
||||||
<input
|
value={bulkForm.startDateTime}
|
||||||
type="datetime-local"
|
onChange={(v) => setBulkForm({ ...bulkForm, startDateTime: v })}
|
||||||
value={bulkForm.startDateTime}
|
required
|
||||||
onChange={(e) => setBulkForm({ ...bulkForm, startDateTime: e.target.value })}
|
/>
|
||||||
className="input"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -1016,7 +1017,7 @@ export default function SchedulesPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} → ${editingSchedule?.destinationStation?.name ?? ''}`}
|
title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} → ${editingSchedule?.destinationStation?.name ?? ''}`}
|
||||||
size="lg"
|
size="xl"
|
||||||
>
|
>
|
||||||
{editingSchedule && (
|
{editingSchedule && (
|
||||||
<form onSubmit={handleEditSubmit} className="space-y-4">
|
<form onSubmit={handleEditSubmit} className="space-y-4">
|
||||||
@@ -1027,27 +1028,18 @@ 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>
|
<DateTimePicker
|
||||||
<label className="label">Departure Date & Time *</label>
|
label="Departure Date & Time *"
|
||||||
<input
|
value={editForm.departureAt}
|
||||||
type="datetime-local"
|
onChange={(v) => setEditForm({ ...editForm, departureAt: v })}
|
||||||
value={editForm.departureAt}
|
required
|
||||||
onChange={(e) => setEditForm({ ...editForm, departureAt: e.target.value })}
|
/>
|
||||||
className="input"
|
<DateTimePicker
|
||||||
required
|
label="Arrival Date & Time *"
|
||||||
/>
|
value={editForm.arrivalAt}
|
||||||
</div>
|
onChange={(v) => setEditForm({ ...editForm, arrivalAt: v })}
|
||||||
|
required
|
||||||
<div>
|
/>
|
||||||
<label className="label">Arrival Date & Time *</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={editForm.arrivalAt}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, arrivalAt: e.target.value })}
|
|
||||||
className="input"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,358 +1,102 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
/**
|
||||||
import { createPortal } from 'react-dom';
|
* DateTimePicker — label + date / hour / minute / AM-PM all on one row.
|
||||||
import { DayPicker } from 'react-day-picker';
|
*
|
||||||
import { ChevronLeft, ChevronRight, Calendar, ChevronUp, ChevronDown, X } from 'lucide-react';
|
* Value contract:
|
||||||
import { cn } from '@/lib/utils';
|
* value : "YYYY-MM-DDTHH:mm" (24-hr, EAT local)
|
||||||
|
* onChange: called with the same shape whenever any part changes
|
||||||
|
*/
|
||||||
|
|
||||||
interface DateTimePickerProps {
|
interface DateTimePickerProps {
|
||||||
value: string; // YYYY-MM-DDTHH:mm (datetime-local format)
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (v: string) => void;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
id?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseLocalString(s: string) {
|
const HOURS = Array.from({ length: 12 }, (_, i) => String(i === 0 ? 12 : i).padStart(2, '0'));
|
||||||
if (!s) return null;
|
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
|
||||||
const [datePart, timePart] = s.split('T');
|
|
||||||
if (!datePart || !timePart) return null;
|
function parse(value: string) {
|
||||||
const [yyyy, mm, dd] = datePart.split('-').map(Number);
|
if (!value) return { date: '', h24: 0, min: 0 };
|
||||||
const [h, m] = timePart.split(':').map(Number);
|
const [datePart, timePart] = value.split('T');
|
||||||
if (isNaN(yyyy) || isNaN(mm) || isNaN(dd) || isNaN(h) || isNaN(m)) return null;
|
const [hStr, mStr] = (timePart ?? '00:00').split(':');
|
||||||
const period: 'AM' | 'PM' = h >= 12 ? 'PM' : 'AM';
|
return { date: datePart ?? '', h24: parseInt(hStr ?? '0'), min: parseInt(mStr ?? '0') };
|
||||||
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') {
|
function build(date: string, h24: number, min: number): string {
|
||||||
let h = hours12 % 12;
|
if (!date) return '';
|
||||||
if (period === 'PM') h += 12;
|
return `${date}T${String(h24).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
|
||||||
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 {
|
export default function DateTimePicker({ value, onChange, required, label }: DateTimePickerProps) {
|
||||||
if (!parsed) return '';
|
const { date, h24, min } = parse(value);
|
||||||
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({
|
const isPM = h24 >= 12;
|
||||||
value,
|
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
|
||||||
onChange,
|
const minStr = String(min).padStart(2, '0');
|
||||||
id,
|
|
||||||
placeholder = 'Select date & time',
|
|
||||||
label,
|
|
||||||
}: DateTimePickerProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => { setMounted(true); }, []);
|
const emit = (newDate: string, newH24: number, newMin: number) =>
|
||||||
|
onChange(build(newDate, newH24, newMin));
|
||||||
|
|
||||||
const parsed = parseLocalString(value);
|
const handleHour = (v: string) => {
|
||||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(parsed?.date);
|
const h = parseInt(v);
|
||||||
const [hours12, setHours12] = useState<number>(parsed?.hours12 ?? 12);
|
const next24 = isPM ? (h === 12 ? 12 : h + 12) : (h === 12 ? 0 : h);
|
||||||
const [minutes, setMinutes] = useState<number>(parsed?.minutes ?? 0);
|
emit(date, next24, min);
|
||||||
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 handleAmPm = (v: string) => {
|
||||||
const next = hours12 + dir;
|
const pm = v === 'PM';
|
||||||
const h = next > 12 ? 1 : next < 1 ? 12 : next;
|
let next24 = h24;
|
||||||
setHours12(h);
|
if (pm && h24 < 12) next24 = h24 + 12;
|
||||||
emit(selectedDate, h, minutes, period);
|
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(
|
|
||||||
<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 (
|
return (
|
||||||
<div className="relative">
|
<div>
|
||||||
<button
|
{label && (
|
||||||
type="button"
|
<label className="label">
|
||||||
id={id}
|
{label}
|
||||||
onClick={() => setOpen(true)}
|
</label>
|
||||||
className={cn(
|
)}
|
||||||
'input flex items-center gap-2 text-left cursor-pointer',
|
<div className="flex items-center gap-2">
|
||||||
!parsed && 'text-muted-foreground',
|
{/* Date */}
|
||||||
)}
|
<input
|
||||||
>
|
type="date"
|
||||||
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
|
className="input text-sm w-36 shrink-0"
|
||||||
<span className="flex-1 text-sm">{displayText}</span>
|
value={date}
|
||||||
</button>
|
required={required}
|
||||||
{modal}
|
onChange={(e) => emit(e.target.value, h24, min)}
|
||||||
|
/>
|
||||||
|
{/* Hour */}
|
||||||
|
<select
|
||||||
|
className="input text-sm w-16 shrink-0"
|
||||||
|
value={String(h12).padStart(2, '0')}
|
||||||
|
onChange={(e) => handleHour(e.target.value)}
|
||||||
|
>
|
||||||
|
{HOURS.map((h) => <option key={h} value={h}>{h}</option>)}
|
||||||
|
</select>
|
||||||
|
<span className="text-muted-foreground font-bold shrink-0">:</span>
|
||||||
|
{/* Minute */}
|
||||||
|
<select
|
||||||
|
className="input text-sm w-16 shrink-0"
|
||||||
|
value={minStr}
|
||||||
|
onChange={(e) => emit(date, h24, parseInt(e.target.value))}
|
||||||
|
>
|
||||||
|
{MINUTES.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||||
|
</select>
|
||||||
|
{/* AM / PM */}
|
||||||
|
<select
|
||||||
|
className="input text-sm w-16 shrink-0"
|
||||||
|
value={isPM ? 'PM' : 'AM'}
|
||||||
|
onChange={(e) => handleAmPm(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="AM">AM</option>
|
||||||
|
<option value="PM">PM</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user