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 { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Clock, Calendar as CalendarIcon, Loader2 } from 'lucide-react'; import { Clock, Calendar as CalendarIcon, Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
import { format, addMinutes, parse, isAfter, isBefore, startOfDay } from 'date-fns'; import { format, addMinutes, parse, isAfter, isBefore, startOfDay, addDays, isToday, isPast } from 'date-fns';
import { id } from 'date-fns/locale'; import { id } from 'date-fns/locale';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
@@ -36,7 +37,7 @@ interface TimeSlotPickerModalProps {
selectedDate: Date; selectedDate: Date;
initialStartTime?: string; initialStartTime?: string;
initialEndTime?: 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 sessionId?: string; // If editing, exclude this session from availability check
} }
@@ -54,6 +55,9 @@ export function TimeSlotPickerModal({
const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]); const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Date selection state
const [currentDate, setCurrentDate] = useState<Date>(selectedDate);
// Range selection state // Range selection state
const [selectedRange, setSelectedRange] = useState<{ start: string | null; end: string | null }>({ const [selectedRange, setSelectedRange] = useState<{ start: string | null; end: string | null }>({
start: initialStartTime || null, start: initialStartTime || null,
@@ -61,11 +65,21 @@ export function TimeSlotPickerModal({
}); });
const [pendingSlot, setPendingSlot] = useState<string | null>(null); 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(() => { useEffect(() => {
if (open) { if (open) {
fetchData(); fetchData();
} }
}, [open, selectedDate]); }, [open, currentDate]);
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
@@ -83,7 +97,7 @@ export function TimeSlotPickerModal({
} }
// Fetch confirmed sessions for availability check // Fetch confirmed sessions for availability check
const dateStr = format(selectedDate, 'yyyy-MM-dd'); const dateStr = format(currentDate, 'yyyy-MM-dd');
const query = supabase const query = supabase
.from('consulting_sessions') .from('consulting_sessions')
.select('session_date, start_time, end_time') .select('session_date, start_time, end_time')
@@ -103,10 +117,28 @@ export function TimeSlotPickerModal({
setLoading(false); 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[] => { const generateTimeSlots = (): TimeSlot[] => {
if (!settings || !workhours.length) return []; if (!settings || !workhours.length) return [];
const dayOfWeek = selectedDate.getDay(); const dayOfWeek = currentDate.getDay();
const workhour = workhours.find(wh => wh.weekday === dayOfWeek); const workhour = workhours.find(wh => wh.weekday === dayOfWeek);
if (!workhour) { if (!workhour) {
@@ -119,17 +151,28 @@ export function TimeSlotPickerModal({
const startTime = parse(workhour.start_time, 'HH:mm:ss', new Date()); const startTime = parse(workhour.start_time, 'HH:mm:ss', new Date());
const endTime = parse(workhour.end_time, 'HH:mm:ss', new Date()); const endTime = parse(workhour.end_time, 'HH:mm:ss', new Date());
let currentTime = startTime; // For today, filter out passed time slots
while (true) { const now = new Date();
const slotEnd = addMinutes(currentTime, slotDuration); 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; 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 isAvailable = !confirmedSlots.some(slot => {
const slotStart = slot.start_time.substring(0, 5); const slotStart = slot.start_time.substring(0, 5);
const slotEnd = slot.end_time.substring(0, 5); const slotEnd = slot.end_time.substring(0, 5);
@@ -142,7 +185,7 @@ export function TimeSlotPickerModal({
available: isAvailable available: isAvailable
}); });
currentTime = slotEnd; currentSlotTime = slotEnd;
} }
return slots; return slots;
@@ -201,7 +244,8 @@ export function TimeSlotPickerModal({
const handleSave = () => { const handleSave = () => {
if (selectedRange.start && selectedRange.end) { 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}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-2xl border-2 border-border max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-2xl border-2 border-border max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Pilih Waktu Sesi</DialogTitle> <DialogTitle>Pilih Jadwal Sesi</DialogTitle>
<DialogDescription> <DialogDescription>
{format(selectedDate, 'd MMMM yyyy', { locale: id })} Pilih slot waktu untuk sesi konsultasi Pilih tanggal dan waktu untuk sesi konsultasi
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -223,74 +267,150 @@ export function TimeSlotPickerModal({
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{/* Info */} {/* Date Selector */}
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted p-3 rounded-lg"> <div className="space-y-3">
<Clock className="w-4 h-4" /> <div className="flex items-center gap-2 text-sm font-medium">
<span> <CalendarIcon className="w-4 h-4" />
Klik slot untuk memilih durasi. Setiap slot = {settings?.consulting_block_duration_minutes || 30} menit <span>Tanggal</span>
</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> </div>
{/* Time Slots Grid */} {/* Divider */}
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2"> <div className="border-t border-border" />
{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; {/* 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 ( {/* Info */}
<Button {isToday(currentDate) && timeSlots.length === 0 ? (
key={slot.start} <div className="bg-amber-50 dark:bg-amber-950 border-2 border-amber-200 dark:border-amber-800 p-4 rounded-lg text-center">
variant={isSelected ? "default" : isPending ? "secondary" : "outline"} <p className="text-sm text-amber-900 dark:text-amber-100">
className={`h-12 text-sm border-2 ${ Tidak ada slot tersedia untuk sisa hari ini. Silakan pilih tanggal lain.
!slot.available ? 'opacity-30 cursor-not-allowed' : '' </p>
}`}
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> </div>
<p className="text-center text-sm mt-2 text-primary font-medium"> ) : timeSlots.length === 0 ? (
Durasi: {totalDuration} menit <div className="bg-muted p-4 rounded-lg text-center">
</p> <p className="text-sm text-muted-foreground">
</div> 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 */} {/* Time Slots Grid */}
{pendingSlot && ( <div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
<div className="bg-amber-500/10 p-3 rounded-lg border-2 border-amber-500/20"> {timeSlots.map((slot) => {
<p className="text-center text-sm"> const isSelected = selectedRange.start && selectedRange.end &&
Klik lagi untuk konfirmasi slot: <strong>{pendingSlot}</strong> timeSlots.findIndex(s => s.start === selectedRange.start) <=
</p> timeSlots.findIndex(s => s.start === slot.start) &&
</div> 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 */} {/* Actions */}
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
@@ -302,7 +422,7 @@ export function TimeSlotPickerModal({
disabled={!selectedRange.start || !selectedRange.end} disabled={!selectedRange.start || !selectedRange.end}
className="shadow-sm" className="shadow-sm"
> >
Simpan Waktu Simpan Jadwal
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -132,11 +132,12 @@ export default function AdminConsulting() {
setDialogOpen(true); 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); setEditStartTime(startTime);
setEditEndTime(endTime); setEditEndTime(endTime);
setEditTotalBlocks(totalBlocks); setEditTotalBlocks(totalBlocks);
setEditTotalDuration(totalDuration); setEditTotalDuration(totalDuration);
setEditSessionDate(selectedDate);
setTimeSlotPickerOpen(false); setTimeSlotPickerOpen(false);
}; };
@@ -1089,7 +1090,7 @@ export default function AdminConsulting() {
<TimeSlotPickerModal <TimeSlotPickerModal
open={timeSlotPickerOpen} open={timeSlotPickerOpen}
onClose={() => setTimeSlotPickerOpen(false)} onClose={() => setTimeSlotPickerOpen(false)}
selectedDate={parseISO(selectedSession.session_date)} selectedDate={parseISO(editSessionDate || selectedSession.session_date)}
initialStartTime={editStartTime} initialStartTime={editStartTime}
initialEndTime={editEndTime} initialEndTime={editEndTime}
onSave={handleTimeSlotSelect} onSave={handleTimeSlotSelect}