573 lines
35 KiB
TypeScript
573 lines
35 KiB
TypeScript
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<Paciente | null>(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<string | undefined>(dentists?.[0]?.id);
|
|
const [procedimento, setProcedimento] = useState('');
|
|
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
|
|
|
const debouncedSearchTerm = useDebounce(searchTerm, 400);
|
|
|
|
const fetcher = useCallback(() => HybridBackend.getPacientes(debouncedSearchTerm), [debouncedSearchTerm]);
|
|
const { data: searchResults, isLoading: isSearching } = useHybridBackend<Paciente[]>(fetcher, [debouncedSearchTerm]);
|
|
|
|
const { data: agendamentos, refresh: refreshAgendamentos } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
|
|
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
|
const { data: allHorarios } = useHybridBackend<DentistaHorario[]>(() =>
|
|
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 (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-2 lg:p-0">
|
|
<form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-2xl w-full max-w-md lg:max-w-none lg:w-[90vw] lg:h-[98vh] animate-in fade-in zoom-in-50 duration-200 flex flex-col overflow-hidden">
|
|
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-orange-50 to-white">
|
|
<h2 className="text-lg font-bold text-gray-800 flex items-center gap-2 uppercase">
|
|
<CalendarIcon size={22} className="text-orange-500" /> AGENDAR CONSULTA
|
|
</h2>
|
|
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1.5 transition-colors"><X size={22} /></button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
|
|
<div className="mb-6">
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">PACIENTE</label>
|
|
{!selectedPatient ? (
|
|
<div className="relative mt-2">
|
|
<input type="text" value={searchTerm} onChange={e => 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 && (
|
|
<ul className="absolute z-10 w-full lg:w-1/2 bg-white border border-gray-300 rounded-lg mt-1 max-h-48 overflow-y-auto shadow-lg">
|
|
{isSearching && <li className="p-3 text-sm text-gray-500">BUSCANDO...</li>}
|
|
{searchResults?.map(p => (
|
|
<li key={p.id} onMouseDown={() => handlePatientSelect(p)} className="p-3 hover:bg-gray-100 cursor-pointer uppercase font-semibold text-sm">{p.nome} ({p.cpf})</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="w-full lg:w-1/2 bg-green-50 border border-green-200 p-3 lg:p-4 rounded-lg mt-2 flex justify-between items-center">
|
|
<span className="font-bold text-green-800 uppercase text-sm lg:text-base">{selectedPatient.nome}</span>
|
|
<button type="button" onClick={() => setSelectedPatient(null)} className="text-xs font-bold bg-blue-100 text-blue-700 hover:bg-blue-200 px-3 py-1.5 rounded-md transition-colors">ALTERAR</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{selectedPatient && (
|
|
<div className="animate-in fade-in space-y-6 lg:space-y-0">
|
|
<div className="lg:grid lg:grid-cols-3 lg:gap-x-8 lg:gap-y-6 space-y-5 lg:space-y-0">
|
|
<div className="space-y-5">
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">DENTISTA RESPONSÁVEL</label>
|
|
<div className="flex items-center gap-3 mt-2">
|
|
<select value={selectedDentistId} onChange={e => setSelectedDentistId(e.target.value)} className="w-full border border-gray-300 rounded-lg p-2.5 lg:p-3 bg-white uppercase-select text-sm">
|
|
{dentists?.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
|
</select>
|
|
<div className="w-7 h-7 rounded-full flex-shrink-0 border-2 border-white shadow-md" style={{ backgroundColor: selectedDentist?.corAgenda || '#ccc' }}></div>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">DATA</label>
|
|
<input type="date" value={selectedDate} onChange={e => 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" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">DURAÇÃO</label>
|
|
<select value={duration} onChange={e => setDuration(Number(e.target.value))} className="w-full mt-2 border border-gray-300 rounded-lg p-2.5 lg:p-3 bg-white uppercase-select text-sm">
|
|
<option value={15}>15 MIN</option>
|
|
<option value={30}>30 MIN</option>
|
|
<option value={45}>45 MIN</option>
|
|
<option value={60}>60 MIN</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">PROCEDIMENTO</label>
|
|
<input type="text" value={procedimento} onChange={e => 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" />
|
|
</div>
|
|
</div>
|
|
<div className="lg:col-span-2">
|
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">HORÁRIOS DISPONÍVEIS</label>
|
|
<div className="grid grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-2 mt-3">
|
|
{timeSlots.map(slot => (
|
|
<button type="button" key={slot} onClick={() => setSelectedTime(slot)} className={`py-2.5 lg:py-3 rounded-lg text-sm font-bold border transition-all duration-150 ${selectedTime === slot ? 'bg-blue-600 text-white border-blue-600 shadow-md scale-105' : 'bg-white text-gray-800 border-gray-300 hover:border-blue-400 hover:bg-blue-50'}`}>{slot}</button>
|
|
))}
|
|
{timeSlots.length === 0 && <p className="col-span-6 lg:col-span-8 xl:col-span-10 text-center text-sm text-gray-400 p-8 bg-gray-50 rounded-lg border border-dashed border-gray-200 uppercase font-bold">NENHUM HORÁRIO DISPONÍVEL.</p>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="px-5 py-4 bg-gray-50 border-t border-gray-200 flex justify-end gap-3 flex-shrink-0">
|
|
<button type="button" onClick={onClose} className="px-8 py-2.5 bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold rounded-lg transition-colors text-sm uppercase">CANCELAR</button>
|
|
<button type="submit" disabled={!selectedPatient} className="px-10 py-2.5 bg-orange-500 hover:bg-orange-600 text-white font-bold rounded-lg shadow-sm transition-colors text-sm uppercase disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2"><CalendarIcon size={16} /> CONFIRMAR</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const AgendaView: React.FC = () => {
|
|
const { data: agendamentos, refresh } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
|
|
const { data: dentists, refresh: refreshDentists } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
|
|
const { data: googleEvents, refresh: refreshGoogle } = useHybridBackend<any[]>(HybridBackend.getGoogleEvents);
|
|
const [events, setEvents] = useState<any[]>([]);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
|
const [selectedDateInfo, setSelectedDateInfo] = useState<any>(null);
|
|
const [selectedAppointment, setSelectedAppointment] = useState<Agendamento | null>(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 (
|
|
<div className="space-y-6">
|
|
<PageHeader title="AGENDA CLÍNICA">
|
|
<div className="flex gap-4 items-center">
|
|
<DragDropContext onDragEnd={onDragEnd}>
|
|
<Droppable droppableId="dentists-legend" direction="horizontal">
|
|
{(provided) => (
|
|
<div {...provided.droppableProps} ref={provided.innerRef} className="hidden lg:flex gap-2 items-center">
|
|
{filteredDentists?.map((d, index) => (
|
|
<Draggable key={d.id} draggableId={d.id} index={index} isDragDisabled={currentRole === 'dentista'}>
|
|
{(provided, snapshot) => (
|
|
<div
|
|
ref={provided.innerRef}
|
|
{...provided.draggableProps}
|
|
{...provided.dragHandleProps}
|
|
className={`flex items-center gap-1.5 text-[10px] text-gray-700 bg-white border border-gray-200 px-3 py-1.5 rounded-full uppercase font-bold shadow-sm transition-all ${currentRole !== 'dentista' ? 'cursor-grab active:cursor-grabbing hover:border-gray-300' : ''} ${snapshot.isDragging ? 'ring-2 ring-blue-500 scale-105 shadow-lg' : ''}`}
|
|
>
|
|
{currentRole !== 'dentista' && <GripVertical size={12} className="text-gray-400" />}
|
|
<div className="w-2.5 h-2.5 rounded-full shadow-inner" style={{ backgroundColor: d.corAgenda }}></div>
|
|
{d.nome}
|
|
</div>
|
|
)}
|
|
</Draggable>
|
|
))}
|
|
{provided.placeholder}
|
|
</div>
|
|
)}
|
|
</Droppable>
|
|
</DragDropContext>
|
|
{(currentRole === 'admin' || currentRole === 'donoclinica') && (
|
|
<button onClick={() => setIsSettingsOpen(true)} className="p-2.5 bg-white text-gray-400 hover:text-blue-600 border border-gray-200 rounded-xl transition-all hover:border-blue-200 hover:shadow-sm">
|
|
<GearIcon size={20} />
|
|
</button>
|
|
)}
|
|
{(currentRole !== 'paciente') && (
|
|
<button onClick={() => { setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); }} className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-bold uppercase flex items-center gap-2 hover:bg-blue-700 shadow-sm transition-colors">
|
|
<Plus size={16} /> NOVO AGENDAMENTO
|
|
</button>
|
|
)}
|
|
</div>
|
|
</PageHeader>
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 relative" style={{ height: '750px' }}>
|
|
<FullCalendar
|
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
|
initialView="timeGridWeek"
|
|
headerToolbar={{ left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }}
|
|
locale="pt-br"
|
|
buttonText={{ today: 'HOJE', month: 'MÊS', week: 'SEMANA', day: 'DIA' }}
|
|
slotMinTime="08:00:00"
|
|
slotMaxTime="19:00:00"
|
|
allDaySlot={false}
|
|
events={events}
|
|
dateClick={handleDateClick}
|
|
eventClick={handleEventClick}
|
|
height="100%"
|
|
slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
|
|
eventContent={(eventInfo) => {
|
|
const isGoogle = eventInfo.event.extendedProps.isGoogle;
|
|
return (
|
|
<div className="p-0.5 overflow-hidden uppercase h-full">
|
|
<div className="text-[10px] font-bold">{eventInfo.timeText}</div>
|
|
{isGoogle ? (
|
|
<div className="text-[10px] font-black leading-tight break-words">{eventInfo.event.title}</div>
|
|
) : (
|
|
<>
|
|
<div className="text-xs truncate font-bold">{eventInfo.event.extendedProps.pacienteNome}</div>
|
|
<div className="text-[10px] truncate opacity-90">{eventInfo.event.extendedProps.procedimento}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Event Details "Popover" (Modal style for real estate) */}
|
|
{showDetails && selectedAppointment && (
|
|
<div className="fixed inset-0 bg-black/40 z-[70] flex items-center justify-center backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
|
<div className="bg-white rounded-[2rem] shadow-2xl w-full max-w-lg overflow-hidden border border-gray-100">
|
|
{/* Header with quick actions */}
|
|
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gray-50/30">
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => { setShowDetails(false); setIsModalOpen(true); }}
|
|
className="p-2.5 bg-white text-gray-400 hover:text-blue-600 rounded-xl border border-gray-100 hover:shadow-md transition-all group"
|
|
title="EDITAR"
|
|
>
|
|
<Edit size={18} className="group-hover:scale-110 transition-transform" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteAppointment(selectedAppointment.id)}
|
|
className="p-2.5 bg-white text-gray-400 hover:text-red-600 rounded-xl border border-gray-100 hover:shadow-md transition-all group"
|
|
title="EXCLUIR"
|
|
>
|
|
<Trash2 size={18} className="group-hover:scale-110 transition-transform" />
|
|
</button>
|
|
</div>
|
|
<button onClick={() => setShowDetails(false)} className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400">
|
|
<X size={24} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-10 space-y-8">
|
|
<div className="flex items-start gap-5">
|
|
<div className="p-4 bg-blue-50 rounded-2xl">
|
|
<User size={28} className="text-blue-600" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<h3 className="text-xl font-black text-gray-900 leading-none uppercase tracking-tighter">{selectedAppointment.pacienteNome}</h3>
|
|
<div className="flex items-center gap-2 text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-2 bg-gray-100 px-3 py-1 rounded-full w-fit">
|
|
<Stethoscope size={12} className="text-blue-400" /> {selectedAppointment.procedimento}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-8 py-6 border-y border-gray-50">
|
|
<div className="space-y-3">
|
|
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Data e Hora</label>
|
|
<div className="flex items-center gap-2 text-sm font-bold text-gray-700">
|
|
<CalendarRange size={16} className="text-blue-500" />
|
|
{new Date(selectedAppointment.start).toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' })}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm font-bold text-gray-700">
|
|
<Clock size={16} className="text-blue-500" />
|
|
{new Date(selectedAppointment.start).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Profissional</label>
|
|
<div className="flex items-center gap-2 text-sm font-bold text-gray-700">
|
|
<div className="w-4 h-4 rounded-full border-2 border-white shadow-sm" style={{ backgroundColor: dentists?.find(d => d.id === selectedAppointment.dentistaId)?.corAgenda }}></div>
|
|
{(selectedAppointment as any).dentistaNome}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status and Action Buttons */}
|
|
<div className="space-y-4">
|
|
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Status do Atendimento</label>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<button
|
|
onClick={() => handleUpdateStatus(selectedAppointment.id, 'Faltou')}
|
|
className={`flex items-center justify-center gap-2 py-4 rounded-2xl text-xs font-black uppercase tracking-widest transition-all border-2 ${selectedAppointment.status === 'Faltou' ? 'bg-red-50 text-red-600 border-red-200' : 'bg-white border-gray-100 text-gray-500 hover:border-red-200 hover:text-red-500'}`}
|
|
>
|
|
<AlertCircle size={16} /> Marcar Falta
|
|
</button>
|
|
<button
|
|
onClick={() => handleUpdateStatus(selectedAppointment.id, 'Confirmado')}
|
|
className={`flex items-center justify-center gap-2 py-4 rounded-2xl text-xs font-black uppercase tracking-widest transition-all border-2 ${selectedAppointment.status === 'Confirmado' ? 'bg-emerald-50 text-emerald-600 border-emerald-200' : 'bg-white border-gray-100 text-gray-500 hover:border-emerald-200 hover:text-emerald-500'}`}
|
|
>
|
|
<CheckCircle2 size={16} /> Confirmar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer: Reagendar */}
|
|
<div className="px-10 py-8 bg-gray-50/50 border-t border-gray-100 flex gap-4">
|
|
<button
|
|
onClick={() => { setShowDetails(false); setIsModalOpen(true); }}
|
|
className="flex-1 py-4 bg-blue-600 text-white rounded-2xl text-xs font-black uppercase tracking-widest hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-200 transition-all shadow-lg shadow-blue-100"
|
|
>
|
|
Reagendar Consulta
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<style>{`
|
|
.animate-in { animation: animate-in 0.2s ease-out; }
|
|
@keyframes animate-in {
|
|
from { opacity: 0; transform: scale(0.95); }
|
|
to { opacity: 1; transform: scale(1); }
|
|
}
|
|
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
|
.custom-scrollbar::-webkit-scrollbar-track { background: #f1f1f1; }
|
|
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
|
|
`}</style>
|
|
<AppointmentModal
|
|
isOpen={isModalOpen}
|
|
onClose={() => { setIsModalOpen(false); setSelectedAppointment(null); }}
|
|
onSave={handleSaveAppointment}
|
|
dentists={dentists}
|
|
initialDate={selectedDateInfo?.dateStr}
|
|
initialAppointment={selectedAppointment}
|
|
/>
|
|
<AgendaSettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} dentists={dentists} />
|
|
</div>
|
|
);
|
|
}; |