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