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:
dwindown
2025-12-31 18:01:52 +07:00
parent ad7b6130b1
commit c6b45378f3
2 changed files with 202 additions and 81 deletions

View File

@@ -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>

View File

@@ -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}