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 { 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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user