import React, { useState, useEffect, useCallback } from 'react'; import FullCalendar from '@fullcalendar/react'; import dayGridPlugin from '@fullcalendar/daygrid'; import timeGridPlugin from '@fullcalendar/timegrid'; import interactionPlugin from '@fullcalendar/interaction'; import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2 } from 'lucide-react'; import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx'; import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'; import { HybridBackend } from '../services/backend.ts'; import { Dentista, Agendamento, Paciente, DentistaHorario } from '../types.ts'; import { useHybridBackend } from '../hooks/useHybridBackend.ts'; import { useToast } from '../contexts/ToastContext.tsx'; import { PageHeader } from '../components/PageHeader.tsx'; // --- Reusable Debounce Hook --- function useDebounce(value: string, delay: number) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); return () => clearTimeout(handler); }, [value, delay]); return debouncedValue; } // --- Advanced Appointment Modal Component --- const AppointmentModal: React.FC<{ isOpen: boolean; onClose: () => void; onSave: () => void; dentists: Dentista[] | null; initialDate?: string; initialAppointment?: Agendamento | null; }> = ({ isOpen, onClose, onSave, dentists, initialDate, initialAppointment }) => { const [selectedPatient, setSelectedPatient] = useState(null); const [searchTerm, setSearchTerm] = useState(''); const [duration, setDuration] = useState(15); const [selectedDate, setSelectedDate] = useState(initialDate ? initialDate.split('T')[0] : new Date().toISOString().split('T')[0]); const [selectedTime, setSelectedTime] = useState(initialDate ? new Date(initialDate).toTimeString().substring(0, 5) : ''); const [selectedDentistId, setSelectedDentistId] = useState(dentists?.[0]?.id); const [procedimento, setProcedimento] = useState(''); const [isSearchFocused, setIsSearchFocused] = useState(false); const debouncedSearchTerm = useDebounce(searchTerm, 400); const fetcher = useCallback(() => HybridBackend.getPacientes(debouncedSearchTerm).then(r => Array.isArray(r.data) ? r.data : []), [debouncedSearchTerm]); const { data: searchResults, isLoading: isSearching } = useHybridBackend(fetcher, [debouncedSearchTerm]); const { data: agendamentos, refresh: refreshAgendamentos } = useHybridBackend(HybridBackend.getAgendamentos); const activeWorkspace = HybridBackend.getActiveWorkspace(); const { data: allHorarios } = useHybridBackend(() => selectedDentistId && activeWorkspace ? HybridBackend.getHorariosByDentista(selectedDentistId, activeWorkspace.id) : Promise.resolve([]), [selectedDentistId, activeWorkspace?.id] ); const toast = useToast(); useEffect(() => { if (isOpen) { if (initialAppointment) { setSelectedPatient({ nome: initialAppointment.pacienteNome, cpf: '', id: 'temp' } as any); const startDate = new Date(initialAppointment.start); const endDate = new Date(initialAppointment.end); setSelectedDate(startDate.toISOString().split('T')[0]); setSelectedTime(startDate.toTimeString().substring(0, 5)); setDuration((endDate.getTime() - startDate.getTime()) / 60000); setSelectedDentistId(initialAppointment.dentistaId); setProcedimento(initialAppointment.procedimento); } else { setSelectedPatient(null); setSearchTerm(''); setSelectedDate(initialDate ? initialDate.split('T')[0] : new Date().toISOString().split('T')[0]); setSelectedTime(initialDate ? new Date(initialDate).toTimeString().substring(0, 5) : ''); setProcedimento(''); setDuration(15); if (dentists && dentists.length > 0) { setSelectedDentistId(dentists[0].id); } } } }, [isOpen, initialDate, dentists, initialAppointment]); useEffect(() => { if (!isOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, onClose]); const selectedDentist = dentists?.find(d => d.id === selectedDentistId); const timeSlots = useCallback(() => { const slots: string[] = []; if (!selectedDentistId || !selectedDate) return slots; const existingAppointments = (agendamentos ?? []).filter(ag => ag.dentistaId === selectedDentistId && new Date(ag.start).toISOString().split('T')[0] === selectedDate && ag.id !== initialAppointment?.id ).map(ag => ({ start: new Date(ag.start).getHours() * 60 + new Date(ag.start).getMinutes(), end: new Date(ag.end).getHours() * 60 + new Date(ag.end).getMinutes(), })); const dayOfWeek = new Date(selectedDate).getUTCDay(); const dayHorarios = allHorarios?.filter(h => h.dia_semana === dayOfWeek && h.ativo) || []; if (dayHorarios.length === 0) { // Default 8-18 fallback if no hours set for (let time = 8 * 60; time < 18 * 60; time += 15) { const isOccupied = existingAppointments.some(app => time >= app.start && time < app.end); if (!isOccupied) { const hour = Math.floor(time / 60), minute = time % 60; slots.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`); } } } else { for (const h of dayHorarios) { const [hStart, mStart] = h.hora_inicio.split(':').map(Number); const [hEnd, mEnd] = h.hora_fim.split(':').map(Number); for (let time = hStart * 60 + mStart; time < hEnd * 60 + mEnd; time += 15) { const isOccupied = existingAppointments.some(app => time >= app.start && time < app.end); if (!isOccupied) { const hour = Math.floor(time / 60), minute = time % 60; slots.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`); } } } } // If current time is not on the 15m grid (like 12:03), add it as a valid slot if (selectedTime && !slots.includes(selectedTime)) { slots.push(selectedTime); slots.sort(); } return slots; }, [selectedDentistId, selectedDate, agendamentos, initialAppointment, selectedTime, allHorarios])(); const handlePatientSelect = (patient: Paciente) => { setSelectedPatient(patient); setSearchTerm(''); setIsSearchFocused(false); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!selectedPatient || !selectedDate || !selectedTime || !selectedDentistId || !procedimento) { toast.error("PREENCHA TODOS OS CAMPOS PARA AGENDAR."); return; } const startDateTime = new Date(`${selectedDate}T${selectedTime}:00`); const endDateTime = new Date(startDateTime.getTime() + duration * 60000); try { const agData = { pacienteNome: selectedPatient.nome, dentistaId: selectedDentistId, start: startDateTime.toISOString(), end: endDateTime.toISOString(), status: initialAppointment?.status || 'Pendente', procedimento: procedimento.toUpperCase(), }; if (initialAppointment?.id) { await HybridBackend.atualizarAgendamento(initialAppointment.id, agData); toast.success(`AGENDAMENTO DE ${selectedPatient.nome} ATUALIZADO!`); } else { await HybridBackend.salvarAgendamento(agData); toast.success(`AGENDAMENTO PARA ${selectedPatient.nome} SALVO!`); } refreshAgendamentos(); onSave(); } catch { toast.error("ERRO AO SALVAR AGENDAMENTO."); } }; if (!isOpen) return null; return (

AGENDAR CONSULTA

{!selectedPatient ? (
setSearchTerm(e.target.value.toUpperCase())} onFocus={() => setIsSearchFocused(true)} onBlur={() => setTimeout(() => setIsSearchFocused(false), 200)} placeholder="DIGITE O NOME OU CPF..." className="w-full lg:w-1/2 bg-gray-100 p-3 lg:p-4 rounded-lg font-bold text-gray-800 uppercase focus:border-blue-500 outline-none border-2 border-transparent focus:border-2 text-sm lg:text-base" /> {isSearchFocused && debouncedSearchTerm && (
    {isSearching &&
  • BUSCANDO...
  • } {searchResults?.map(p => (
  • handlePatientSelect(p)} className="p-3 hover:bg-gray-100 cursor-pointer uppercase font-semibold text-sm">{p.nome} ({p.cpf})
  • ))}
)}
) : (
{selectedPatient.nome}
)}
{selectedPatient && (
setSelectedDate(e.target.value)} className="w-full mt-2 border border-gray-300 bg-white text-gray-800 rounded-lg p-2.5 lg:p-3 text-sm" />
setProcedimento(e.target.value.toUpperCase())} placeholder="EX: AVALIAÇÃO, LIMPEZA..." className="w-full mt-2 border border-gray-300 bg-white text-gray-800 rounded-lg p-2.5 lg:p-3 uppercase-input placeholder-gray-400 text-sm" />
{timeSlots.map(slot => ( ))} {timeSlots.length === 0 &&

NENHUM HORÁRIO DISPONÍVEL.

}
)}
); }; export const AgendaView: React.FC = () => { const { data: agendamentos, refresh } = useHybridBackend(HybridBackend.getAgendamentos); const { data: dentists, refresh: refreshDentists } = useHybridBackend(HybridBackend.getDentistas); const { data: googleEvents, refresh: refreshGoogle } = useHybridBackend(HybridBackend.getGoogleEvents); const [events, setEvents] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [selectedDateInfo, setSelectedDateInfo] = useState(null); const [selectedAppointment, setSelectedAppointment] = useState(null); const [showDetails, setShowDetails] = useState(false); const toast = useToast(); useEffect(() => { if (agendamentos && dentists) { const currentRole = HybridBackend.getCurrentRole(); const currentUserEmail = HybridBackend.getCurrentUser(); const isDentist = currentRole === 'dentista'; const loginedDentistId = dentists.find(d => d.email === currentUserEmail)?.id; let filteredAgendamentos = agendamentos; if (isDentist) { filteredAgendamentos = agendamentos.filter(ag => ag.dentistaId === loginedDentistId); } const mappedEvents = filteredAgendamentos.map(ag => { const dentist = dentists.find(d => d.id === ag.dentistaId); return { id: ag.id, title: `${ag.pacienteNome} - ${ag.procedimento}`, start: ag.start, end: ag.end, backgroundColor: ag.status === 'Faltou' ? '#ef4444' : (dentist ? dentist.corAgenda : '#94a3b8'), borderColor: ag.status === 'Faltou' ? '#ef4444' : (dentist ? dentist.corAgenda : '#94a3b8'), extendedProps: { ...ag, isGoogle: false, dentistaNome: dentist?.nome } }; }); // Add Google events if any if (googleEvents && googleEvents.length > 0) { let filteredGoogleEvents = googleEvents; if (isDentist) { filteredGoogleEvents = googleEvents.filter(ev => ev.extendedProps.owner_id === currentUserEmail); } mappedEvents.push(...filteredGoogleEvents); } setEvents(mappedEvents); } }, [agendamentos, dentists, googleEvents]); const handleDateClick = (arg: any) => { setSelectedDateInfo(arg); setIsModalOpen(true); }; const handleEventClick = (info: any) => { if (info.event.extendedProps.isGoogle) { toast.info(`EVENTO DO GOOGLE CALENDAR: ${info.event.title}`); return; } const ag = info.event.extendedProps as Agendamento; setSelectedAppointment(ag); setShowDetails(true); }; const handleUpdateStatus = async (id: string, status: Agendamento['status']) => { try { await HybridBackend.atualizarAgendamento(id, { status }); toast.success(`STATUS ATUALIZADO PARA ${status.toUpperCase()}`); refresh(); setShowDetails(false); } catch { toast.error("ERRO AO ATUALIZAR STATUS."); } }; const handleDeleteAppointment = async (id: string) => { if (!confirm("DESEJA REALMENTE EXCLUIR ESTE AGENDAMENTO?")) return; try { await HybridBackend.excluirAgendamento(id); toast.success("AGENDAMENTO EXCLUÍDO!"); refresh(); setShowDetails(false); } catch { toast.error("ERRO AO EXCLUIR AGENDAMENTO."); } }; const handleSaveAppointment = () => { refresh(); refreshGoogle(); setIsModalOpen(false); setSelectedAppointment(null); }; const onDragEnd = async (result: any) => { if (!result.destination || !dentists) return; const items = Array.from(dentists); const [reorderedItem] = items.splice(result.source.index, 1); items.splice(result.destination.index, 0, reorderedItem); const updatedOrders = items.map((item, index) => ({ id: item.id, ordem: index })); try { await HybridBackend.reorderDentistas(updatedOrders); toast.success("ORDEM DOS DENTISTAS ATUALIZADA!"); refreshDentists(); } catch { toast.error("ERRO AO REORDENAR DENTISTAS."); } }; const currentRole = HybridBackend.getCurrentRole(); const currentUserEmail = HybridBackend.getCurrentUser(); const filteredDentists = dentists?.filter(d => { if (currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'funcionario') return true; if (currentRole === 'dentista') return d.email === currentUserEmail; return false; }); return (
{(provided) => (
{filteredDentists?.map((d, index) => ( {(provided, snapshot) => (
{currentRole !== 'dentista' && }
{d.nome}
)}
))} {provided.placeholder}
)}
{(currentRole === 'admin' || currentRole === 'donoclinica') && ( )} {(currentRole !== 'paciente') && ( )}
{ const isGoogle = eventInfo.event.extendedProps.isGoogle; return (
{eventInfo.timeText}
{isGoogle ? (
{eventInfo.event.title}
) : ( <>
{eventInfo.event.extendedProps.pacienteNome}
{eventInfo.event.extendedProps.procedimento}
)}
); }} />
{/* Event Details "Popover" (Modal style for real estate) */} {showDetails && selectedAppointment && (
{/* Header with quick actions */}
{/* Content */}

{selectedAppointment.pacienteNome}

{selectedAppointment.procedimento}
{new Date(selectedAppointment.start).toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' })}
{new Date(selectedAppointment.start).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
d.id === selectedAppointment.dentistaId)?.corAgenda }}>
{(selectedAppointment as any).dentistaNome}
{/* Status and Action Buttons */}
{/* Footer: Reagendar */}
)} { setIsModalOpen(false); setSelectedAppointment(null); }} onSave={handleSaveAppointment} dentists={dentists} initialDate={selectedDateInfo?.dateStr} initialAppointment={selectedAppointment} /> setIsSettingsOpen(false)} dentists={dentists} />
); };