Add date-aware time slot picker for rescheduling
Enhanced TimeSlotPickerModal with: - Date selection via date input and navigation arrows - Passed time slots filtered out (only show future slots for today) - Complete schedule picker (date + time in one modal) - Dynamic slot availability based on selected date - Better UX with date/time sections clearly separated Updated AdminConsulting to: - Use editSessionDate when opening time slot picker - Pass selected date back from modal to parent - Handle date changes during rescheduling Fixes the issue where admins could only select time slots for the original session date, not the new rescheduled date. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Clock, Calendar as CalendarIcon, Loader2 } from 'lucide-react';
|
||||
import { format, addMinutes, parse, isAfter, isBefore, startOfDay } from 'date-fns';
|
||||
import { Clock, Calendar as CalendarIcon, Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { format, addMinutes, parse, isAfter, isBefore, startOfDay, addDays, isToday, isPast } from 'date-fns';
|
||||
import { id } from 'date-fns/locale';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
@@ -36,7 +37,7 @@ interface TimeSlotPickerModalProps {
|
||||
selectedDate: Date;
|
||||
initialStartTime?: string;
|
||||
initialEndTime?: string;
|
||||
onSave: (startTime: string, endTime: string, totalBlocks: number, totalDuration: number) => void;
|
||||
onSave: (startTime: string, endTime: string, totalBlocks: number, totalDuration: number, selectedDate: string) => void;
|
||||
sessionId?: string; // If editing, exclude this session from availability check
|
||||
}
|
||||
|
||||
@@ -54,6 +55,9 @@ export function TimeSlotPickerModal({
|
||||
const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Date selection state
|
||||
const [currentDate, setCurrentDate] = useState<Date>(selectedDate);
|
||||
|
||||
// Range selection state
|
||||
const [selectedRange, setSelectedRange] = useState<{ start: string | null; end: string | null }>({
|
||||
start: initialStartTime || null,
|
||||
@@ -61,11 +65,21 @@ export function TimeSlotPickerModal({
|
||||
});
|
||||
const [pendingSlot, setPendingSlot] = useState<string | null>(null);
|
||||
|
||||
// Reset range when date changes
|
||||
useEffect(() => {
|
||||
setCurrentDate(selectedDate);
|
||||
setSelectedRange({
|
||||
start: initialStartTime || null,
|
||||
end: initialEndTime || null
|
||||
});
|
||||
setPendingSlot(null);
|
||||
}, [selectedDate, initialStartTime, initialEndTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchData();
|
||||
}
|
||||
}, [open, selectedDate]);
|
||||
}, [open, currentDate]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
@@ -83,7 +97,7 @@ export function TimeSlotPickerModal({
|
||||
}
|
||||
|
||||
// Fetch confirmed sessions for availability check
|
||||
const dateStr = format(selectedDate, 'yyyy-MM-dd');
|
||||
const dateStr = format(currentDate, 'yyyy-MM-dd');
|
||||
const query = supabase
|
||||
.from('consulting_sessions')
|
||||
.select('session_date, start_time, end_time')
|
||||
@@ -103,10 +117,28 @@ export function TimeSlotPickerModal({
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Date navigation handlers
|
||||
const handlePreviousDay = () => {
|
||||
const newDate = addDays(currentDate, -1);
|
||||
setCurrentDate(newDate);
|
||||
};
|
||||
|
||||
const handleNextDay = () => {
|
||||
const newDate = addDays(currentDate, 1);
|
||||
setCurrentDate(newDate);
|
||||
};
|
||||
|
||||
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newDate = parse(e.target.value, 'yyyy-MM-dd', new Date());
|
||||
if (!isNaN(newDate.getTime())) {
|
||||
setCurrentDate(newDate);
|
||||
}
|
||||
};
|
||||
|
||||
const generateTimeSlots = (): TimeSlot[] => {
|
||||
if (!settings || !workhours.length) return [];
|
||||
|
||||
const dayOfWeek = selectedDate.getDay();
|
||||
const dayOfWeek = currentDate.getDay();
|
||||
const workhour = workhours.find(wh => wh.weekday === dayOfWeek);
|
||||
|
||||
if (!workhour) {
|
||||
@@ -119,17 +151,28 @@ export function TimeSlotPickerModal({
|
||||
const startTime = parse(workhour.start_time, 'HH:mm:ss', new Date());
|
||||
const endTime = parse(workhour.end_time, 'HH:mm:ss', new Date());
|
||||
|
||||
let currentTime = startTime;
|
||||
while (true) {
|
||||
const slotEnd = addMinutes(currentTime, slotDuration);
|
||||
// For today, filter out passed time slots
|
||||
const now = new Date();
|
||||
const isTodayDate = isToday(currentDate);
|
||||
const currentTimeStr = isTodayDate ? format(now, 'HH:mm') : '00:00';
|
||||
|
||||
if (isAfter(slotEnd, endTime) || isBefore(slotEnd, currentTime)) {
|
||||
let currentSlotTime = startTime;
|
||||
while (true) {
|
||||
const slotEnd = addMinutes(currentSlotTime, slotDuration);
|
||||
|
||||
if (isAfter(slotEnd, endTime) || isBefore(slotEnd, currentSlotTime)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const timeString = format(currentTime, 'HH:mm');
|
||||
const timeString = format(currentSlotTime, 'HH:mm');
|
||||
|
||||
// Check if this slot is available
|
||||
// Skip slots that have already passed for today
|
||||
if (isTodayDate && timeString < currentTimeStr) {
|
||||
currentSlotTime = slotEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this slot is available (not booked by another session)
|
||||
const isAvailable = !confirmedSlots.some(slot => {
|
||||
const slotStart = slot.start_time.substring(0, 5);
|
||||
const slotEnd = slot.end_time.substring(0, 5);
|
||||
@@ -142,7 +185,7 @@ export function TimeSlotPickerModal({
|
||||
available: isAvailable
|
||||
});
|
||||
|
||||
currentTime = slotEnd;
|
||||
currentSlotTime = slotEnd;
|
||||
}
|
||||
|
||||
return slots;
|
||||
@@ -201,7 +244,8 @@ export function TimeSlotPickerModal({
|
||||
|
||||
const handleSave = () => {
|
||||
if (selectedRange.start && selectedRange.end) {
|
||||
onSave(selectedRange.start, selectedRange.end, totalBlocks, totalDuration);
|
||||
const dateStr = format(currentDate, 'yyyy-MM-dd');
|
||||
onSave(selectedRange.start, selectedRange.end, totalBlocks, totalDuration, dateStr);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -209,9 +253,9 @@ export function TimeSlotPickerModal({
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl border-2 border-border max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pilih Waktu Sesi</DialogTitle>
|
||||
<DialogTitle>Pilih Jadwal Sesi</DialogTitle>
|
||||
<DialogDescription>
|
||||
{format(selectedDate, 'd MMMM yyyy', { locale: id })} • Pilih slot waktu untuk sesi konsultasi
|
||||
Pilih tanggal dan waktu untuk sesi konsultasi
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -223,74 +267,150 @@ export function TimeSlotPickerModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Info */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted p-3 rounded-lg">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>
|
||||
Klik slot untuk memilih durasi. Setiap slot = {settings?.consulting_block_duration_minutes || 30} menit
|
||||
</span>
|
||||
{/* Date Selector */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
<span>Tanggal</span>
|
||||
</div>
|
||||
|
||||
{/* Date Navigation */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviousDay}
|
||||
className="border-2"
|
||||
disabled={isPast(addDays(currentDate, 1))}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
type="date"
|
||||
value={format(currentDate, 'yyyy-MM-dd')}
|
||||
onChange={handleDateChange}
|
||||
className="flex-1 border-2"
|
||||
min={format(new Date(), 'yyyy-MM-dd')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextDay}
|
||||
className="border-2"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Selected Date Display */}
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold">
|
||||
{format(currentDate, 'd MMMM yyyy', { locale: id })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isToday(currentDate) && 'Hari ini • '}
|
||||
{timeSlots.length} slot tersedia
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Slots Grid */}
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
||||
{timeSlots.map((slot) => {
|
||||
const isSelected = selectedRange.start && selectedRange.end &&
|
||||
timeSlots.findIndex(s => s.start === selectedRange.start) <=
|
||||
timeSlots.findIndex(s => s.start === slot.start) &&
|
||||
timeSlots.findIndex(s => s.start === selectedRange.end) >=
|
||||
timeSlots.findIndex(s => s.start === slot.start);
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border" />
|
||||
|
||||
const isPending = pendingSlot === slot.start;
|
||||
{/* Time Slots Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Waktu</span>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={slot.start}
|
||||
variant={isSelected ? "default" : isPending ? "secondary" : "outline"}
|
||||
className={`h-12 text-sm border-2 ${
|
||||
!slot.available ? 'opacity-30 cursor-not-allowed' : ''
|
||||
}`}
|
||||
disabled={!slot.available}
|
||||
onClick={() => handleSlotClick(slot.start)}
|
||||
>
|
||||
{slot.start}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Selection Summary */}
|
||||
{selectedRange.start && selectedRange.end && (
|
||||
<div className="bg-primary/10 p-4 rounded-lg border-2 border-primary/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Mulai</p>
|
||||
<p className="font-bold text-lg">{selectedRange.start}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl">→</p>
|
||||
<p className="text-xs text-muted-foreground">{totalBlocks} blok</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-muted-foreground">Selesai</p>
|
||||
<p className="font-bold text-lg">
|
||||
{format(addMinutes(parse(selectedRange.end, 'HH:mm', new Date()), settings?.consulting_block_duration_minutes || 30), 'HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Info */}
|
||||
{isToday(currentDate) && timeSlots.length === 0 ? (
|
||||
<div className="bg-amber-50 dark:bg-amber-950 border-2 border-amber-200 dark:border-amber-800 p-4 rounded-lg text-center">
|
||||
<p className="text-sm text-amber-900 dark:text-amber-100">
|
||||
Tidak ada slot tersedia untuk sisa hari ini. Silakan pilih tanggal lain.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-center text-sm mt-2 text-primary font-medium">
|
||||
Durasi: {totalDuration} menit
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
) : timeSlots.length === 0 ? (
|
||||
<div className="bg-muted p-4 rounded-lg text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tidak ada jadwal kerja untuk tanggal ini.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted p-3 rounded-lg">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>
|
||||
Klik slot untuk memilih durasi. Setiap slot = {settings?.consulting_block_duration_minutes || 30} menit
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Pending Slot */}
|
||||
{pendingSlot && (
|
||||
<div className="bg-amber-500/10 p-3 rounded-lg border-2 border-amber-500/20">
|
||||
<p className="text-center text-sm">
|
||||
Klik lagi untuk konfirmasi slot: <strong>{pendingSlot}</strong>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Time Slots Grid */}
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
||||
{timeSlots.map((slot) => {
|
||||
const isSelected = selectedRange.start && selectedRange.end &&
|
||||
timeSlots.findIndex(s => s.start === selectedRange.start) <=
|
||||
timeSlots.findIndex(s => s.start === slot.start) &&
|
||||
timeSlots.findIndex(s => s.start === selectedRange.end) >=
|
||||
timeSlots.findIndex(s => s.start === slot.start);
|
||||
|
||||
const isPending = pendingSlot === slot.start;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={slot.start}
|
||||
variant={isSelected ? "default" : isPending ? "secondary" : "outline"}
|
||||
className={`h-12 text-sm border-2 ${
|
||||
!slot.available ? 'opacity-30 cursor-not-allowed' : ''
|
||||
}`}
|
||||
disabled={!slot.available}
|
||||
onClick={() => handleSlotClick(slot.start)}
|
||||
>
|
||||
{slot.start}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Selection Summary */}
|
||||
{selectedRange.start && selectedRange.end && (
|
||||
<div className="bg-primary/10 p-4 rounded-lg border-2 border-primary/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Mulai</p>
|
||||
<p className="font-bold text-lg">{selectedRange.start}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl">→</p>
|
||||
<p className="text-xs text-muted-foreground">{totalBlocks} blok</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-muted-foreground">Selesai</p>
|
||||
<p className="font-bold text-lg">
|
||||
{format(addMinutes(parse(selectedRange.end, 'HH:mm', new Date()), settings?.consulting_block_duration_minutes || 30), 'HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-sm mt-2 text-primary font-medium">
|
||||
Durasi: {totalDuration} menit
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Slot */}
|
||||
{pendingSlot && (
|
||||
<div className="bg-amber-500/10 p-3 rounded-lg border-2 border-amber-500/20">
|
||||
<p className="text-center text-sm">
|
||||
Klik lagi untuk konfirmasi slot: <strong>{pendingSlot}</strong>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 justify-end">
|
||||
@@ -302,7 +422,7 @@ export function TimeSlotPickerModal({
|
||||
disabled={!selectedRange.start || !selectedRange.end}
|
||||
className="shadow-sm"
|
||||
>
|
||||
Simpan Waktu
|
||||
Simpan Jadwal
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -132,11 +132,12 @@ export default function AdminConsulting() {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleTimeSlotSelect = (startTime: string, endTime: string, totalBlocks: number, totalDuration: number) => {
|
||||
const handleTimeSlotSelect = (startTime: string, endTime: string, totalBlocks: number, totalDuration: number, selectedDate: string) => {
|
||||
setEditStartTime(startTime);
|
||||
setEditEndTime(endTime);
|
||||
setEditTotalBlocks(totalBlocks);
|
||||
setEditTotalDuration(totalDuration);
|
||||
setEditSessionDate(selectedDate);
|
||||
setTimeSlotPickerOpen(false);
|
||||
};
|
||||
|
||||
@@ -1089,7 +1090,7 @@ export default function AdminConsulting() {
|
||||
<TimeSlotPickerModal
|
||||
open={timeSlotPickerOpen}
|
||||
onClose={() => setTimeSlotPickerOpen(false)}
|
||||
selectedDate={parseISO(selectedSession.session_date)}
|
||||
selectedDate={parseISO(editSessionDate || selectedSession.session_date)}
|
||||
initialStartTime={editStartTime}
|
||||
initialEndTime={editEndTime}
|
||||
onSave={handleTimeSlotSelect}
|
||||
|
||||
Reference in New Issue
Block a user