Enhance AvailabilityCalendar component with loading state, toast notifications, and delete confirmation modal

- Added loading state to indicate data fetching progress.
- Implemented toast notifications for success and error messages during availability operations.
- Introduced a delete confirmation modal for improved user experience when removing availability slots.
- Enhanced TimeSlotModal with validation error handling and loading indicators for adding time slots.
This commit is contained in:
salirezav
2025-12-18 15:56:20 -05:00
parent 6cf67822dc
commit 8d8b639a35
4 changed files with 372 additions and 41 deletions

View File

@@ -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<CalendarEvent[]>([])
const [loading, setLoading] = useState(true)
const [selectedDate, setSelectedDate] = useState<Date | null>(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<CalendarEvent | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [isAdding, setIsAdding] = useState(false)
const [validationError, setValidationError] = useState<string | null>(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 (
<div className="space-y-6">
{/* Toast Notification */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
{/* Delete Confirmation Modal */}
{eventToDelete && (
<DeleteConfirmationModal
title="Remove Availability"
message={`Are you sure you want to remove this availability slot on ${moment(eventToDelete.start).format('MMMM D, YYYY')} from ${moment(eventToDelete.start).format('h:mm A')} to ${moment(eventToDelete.end).format('h:mm A')}?`}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
isDeleting={isDeleting}
/>
)}
{/* Calendar Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div>
@@ -184,34 +248,66 @@ export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps
onTimeSlotChange={setNewTimeSlot}
onAdd={handleAddTimeSlot}
onCancel={handleCancelTimeSlot}
isAdding={isAdding}
validationError={validationError}
/>
)}
{/* Calendar */}
<div className="h-[600px] flex flex-col">
<Calendar
localizer={localizer}
events={events}
startAccessor="start"
endAccessor="end"
style={{ height: '100%', minHeight: '600px' }}
view={currentView}
onView={setCurrentView}
date={currentDate}
onNavigate={setCurrentDate}
views={[Views.MONTH, Views.WEEK, Views.DAY]}
selectable
onSelectSlot={handleSelectSlot}
onSelectEvent={handleSelectEvent}
eventPropGetter={eventStyleGetter}
popup
showMultiDayTimes
step={30}
timeslots={2}
min={new Date(2024, 0, 1, 6, 0)} // 6:00 AM
max={new Date(2024, 0, 1, 22, 0)} // 10:00 PM
/>
</div>
{/* Loading State */}
{loading ? (
<div className="h-[600px] flex items-center justify-center">
<div className="text-center">
<svg className="animate-spin h-8 w-8 text-blue-600 dark:text-blue-400 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-gray-600 dark:text-gray-400">Loading availability...</p>
</div>
</div>
) : (
/* Calendar with optional empty state overlay */
<div className="relative h-[600px] flex flex-col">
{events.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="text-center bg-white/90 dark:bg-gray-800/90 rounded-lg p-6 backdrop-blur-sm">
<div className="mx-auto w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
No availability slots yet
</h3>
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
Click and drag on the calendar to add your first availability slot.
</p>
</div>
</div>
)}
<Calendar
localizer={localizer}
events={events}
startAccessor="start"
endAccessor="end"
style={{ height: '100%', minHeight: '600px' }}
view={currentView}
onView={setCurrentView}
date={currentDate}
onNavigate={setCurrentDate}
views={[Views.MONTH, Views.WEEK, Views.DAY]}
selectable
onSelectSlot={handleSelectSlot}
onSelectEvent={handleSelectEvent}
eventPropGetter={eventStyleGetter}
popup
showMultiDayTimes
step={30}
timeslots={2}
min={new Date(2024, 0, 1, 6, 0)} // 6:00 AM
max={new Date(2024, 0, 1, 22, 0)} // 10:00 PM
/>
</div>
)}
</div>
)
}

View File

@@ -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 (
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-[999999]">
<div
className="fixed inset-0 h-full w-full bg-gray-900/50 backdrop-blur-sm"
onClick={onCancel}
/>
<div
className="relative w-full rounded-2xl bg-white shadow-xl dark:bg-gray-900 max-w-md mx-auto p-6"
onClick={(e) => e.stopPropagation()}
>
{/* Close Button */}
<button
onClick={onCancel}
disabled={isDeleting}
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Close"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
fill="currentColor"
/>
</svg>
</button>
{/* Icon */}
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/20 mb-4">
<svg
className="h-6 w-6 text-red-600 dark:text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-gray-900 dark:text-white text-center mb-2">
{title}
</h3>
{/* Message */}
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-6">
{message}
</p>
{/* Actions */}
<div className="flex justify-end space-x-3">
<button
onClick={onCancel}
disabled={isDeleting}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={isDeleting}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
{isDeleting ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Deleting...
</>
) : (
'Delete'
)}
</button>
</div>
</div>
</div>
)
}

View File

@@ -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 (
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
<div
@@ -69,7 +85,12 @@ export function TimeSlotModal({
type="time"
value={newTimeSlot.startTime}
onChange={(e) => 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}
/>
</div>
@@ -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)) && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{validationError || 'End time must be after start time'}
</p>
)}
</div>
</div>
@@ -105,15 +136,27 @@ export function TimeSlotModal({
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={onCancel}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
disabled={isAdding}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={onAdd}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
disabled={!isValid() || isAdding}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
Add Time Slot
{isAdding ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Adding...
</>
) : (
'Add Time Slot'
)}
</button>
</div>
</div>

View File

@@ -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: (
<svg className={`h-5 w-5 ${iconStyles[type]}`} viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
),
error: (
<svg className={`h-5 w-5 ${iconStyles[type]}`} viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
),
info: (
<svg className={`h-5 w-5 ${iconStyles[type]}`} viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
),
warning: (
<svg className={`h-5 w-5 ${iconStyles[type]}`} viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
),
}
return (
<div
className={`fixed top-4 right-4 z-[999999] p-4 rounded-md shadow-lg border ${typeStyles[type]} animate-in slide-in-from-top-5 fade-in duration-300`}
role="alert"
>
<div className="flex items-center">
<div className="flex-shrink-0">
{icons[type]}
</div>
<div className="ml-3">
<p className="text-sm font-medium">{message}</p>
</div>
<div className="ml-auto pl-3">
<button
onClick={onClose}
className={`inline-flex rounded-md p-1.5 ${typeStyles[type]} hover:opacity-75 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-gray-800`}
aria-label="Close notification"
>
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
)
}