/** * @description Calendar view component with drag-and-drop rescheduling and booking creation * @audit BUSINESS RULE: Calendar shows only bookings for selected date and filters * @audit SECURITY: Component requires authenticated admin/manager user context * @audit PERFORMANCE: Auto-refresh every 30 seconds for real-time updates * @audit Validate: Drag operations validate conflicts before API calls * @audit Validate: Real-time indicators update without full page reload */ 'use client' import { useState, useEffect, useCallback } from 'react' import { format, addDays, startOfDay, endOfDay, parseISO, addMinutes } from 'date-fns' import { es } from 'date-fns/locale' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Calendar, ChevronLeft, ChevronRight, Clock, User, MapPin, Plus } from 'lucide-react' import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, } from '@dnd-kit/core' import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable' import { useSortable, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { checkStaffCanPerformService, checkForConflicts, rescheduleBooking } from '@/lib/calendar-utils' interface Booking { id: string shortId: string status: string startTime: string endTime: string customer: { id: string first_name: string last_name: string } service: { id: string name: string duration_minutes: number } staff: { id: string display_name: string } resource: { id: string name: string type: string } } interface Staff { id: string display_name: string role: string location_id: string } interface Location { id: string name: string address: string } interface CalendarData { bookings: Booking[] staff: Staff[] locations: Location[] businessHours: { start: string end: string days: number[] } } interface SortableBookingProps { booking: Booking onReschedule?: (bookingId: string, newTime: string, newStaffId?: string) => void } function SortableBooking({ booking, onReschedule }: SortableBookingProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: booking.id }) const style = { transform: CSS.Transform.toString(transform), transition, } const getStatusColor = (status: string) => { switch (status) { case 'confirmed': return 'bg-green-100 border-green-300 text-green-800' case 'pending': return 'bg-yellow-100 border-yellow-300 text-yellow-800' case 'completed': return 'bg-blue-100 border-blue-300 text-blue-800' case 'cancelled': return 'bg-red-100 border-red-300 text-red-800' default: return 'bg-gray-100 border-gray-300 text-gray-800' } } const startTime = parseISO(booking.startTime) const endTime = parseISO(booking.endTime) const duration = (endTime.getTime() - startTime.getTime()) / (1000 * 60) return (
{booking.shortId}
{booking.customer.first_name} {booking.customer.last_name}
{booking.service.name}
{format(startTime, 'HH:mm')} - {format(endTime, 'HH:mm')}
{booking.resource.name}
) } interface TimeSlotProps { time: Date bookings: Booking[] staffId: string onBookingDrop?: (bookingId: string, newTime: string, staffId: string) => void onSlotClick?: (time: Date, staffId: string) => void } function TimeSlot({ time, bookings, staffId, onBookingDrop, onSlotClick }: TimeSlotProps) { const timeBookings = bookings.filter(booking => booking.staff.id === staffId && parseISO(booking.startTime).getHours() === time.getHours() && parseISO(booking.startTime).getMinutes() === time.getMinutes() ) return (
onSlotClick && timeBookings.length === 0 && onSlotClick(time, staffId)} > {timeBookings.length === 0 && onSlotClick && (
)} {timeBookings.map(booking => ( ))}
) } interface StaffColumnProps { staff: Staff date: Date bookings: Booking[] businessHours: { start: string, end: string } onBookingDrop?: (bookingId: string, newTime: string, staffId: string) => void onSlotClick?: (time: Date, staffId: string) => void } function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop, onSlotClick }: StaffColumnProps) { const staffBookings = bookings.filter(booking => booking.staff.id === staff.id) const timeSlots = [] const [startHour, startMinute] = businessHours.start.split(':').map(Number) const [endHour, endMinute] = businessHours.end.split(':').map(Number) let currentTime = new Date(date) currentTime.setHours(startHour, startMinute, 0, 0) const endTime = new Date(date) endTime.setHours(endHour, endMinute, 0, 0) while (currentTime < endTime) { timeSlots.push(new Date(currentTime)) currentTime = addMinutes(currentTime, 15) } return (
{staff.display_name}
{staff.role}
{timeSlots.map((timeSlot, index) => (
))}
) } /** * @description Main calendar component for multi-staff booking management * @returns {JSX.Element} Complete calendar interface with filters and drag-drop * @audit BUSINESS RULE: Calendar columns represent staff members with their bookings * @audit SECURITY: Only renders for authenticated admin/manager users * @audit PERFORMANCE: Memoized fetchCalendarData prevents unnecessary re-renders * @audit Validate: State updates trigger appropriate re-fetching of data */ export default function CalendarView() { const [currentDate, setCurrentDate] = useState(new Date()) const [calendarData, setCalendarData] = useState(null) const [loading, setLoading] = useState(false) const [selectedStaff, setSelectedStaff] = useState([]) const [selectedLocations, setSelectedLocations] = useState([]) const [rescheduleError, setRescheduleError] = useState(null) const [lastUpdated, setLastUpdated] = useState(null) const [showCreateBooking, setShowCreateBooking] = useState(false) const [createBookingData, setCreateBookingData] = useState<{ time: Date | null staffId: string | null customerId: string serviceId: string locationId: string notes: string }>({ time: null, staffId: null, customerId: '', serviceId: '', locationId: '', notes: '' }) const [createBookingError, setCreateBookingError] = useState(null) const [services, setServices] = useState([]) const [customers, setCustomers] = useState([]) const fetchServices = async () => { try { const response = await fetch('/api/services') const data = await response.json() if (data.success) { setServices(data.services || []) } } catch (error) { console.error('Error fetching services:', error) } } const fetchCustomers = async () => { try { const response = await fetch('/api/customers') const data = await response.json() if (data.success) { setCustomers(data.customers || []) } } catch (error) { console.error('Error fetching customers:', error) } } useEffect(() => { fetchServices() fetchCustomers() }, []) const handleSlotClick = (time: Date, staffId: string) => { const locationId = selectedLocations.length > 0 ? selectedLocations[0] : (calendarData?.locations[0]?.id || '') setCreateBookingData({ time, staffId, customerId: '', serviceId: '', locationId, notes: '' }) setShowCreateBooking(true) setCreateBookingError(null) } const handleCreateBooking = async (e: React.FormEvent) => { e.preventDefault() setCreateBookingError(null) if (!createBookingData.time || !createBookingData.staffId || !createBookingData.customerId || !createBookingData.serviceId || !createBookingData.locationId) { setCreateBookingError('Todos los campos son obligatorios') return } try { setLoading(true) const startTimeUtc = createBookingData.time.toISOString() const response = await fetch('/api/bookings', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ customer_id: createBookingData.customerId, service_id: createBookingData.serviceId, location_id: createBookingData.locationId, start_time_utc: startTimeUtc, staff_id: createBookingData.staffId, notes: createBookingData.notes || null }), }) const result = await response.json() if (result.success) { setShowCreateBooking(false) setCreateBookingData({ time: null, staffId: null, customerId: '', serviceId: '', locationId: '', notes: '' }) await fetchCalendarData() } else { setCreateBookingError(result.error || 'Error al crear la cita') } } catch (error) { console.error('Error creating booking:', error) setCreateBookingError('Error de conexión al crear la cita') } finally { setLoading(false) } } const fetchCalendarData = useCallback(async () => { setLoading(true) try { const startDate = format(startOfDay(currentDate), 'yyyy-MM-dd') const endDate = format(endOfDay(currentDate), 'yyyy-MM-dd') const params = new URLSearchParams({ start_date: `${startDate}T00:00:00Z`, end_date: `${endDate}T23:59:59Z`, }) if (selectedStaff.length > 0) { params.append('staff_ids', selectedStaff.join(',')) } if (selectedLocations.length > 0) { params.append('location_ids', selectedLocations.join(',')) } const response = await fetch(`/api/aperture/calendar?${params}`) const data = await response.json() if (data.success) { setCalendarData(data) setLastUpdated(new Date()) } } catch (error) { console.error('Error fetching calendar data:', error) } finally { setLoading(false) } }, [currentDate, selectedStaff, selectedLocations]) useEffect(() => { fetchCalendarData() }, [fetchCalendarData]) useEffect(() => { const interval = setInterval(() => { fetchCalendarData() }, 30000) return () => clearInterval(interval) }, [fetchCalendarData]) const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ) const handlePreviousDay = () => { setCurrentDate(prev => addDays(prev, -1)) } const handleNextDay = () => { setCurrentDate(prev => addDays(prev, 1)) } const handleToday = () => { setCurrentDate(new Date()) } const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event if (!over) return const bookingId = active.id as string const targetInfo = over.id as string const [targetStaffId, targetTime] = targetInfo.includes('-') ? targetInfo.split('-') : [targetInfo, null] try { setRescheduleError(null) const currentStart = parseISO(bookingId) const newStartTime = new Date(currentStart.getTime() + (60 * 60 * 1000)) const response = await fetch(`/api/aperture/bookings/${bookingId}/reschedule`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ bookingId, newStartTime: newStartTime.toISOString(), newStaffId: targetStaffId, }), }) const result = await response.json() if (result.success) { await fetchCalendarData() setRescheduleError(null) } else { setRescheduleError(result.error || 'Error al reprogramar la cita') } } catch (error) { console.error('Error rescheduling booking:', error) setRescheduleError('Error de conexión al reprogramar la cita') } } if (!calendarData) { return (

Cargando calendario...

) } return (
Crear Nueva Cita {createBookingData.time && ( {format(createBookingData.time, 'EEEE, d MMMM yyyy HH:mm', { locale: es })} )}
setCreateBookingData({ ...createBookingData, notes: e.target.value })} placeholder="Notas adicionales (opcional)" />
{createBookingError && (

{createBookingError}

)}
Calendario de Citas
{format(currentDate, 'EEEE, d MMMM', { locale: es })}
{lastUpdated && `Actualizado: ${format(lastUpdated, 'HH:mm:ss')}`}
Sucursal:
Staff:
{rescheduleError && (

{rescheduleError}

)}
Hora
{(() => { const timeSlots = [] const [startHour] = calendarData.businessHours.start.split(':').map(Number) const [endHour] = calendarData.businessHours.end.split(':').map(Number) for (let hour = startHour; hour <= endHour; hour++) { timeSlots.push(
{hour.toString().padStart(2, '0')}:00
) } return timeSlots })()}
{calendarData.staff.map(staff => ( ))}
) }