diff --git a/scheduling-remote/src/components/scheduling/AvailabilityCalendar.tsx b/scheduling-remote/src/components/scheduling/AvailabilityCalendar.tsx index 6d19f37..af39b11 100644 --- a/scheduling-remote/src/components/scheduling/AvailabilityCalendar.tsx +++ b/scheduling-remote/src/components/scheduling/AvailabilityCalendar.tsx @@ -5,6 +5,8 @@ import type { User, CalendarEvent } from './types' import { availabilityManagement } from '../../services/supabase' import { BackButton } from './ui/BackButton' import { TimeSlotModal } from './ui/TimeSlotModal' +import { Toast, type ToastType } from './ui/Toast' +import { DeleteConfirmationModal } from './ui/DeleteConfirmationModal' import 'react-big-calendar/lib/css/react-big-calendar.css' import '../CalendarStyles.css' @@ -16,6 +18,7 @@ interface AvailabilityCalendarProps { export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps) { const localizer = momentLocalizer(moment) const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) const [selectedDate, setSelectedDate] = useState(null) const [showTimeSlotForm, setShowTimeSlotForm] = useState(false) @@ -26,10 +29,20 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps const [currentView, setCurrentView] = useState(Views.MONTH) const [currentDate, setCurrentDate] = useState(new Date()) + // Toast notification state + const [toast, setToast] = useState<{ message: string; type: ToastType } | null>(null) + + // Delete confirmation state + const [eventToDelete, setEventToDelete] = useState(null) + const [isDeleting, setIsDeleting] = useState(false) + const [isAdding, setIsAdding] = useState(false) + const [validationError, setValidationError] = useState(null) + // Load availability from DB on mount useEffect(() => { const loadAvailability = async () => { try { + setLoading(true) const records = await availabilityManagement.getMyAvailability() const mapped: CalendarEvent[] = records.map(r => ({ id: r.id, @@ -41,6 +54,9 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps setEvents(mapped) } catch (e) { console.error('Failed to load availability', e) + setToast({ message: 'Failed to load availability. Please refresh the page.', type: 'error' }) + } finally { + setLoading(false) } } loadAvailability() @@ -73,24 +89,38 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps }) } - const handleSelectEvent = async (event: CalendarEvent) => { - if (!window.confirm('Do you want to remove this availability?')) { - return - } + const handleSelectEvent = (event: CalendarEvent) => { + setEventToDelete(event) + } + + const handleConfirmDelete = async () => { + if (!eventToDelete) return + try { - if (typeof event.id === 'string') { - await availabilityManagement.deleteAvailability(event.id) + setIsDeleting(true) + if (typeof eventToDelete.id === 'string') { + await availabilityManagement.deleteAvailability(eventToDelete.id) } - setEvents(events.filter(e => e.id !== event.id)) + setEvents(events.filter(e => e.id !== eventToDelete.id)) + setToast({ message: 'Availability removed successfully.', type: 'success' }) + setEventToDelete(null) } catch (e: any) { - alert(e?.message || 'Failed to delete availability.') + setToast({ message: e?.message || 'Failed to delete availability. Please try again.', type: 'error' }) console.error('Failed to delete availability', e) + } finally { + setIsDeleting(false) } } + const handleCancelDelete = () => { + setEventToDelete(null) + } + const handleAddTimeSlot = async () => { if (!selectedDate) return + setValidationError(null) + const [startHour, startMinute] = newTimeSlot.startTime.split(':').map(Number) const [endHour, endMinute] = newTimeSlot.endTime.split(':').map(Number) @@ -100,6 +130,12 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps const endDateTime = new Date(selectedDate) endDateTime.setHours(endHour, endMinute, 0, 0) + // Validate start time is before end time + if (startDateTime >= endDateTime) { + setValidationError('End time must be after start time') + return + } + // Check for overlapping events const hasOverlap = events.some(event => { const eventStart = new Date(event.start) @@ -113,11 +149,12 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps }) if (hasOverlap) { - alert('This time slot overlaps with an existing availability. Please choose a different time.') + setValidationError('This time slot overlaps with an existing availability. Please choose a different time.') return } try { + setIsAdding(true) // Persist to DB first to get real ID and server validation const created = await availabilityManagement.createAvailability({ available_from: startDateTime.toISOString(), @@ -135,15 +172,22 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps setEvents([...events, newEvent]) setShowTimeSlotForm(false) setSelectedDate(null) + setNewTimeSlot({ startTime: '09:00', endTime: '17:00' }) + setValidationError(null) + setToast({ message: 'Availability added successfully.', type: 'success' }) } catch (e: any) { - alert(e?.message || 'Failed to save availability. Please try again.') + setToast({ message: e?.message || 'Failed to save availability. Please try again.', type: 'error' }) console.error('Failed to create availability', e) + } finally { + setIsAdding(false) } } const handleCancelTimeSlot = () => { setShowTimeSlotForm(false) setSelectedDate(null) + setNewTimeSlot({ startTime: '09:00', endTime: '17:00' }) + setValidationError(null) } const getEventsForDate = (date: Date) => { @@ -155,6 +199,26 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps return (
+ {/* Toast Notification */} + {toast && ( + setToast(null)} + /> + )} + + {/* Delete Confirmation Modal */} + {eventToDelete && ( + + )} + {/* Calendar Header */}
@@ -184,34 +248,66 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps onTimeSlotChange={setNewTimeSlot} onAdd={handleAddTimeSlot} onCancel={handleCancelTimeSlot} + isAdding={isAdding} + validationError={validationError} /> )} - {/* Calendar */} -
- -
+ {/* Loading State */} + {loading ? ( +
+
+ + + + +

Loading availability...

+
+
+ ) : ( + /* Calendar with optional empty state overlay */ +
+ {events.length === 0 && ( +
+
+
+ + + +
+

+ No availability slots yet +

+

+ Click and drag on the calendar to add your first availability slot. +

+
+
+ )} + +
+ )}
) } diff --git a/scheduling-remote/src/components/scheduling/ui/DeleteConfirmationModal.tsx b/scheduling-remote/src/components/scheduling/ui/DeleteConfirmationModal.tsx new file mode 100644 index 0000000..fa7bc2b --- /dev/null +++ b/scheduling-remote/src/components/scheduling/ui/DeleteConfirmationModal.tsx @@ -0,0 +1,107 @@ +interface DeleteConfirmationModalProps { + title: string + message: string + onConfirm: () => void + onCancel: () => void + isDeleting?: boolean +} + +export function DeleteConfirmationModal({ + title, + message, + onConfirm, + onCancel, + isDeleting = false +}: DeleteConfirmationModalProps) { + return ( +
+
+
e.stopPropagation()} + > + {/* Close Button */} + + + {/* Icon */} +
+ + + +
+ + {/* Title */} +

+ {title} +

+ + {/* Message */} +

+ {message} +

+ + {/* Actions */} +
+ + +
+
+
+ ) +} + diff --git a/scheduling-remote/src/components/scheduling/ui/TimeSlotModal.tsx b/scheduling-remote/src/components/scheduling/ui/TimeSlotModal.tsx index 818253f..083c962 100644 --- a/scheduling-remote/src/components/scheduling/ui/TimeSlotModal.tsx +++ b/scheduling-remote/src/components/scheduling/ui/TimeSlotModal.tsx @@ -11,6 +11,8 @@ interface TimeSlotModalProps { onTimeSlotChange: (timeSlot: { startTime: string; endTime: string }) => void onAdd: () => void onCancel: () => void + isAdding?: boolean + validationError?: string | null } export function TimeSlotModal({ @@ -19,7 +21,9 @@ export function TimeSlotModal({ existingEvents, onTimeSlotChange, onAdd, - onCancel + onCancel, + isAdding = false, + validationError = null }: TimeSlotModalProps) { const getEventsForDate = (date: Date) => { return existingEvents.filter(event => { @@ -28,6 +32,18 @@ export function TimeSlotModal({ }) } + // Validate time slot + const isValid = () => { + if (!newTimeSlot.startTime || !newTimeSlot.endTime) { + return false + } + const [startHour, startMinute] = newTimeSlot.startTime.split(':').map(Number) + const [endHour, endMinute] = newTimeSlot.endTime.split(':').map(Number) + const startMinutes = startHour * 60 + startMinute + const endMinutes = endHour * 60 + endMinute + return startMinutes < endMinutes + } + return (
onTimeSlotChange({ ...newTimeSlot, startTime: e.target.value })} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white" + className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${ + validationError || !isValid() && newTimeSlot.startTime && newTimeSlot.endTime + ? 'border-red-300 dark:border-red-600' + : 'border-gray-300 dark:border-gray-600' + }`} + disabled={isAdding} />
@@ -81,8 +102,18 @@ export function TimeSlotModal({ type="time" value={newTimeSlot.endTime} onChange={(e) => onTimeSlotChange({ ...newTimeSlot, endTime: e.target.value })} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white" + className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${ + validationError || !isValid() && newTimeSlot.startTime && newTimeSlot.endTime + ? 'border-red-300 dark:border-red-600' + : 'border-gray-300 dark:border-gray-600' + }`} + disabled={isAdding} /> + {(validationError || (!isValid() && newTimeSlot.startTime && newTimeSlot.endTime)) && ( +

+ {validationError || 'End time must be after start time'} +

+ )}
@@ -105,15 +136,27 @@ export function TimeSlotModal({
diff --git a/scheduling-remote/src/components/scheduling/ui/Toast.tsx b/scheduling-remote/src/components/scheduling/ui/Toast.tsx new file mode 100644 index 0000000..3a67532 --- /dev/null +++ b/scheduling-remote/src/components/scheduling/ui/Toast.tsx @@ -0,0 +1,85 @@ +import { useEffect } from 'react' + +export type ToastType = 'success' | 'error' | 'info' | 'warning' + +interface ToastProps { + message: string + type: ToastType + onClose: () => void + duration?: number +} + +export function Toast({ message, type, onClose, duration = 5000 }: ToastProps) { + useEffect(() => { + const timer = setTimeout(() => { + onClose() + }, duration) + + return () => clearTimeout(timer) + }, [duration, onClose]) + + const typeStyles = { + success: 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-300', + error: 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-300', + info: 'bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-300', + warning: 'bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-300', + } + + const iconStyles = { + success: 'text-green-400', + error: 'text-red-400', + info: 'text-blue-400', + warning: 'text-yellow-400', + } + + const icons = { + success: ( + + + + ), + error: ( + + + + ), + info: ( + + + + ), + warning: ( + + + + ), + } + + return ( +
+
+
+ {icons[type]} +
+
+

{message}

+
+
+ +
+
+
+ ) +} +