mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
Added stops departure and arrival datetime
This commit is contained in:
@@ -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<number | undefined>(undefined);
|
||||
const [originCheckinMinutes, setOriginCheckinMinutes] = useState<number | undefined>(undefined);
|
||||
const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState<number | undefined>(undefined);
|
||||
const [originDepartureTime, setOriginDepartureTime] = useState<string>('');
|
||||
const [destinationArrivalTime, setDestinationArrivalTime] = useState<string>('');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null });
|
||||
const [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"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
||||
{editingRoute && (
|
||||
@@ -665,7 +692,7 @@ export default function RoutesPage() {
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="label mb-0">Route Stops</label>
|
||||
<span className="text-xs text-muted-foreground">Drag to rearrange · <span className="font-medium">Cutoff min</span> overrides route check-in window per stop (leave blank to inherit)</span>
|
||||
<span className="text-xs text-muted-foreground">Drag to rearrange · <span className="font-medium">Cutoff</span> overrides check-in · <span className="font-medium">Arr/Dep time</span> sets default times (auto-filled on schedule creation)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -683,7 +710,7 @@ export default function RoutesPage() {
|
||||
<span className="text-muted-foreground">Select origin station above</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-28 flex-shrink-0">
|
||||
<div className="w-24 flex-shrink-0">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
@@ -694,7 +721,15 @@ export default function RoutesPage() {
|
||||
title="Check-in cutoff override (minutes) for this stop"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground w-12 text-right flex-shrink-0">0 km</div>
|
||||
<div className="w-44 flex-shrink-0">
|
||||
<DateTimePicker
|
||||
value={originDepartureTime}
|
||||
onChange={setOriginDepartureTime}
|
||||
placeholder="Dep time"
|
||||
label="Planned Departure"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground w-10 text-right flex-shrink-0">0 km</div>
|
||||
</div>
|
||||
|
||||
{stops.map((stop, index) => (
|
||||
@@ -729,7 +764,7 @@ export default function RoutesPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<div className="w-20">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
@@ -741,17 +776,33 @@ export default function RoutesPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<div className="w-20">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder="cutoff min"
|
||||
placeholder="cutoff"
|
||||
value={stop.checkinMinutesBefore ?? ''}
|
||||
onChange={(e) => updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min={1}
|
||||
title="Check-in cutoff override (minutes) for this stop"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-44">
|
||||
<DateTimePicker
|
||||
value={stop.plannedDepartureTime ?? ''}
|
||||
onChange={(v) => updateStop(index, 'plannedDepartureTime', v)}
|
||||
placeholder="Dep time"
|
||||
label="Planned Departure"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-44">
|
||||
<DateTimePicker
|
||||
value={stop.plannedArrivalTime ?? ''}
|
||||
onChange={(v) => updateStop(index, 'plannedArrivalTime', v)}
|
||||
placeholder="Arr time"
|
||||
label="Planned Arrival"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStop(index)}
|
||||
@@ -790,7 +841,7 @@ export default function RoutesPage() {
|
||||
<span className="text-muted-foreground">Select destination station above</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<div className="w-24">
|
||||
{destinationStationId && (
|
||||
<input
|
||||
type="number"
|
||||
@@ -803,7 +854,18 @@ export default function RoutesPage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<div className="w-44" />
|
||||
<div className="w-44">
|
||||
{destinationStationId && (
|
||||
<DateTimePicker
|
||||
value={destinationArrivalTime}
|
||||
onChange={setDestinationArrivalTime}
|
||||
placeholder="Arr time"
|
||||
label="Planned Arrival"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-24">
|
||||
{destinationStationId && (
|
||||
<input
|
||||
type="number"
|
||||
|
||||
@@ -7,10 +7,29 @@ import DataTable from '@/components/ui/DataTable';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import DateTimePicker from '@/components/ui/DateTimePicker';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { routeCoachTemplatesApi } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
// EAT is UTC+3. Convert without depending on the browser's own timezone.
|
||||
const EAT_MS = 3 * 60 * 60 * 1000;
|
||||
// UTC ISO string → EAT "YYYY-MM-DDTHH:mm" for DateTimePicker display
|
||||
const isoToEAT = (iso: string): string =>
|
||||
new Date(new Date(iso).getTime() + EAT_MS).toISOString().slice(0, 16);
|
||||
// EAT "YYYY-MM-DDTHH:mm" → UTC ISO string for API submission
|
||||
const eatToISO = (local: string): string =>
|
||||
new Date(new Date(local + ':00Z').getTime() - EAT_MS).toISOString();
|
||||
// Extract HH:mm in EAT from a UTC ISO datetime (e.g. route stop planned time)
|
||||
const isoToEATTimePart = (iso: string): string | null => {
|
||||
if (!iso) return null;
|
||||
const eatMs = new Date(iso).getTime() + EAT_MS;
|
||||
const msIntoDay = eatMs % (24 * 60 * 60 * 1000);
|
||||
const h = Math.floor(msIntoDay / 3600000);
|
||||
const m = Math.floor((msIntoDay % 3600000) / 60000);
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
interface Schedule {
|
||||
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<any>(`/routes/${addForm.routeId}`),
|
||||
enabled: !!addForm.routeId,
|
||||
});
|
||||
|
||||
const { data: editRouteDetail } = useQuery({
|
||||
queryKey: ['route-detail', editingSchedule?.routeId],
|
||||
queryFn: () => apiClient.get<any>(`/routes/${editingSchedule!.routeId}`),
|
||||
enabled: !!editingSchedule?.routeId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
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<HTMLFormElement>) => {
|
||||
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() {
|
||||
|
||||
<Modal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}
|
||||
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setAddStopTimes([]); setError(null); }}
|
||||
title="Add Schedule"
|
||||
size="lg"
|
||||
>
|
||||
@@ -699,14 +803,114 @@ export default function SchedulesPage() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Departure *</label>
|
||||
<input type="datetime-local" className="input" value={addForm.departureAt} onChange={(e) => setAddForm({ ...addForm, departureAt: e.target.value })} required />
|
||||
<DateTimePicker
|
||||
value={addForm.departureAt}
|
||||
onChange={(v) => setAddForm({ ...addForm, departureAt: v })}
|
||||
placeholder="Select departure"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Arrival *</label>
|
||||
<input type="datetime-local" className="input" value={addForm.arrivalAt} onChange={(e) => setAddForm({ ...addForm, arrivalAt: e.target.value })} required />
|
||||
<DateTimePicker
|
||||
value={addForm.arrivalAt}
|
||||
onChange={(v) => setAddForm({ ...addForm, arrivalAt: v })}
|
||||
placeholder="Select arrival"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{addStopTimes.length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<label className="label mb-0">Stop Times</label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Set planned times for each stop. Leave all blank to auto-generate from distance.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary underline"
|
||||
onClick={() => {
|
||||
const dep = addForm.departureAt ? new Date(addForm.departureAt + ':00Z') : null;
|
||||
const arr = addForm.arrivalAt ? new Date(addForm.arrivalAt + ':00Z') : null;
|
||||
if (!dep || !arr || arr <= dep) return;
|
||||
const stops = (addRouteDetail as any)?.stops ?? [];
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = stops[stops.length - 1]?.distanceKm || 1;
|
||||
setAddStopTimes(addStopTimes.map((s, i) => {
|
||||
const stop = stops.find((st: any) => st.sequence === s.sequence);
|
||||
const dist = stop?.distanceKm ?? 0;
|
||||
const t = new Date(dep.getTime() + (dist / totalDistance) * totalDuration);
|
||||
const fmt = t.toISOString().slice(0, 16);
|
||||
return {
|
||||
...s,
|
||||
plannedArrivalAt: i === 0 ? '' : fmt,
|
||||
plannedDepartureAt: i === addStopTimes.length - 1 ? '' : fmt,
|
||||
};
|
||||
}));
|
||||
}}
|
||||
>
|
||||
Auto-fill from departure/arrival
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-muted-foreground border-b">
|
||||
<th className="pb-2 pr-3 font-medium">#</th>
|
||||
<th className="pb-2 pr-3 font-medium">Station</th>
|
||||
<th className="pb-2 pr-3 font-medium">Planned Arrival</th>
|
||||
<th className="pb-2 font-medium">Planned Departure</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{addStopTimes.map((stop, i) => {
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === addStopTimes.length - 1;
|
||||
return (
|
||||
<tr key={stop.sequence}>
|
||||
<td className="py-2 pr-3 text-muted-foreground">{stop.sequence}</td>
|
||||
<td className="py-2 pr-3 font-medium whitespace-nowrap">{stop.stationName}</td>
|
||||
<td className="py-2 pr-3 min-w-[200px]">
|
||||
{isFirst ? (
|
||||
<span className="text-xs text-muted-foreground italic">—</span>
|
||||
) : (
|
||||
<DateTimePicker
|
||||
value={stop.plannedArrivalAt}
|
||||
onChange={(v) => {
|
||||
const updated = [...addStopTimes];
|
||||
updated[i] = { ...updated[i], plannedArrivalAt: v };
|
||||
setAddStopTimes(updated);
|
||||
}}
|
||||
placeholder="Pick arrival"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 min-w-[200px]">
|
||||
{isLast ? (
|
||||
<span className="text-xs text-muted-foreground italic">—</span>
|
||||
) : (
|
||||
<DateTimePicker
|
||||
value={stop.plannedDepartureAt}
|
||||
onChange={(v) => {
|
||||
const updated = [...addStopTimes];
|
||||
updated[i] = { ...updated[i], plannedDepartureAt: v };
|
||||
setAddStopTimes(updated);
|
||||
}}
|
||||
placeholder="Pick departure"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="label mb-0">Coaches</label>
|
||||
@@ -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() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Departure Date & Time *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
<DateTimePicker
|
||||
value={editForm.departureAt}
|
||||
onChange={(e) => setEditForm({ ...editForm, departureAt: e.target.value })}
|
||||
className="input"
|
||||
required
|
||||
onChange={(v) => setEditForm({ ...editForm, departureAt: v })}
|
||||
placeholder="Select departure"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Arrival Date & Time *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
<DateTimePicker
|
||||
value={editForm.arrivalAt}
|
||||
onChange={(e) => setEditForm({ ...editForm, arrivalAt: e.target.value })}
|
||||
className="input"
|
||||
required
|
||||
onChange={(v) => setEditForm({ ...editForm, arrivalAt: v })}
|
||||
placeholder="Select arrival"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1072,6 +1273,101 @@ export default function SchedulesPage() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{editStopTimes.length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<label className="label mb-0">Stop Times</label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Edit planned times for each stop. All stops must be filled to update.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary underline"
|
||||
onClick={() => {
|
||||
const dep = editForm.departureAt ? new Date(editForm.departureAt + ':00Z') : null;
|
||||
const arr = editForm.arrivalAt ? new Date(editForm.arrivalAt + ':00Z') : null;
|
||||
if (!dep || !arr || arr <= dep) return;
|
||||
const stops = (editRouteDetail as any)?.stops ?? [];
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = stops[stops.length - 1]?.distanceKm || 1;
|
||||
setEditStopTimes(editStopTimes.map((s, i) => {
|
||||
const stop = stops.find((st: any) => st.sequence === s.sequence);
|
||||
const dist = stops.length > 0 ? (stop?.distanceKm ?? 0) : 0;
|
||||
const progress = stops.length > 0 && totalDistance > 0
|
||||
? dist / totalDistance
|
||||
: i / Math.max(editStopTimes.length - 1, 1);
|
||||
const t = new Date(dep.getTime() + totalDuration * progress);
|
||||
const fmt = t.toISOString().slice(0, 16);
|
||||
return {
|
||||
...s,
|
||||
plannedArrivalAt: i === 0 ? '' : fmt,
|
||||
plannedDepartureAt: i === editStopTimes.length - 1 ? '' : fmt,
|
||||
};
|
||||
}));
|
||||
}}
|
||||
>
|
||||
Auto-fill from departure/arrival
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-muted-foreground border-b">
|
||||
<th className="pb-2 pr-3 font-medium">#</th>
|
||||
<th className="pb-2 pr-3 font-medium">Station</th>
|
||||
<th className="pb-2 pr-3 font-medium">Planned Arrival</th>
|
||||
<th className="pb-2 font-medium">Planned Departure</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{editStopTimes.map((stop, i) => {
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === editStopTimes.length - 1;
|
||||
return (
|
||||
<tr key={stop.sequence}>
|
||||
<td className="py-2 pr-3 text-muted-foreground">{stop.sequence}</td>
|
||||
<td className="py-2 pr-3 font-medium whitespace-nowrap">{stop.stationName}</td>
|
||||
<td className="py-2 pr-3 min-w-[200px]">
|
||||
{isFirst ? (
|
||||
<span className="text-xs text-muted-foreground italic">—</span>
|
||||
) : (
|
||||
<DateTimePicker
|
||||
value={stop.plannedArrivalAt}
|
||||
onChange={(v) => {
|
||||
const updated = [...editStopTimes];
|
||||
updated[i] = { ...updated[i], plannedArrivalAt: v };
|
||||
setEditStopTimes(updated);
|
||||
}}
|
||||
placeholder="Pick arrival"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 min-w-[200px]">
|
||||
{isLast ? (
|
||||
<span className="text-xs text-muted-foreground italic">—</span>
|
||||
) : (
|
||||
<DateTimePicker
|
||||
value={stop.plannedDepartureAt}
|
||||
onChange={(v) => {
|
||||
const updated = [...editStopTimes];
|
||||
updated[i] = { ...updated[i], plannedDepartureAt: v };
|
||||
setEditStopTimes(updated);
|
||||
}}
|
||||
placeholder="Pick departure"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="label">Coaches (Optional)</label>
|
||||
@@ -1129,6 +1425,7 @@ export default function SchedulesPage() {
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setEditingSchedule(null);
|
||||
setEditStopTimes([]);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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