feat: Sistema completo de agendamento com disponibilidade de médicos e reserva de consultas para pacientes
This commit is contained in:
parent
e7aa76df75
commit
6ebbfae4f2
@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:50fbfa0b9343001fbf0b803364ec8a0fe48bcc62537eb59a31abc74674d80d0a
|
|
||||||
size 807015226
|
|
||||||
821
MEDICONNECT 2/src/components/AgendamentoConsulta.tsx
Normal file
821
MEDICONNECT 2/src/components/AgendamentoConsulta.tsx
Normal file
@ -0,0 +1,821 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
addMonths,
|
||||||
|
subMonths,
|
||||||
|
startOfMonth,
|
||||||
|
endOfMonth,
|
||||||
|
eachDayOfInterval,
|
||||||
|
isSameMonth,
|
||||||
|
isSameDay,
|
||||||
|
isToday,
|
||||||
|
parseISO,
|
||||||
|
isBefore,
|
||||||
|
startOfDay,
|
||||||
|
} from "date-fns";
|
||||||
|
import { ptBR } from "date-fns/locale";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Star,
|
||||||
|
MapPin,
|
||||||
|
Video,
|
||||||
|
Clock,
|
||||||
|
CalendarDays,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Stethoscope,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { medicoService } from "../services/medicoService";
|
||||||
|
import { availabilityService } from "../services/availabilityService";
|
||||||
|
import { exceptionService } from "../services/exceptionService";
|
||||||
|
import { consultaService } from "../services/consultaService";
|
||||||
|
|
||||||
|
interface Medico {
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
especialidade: string;
|
||||||
|
crm: string;
|
||||||
|
foto?: string;
|
||||||
|
email?: string;
|
||||||
|
telefone?: string;
|
||||||
|
valorConsulta?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeSlot {
|
||||||
|
inicio: string;
|
||||||
|
fim: string;
|
||||||
|
ativo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DaySchedule {
|
||||||
|
ativo: boolean;
|
||||||
|
horarios: TimeSlot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Availability {
|
||||||
|
domingo: DaySchedule;
|
||||||
|
segunda: DaySchedule;
|
||||||
|
terca: DaySchedule;
|
||||||
|
quarta: DaySchedule;
|
||||||
|
quinta: DaySchedule;
|
||||||
|
sexta: DaySchedule;
|
||||||
|
sabado: DaySchedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Exception {
|
||||||
|
id: string;
|
||||||
|
data: string;
|
||||||
|
motivo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayOfWeekMap: { [key: number]: keyof Availability } = {
|
||||||
|
0: "domingo",
|
||||||
|
1: "segunda",
|
||||||
|
2: "terca",
|
||||||
|
3: "quarta",
|
||||||
|
4: "quinta",
|
||||||
|
5: "sexta",
|
||||||
|
6: "sabado",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AgendamentoConsulta() {
|
||||||
|
const [medicos, setMedicos] = useState<Medico[]>([]);
|
||||||
|
const [filteredMedicos, setFilteredMedicos] = useState<Medico[]>([]);
|
||||||
|
const [selectedMedico, setSelectedMedico] = useState<Medico | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedSpecialty, setSelectedSpecialty] = useState("all");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Calendar and scheduling states
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
|
||||||
|
const [availability, setAvailability] = useState<Availability | null>(null);
|
||||||
|
const [exceptions, setExceptions] = useState<Exception[]>([]);
|
||||||
|
const [availableSlots, setAvailableSlots] = useState<string[]>([]);
|
||||||
|
const [selectedTime, setSelectedTime] = useState("");
|
||||||
|
const [appointmentType, setAppointmentType] = useState<
|
||||||
|
"presencial" | "online"
|
||||||
|
>("presencial");
|
||||||
|
const [motivo, setMotivo] = useState("");
|
||||||
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
|
const [bookingSuccess, setBookingSuccess] = useState(false);
|
||||||
|
const [bookingError, setBookingError] = useState("");
|
||||||
|
|
||||||
|
// Load doctors on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadMedicos();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMedicos = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await medicoService.listarMedicos();
|
||||||
|
setMedicos(data);
|
||||||
|
setFilteredMedicos(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao carregar médicos:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter doctors based on search and specialty
|
||||||
|
useEffect(() => {
|
||||||
|
let filtered = medicos;
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(medico) =>
|
||||||
|
medico.nome.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
medico.especialidade.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSpecialty !== "all") {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(medico) => medico.especialidade === selectedSpecialty
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredMedicos(filtered);
|
||||||
|
}, [searchTerm, selectedSpecialty, medicos]);
|
||||||
|
|
||||||
|
// Get unique specialties
|
||||||
|
const specialties = Array.from(new Set(medicos.map((m) => m.especialidade)));
|
||||||
|
|
||||||
|
// Load availability and exceptions when doctor is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedMedico) {
|
||||||
|
loadDoctorAvailability();
|
||||||
|
loadDoctorExceptions();
|
||||||
|
}
|
||||||
|
}, [selectedMedico]);
|
||||||
|
|
||||||
|
const loadDoctorAvailability = async () => {
|
||||||
|
if (!selectedMedico) return;
|
||||||
|
try {
|
||||||
|
const data = await availabilityService.getAvailability(selectedMedico.id);
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
const avail = data[0];
|
||||||
|
setAvailability({
|
||||||
|
domingo: avail.domingo || { ativo: false, horarios: [] },
|
||||||
|
segunda: avail.segunda || { ativo: false, horarios: [] },
|
||||||
|
terca: avail.terca || { ativo: false, horarios: [] },
|
||||||
|
quarta: avail.quarta || { ativo: false, horarios: [] },
|
||||||
|
quinta: avail.quinta || { ativo: false, horarios: [] },
|
||||||
|
sexta: avail.sexta || { ativo: false, horarios: [] },
|
||||||
|
sabado: avail.sabado || { ativo: false, horarios: [] },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAvailability(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao carregar disponibilidade:", error);
|
||||||
|
setAvailability(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDoctorExceptions = async () => {
|
||||||
|
if (!selectedMedico) return;
|
||||||
|
try {
|
||||||
|
const data = await exceptionService.listExceptions(selectedMedico.id);
|
||||||
|
setExceptions(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao carregar exceções:", error);
|
||||||
|
setExceptions([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate available slots when date is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDate && availability && selectedMedico) {
|
||||||
|
calculateAvailableSlots();
|
||||||
|
} else {
|
||||||
|
setAvailableSlots([]);
|
||||||
|
}
|
||||||
|
}, [selectedDate, availability, exceptions]);
|
||||||
|
|
||||||
|
const calculateAvailableSlots = () => {
|
||||||
|
if (!selectedDate || !availability) return;
|
||||||
|
|
||||||
|
// Check if date is an exception (blocked)
|
||||||
|
const dateStr = format(selectedDate, "yyyy-MM-dd");
|
||||||
|
const isBlocked = exceptions.some((exc) => exc.data === dateStr);
|
||||||
|
|
||||||
|
if (isBlocked) {
|
||||||
|
setAvailableSlots([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get day of week schedule
|
||||||
|
const dayOfWeek = selectedDate.getDay();
|
||||||
|
const dayKey = dayOfWeekMap[dayOfWeek];
|
||||||
|
const daySchedule = availability[dayKey];
|
||||||
|
|
||||||
|
if (!daySchedule || !daySchedule.ativo) {
|
||||||
|
setAvailableSlots([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract active time slots
|
||||||
|
const slots = daySchedule.horarios
|
||||||
|
.filter((slot) => slot.ativo)
|
||||||
|
.map((slot) => slot.inicio);
|
||||||
|
|
||||||
|
setAvailableSlots(slots);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDateBlocked = (date: Date): boolean => {
|
||||||
|
const dateStr = format(date, "yyyy-MM-dd");
|
||||||
|
return exceptions.some((exc) => exc.data === dateStr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDateAvailable = (date: Date): boolean => {
|
||||||
|
if (!availability) return false;
|
||||||
|
|
||||||
|
// Check if in the past
|
||||||
|
if (isBefore(date, startOfDay(new Date()))) return false;
|
||||||
|
|
||||||
|
// Check if blocked
|
||||||
|
if (isDateBlocked(date)) return false;
|
||||||
|
|
||||||
|
// Check if day has available schedule
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
const dayKey = dayOfWeekMap[dayOfWeek];
|
||||||
|
const daySchedule = availability[dayKey];
|
||||||
|
|
||||||
|
return (
|
||||||
|
daySchedule?.ativo && daySchedule.horarios.some((slot) => slot.ativo)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calendar generation
|
||||||
|
const generateCalendarDays = () => {
|
||||||
|
const start = startOfMonth(currentMonth);
|
||||||
|
const end = endOfMonth(currentMonth);
|
||||||
|
const days = eachDayOfInterval({ start, end });
|
||||||
|
|
||||||
|
// Add padding days from previous month
|
||||||
|
const startDay = start.getDay();
|
||||||
|
const prevMonthDays = [];
|
||||||
|
for (let i = startDay - 1; i >= 0; i--) {
|
||||||
|
const day = new Date(start);
|
||||||
|
day.setDate(day.getDate() - (i + 1));
|
||||||
|
prevMonthDays.push(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...prevMonthDays, ...days];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevMonth = () => {
|
||||||
|
setCurrentMonth(subMonths(currentMonth, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextMonth = () => {
|
||||||
|
setCurrentMonth(addMonths(currentMonth, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectDoctor = (medico: Medico) => {
|
||||||
|
setSelectedMedico(medico);
|
||||||
|
setSelectedDate(undefined);
|
||||||
|
setSelectedTime("");
|
||||||
|
setMotivo("");
|
||||||
|
setBookingSuccess(false);
|
||||||
|
setBookingError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBookAppointment = () => {
|
||||||
|
if (selectedMedico && selectedDate && selectedTime && motivo) {
|
||||||
|
setShowConfirmDialog(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmAppointment = async () => {
|
||||||
|
if (!selectedMedico || !selectedDate || !selectedTime) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setBookingError("");
|
||||||
|
|
||||||
|
// Get current user from localStorage
|
||||||
|
const userStr = localStorage.getItem("user");
|
||||||
|
if (!userStr) {
|
||||||
|
setBookingError("Usuário não autenticado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = JSON.parse(userStr);
|
||||||
|
|
||||||
|
// Create date-time string
|
||||||
|
const dataHora = `${format(
|
||||||
|
selectedDate,
|
||||||
|
"yyyy-MM-dd"
|
||||||
|
)}T${selectedTime}:00`;
|
||||||
|
|
||||||
|
// Book appointment via API
|
||||||
|
await consultaService.criarConsulta({
|
||||||
|
medicoId: selectedMedico.id,
|
||||||
|
pacienteId: user.id,
|
||||||
|
dataHora,
|
||||||
|
tipoConsulta: appointmentType,
|
||||||
|
motivoConsulta: motivo,
|
||||||
|
status: "agendada",
|
||||||
|
});
|
||||||
|
|
||||||
|
setBookingSuccess(true);
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
|
||||||
|
// Reset form after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setSelectedMedico(null);
|
||||||
|
setSelectedDate(undefined);
|
||||||
|
setSelectedTime("");
|
||||||
|
setMotivo("");
|
||||||
|
setBookingSuccess(false);
|
||||||
|
}, 3000);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Erro ao agendar consulta:", error);
|
||||||
|
setBookingError(
|
||||||
|
error.message || "Erro ao agendar consulta. Tente novamente."
|
||||||
|
);
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calendarDays = generateCalendarDays();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* Success Message */}
|
||||||
|
{bookingSuccess && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-center gap-3">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-green-900 dark:text-green-100">
|
||||||
|
Consulta agendada com sucesso!
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-green-700 dark:text-green-300">
|
||||||
|
Você receberá uma confirmação por e-mail em breve.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{bookingError && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-center gap-3">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||||
|
<p className="text-red-900 dark:text-red-100">{bookingError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Agendar Consulta
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Escolha um médico e horário disponível
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Buscar Médicos
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Buscar por nome ou especialidade
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex: Cardiologia, Dr. Silva..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Especialidade
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedSpecialty}
|
||||||
|
onChange={(e) => setSelectedSpecialty(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="all">Todas as especialidades</option>
|
||||||
|
{specialties.map((specialty) => (
|
||||||
|
<option key={specialty} value={specialty}>
|
||||||
|
{specialty}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Doctors List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
<p className="mt-2 text-gray-600 dark:text-gray-400">
|
||||||
|
Carregando médicos...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : filteredMedicos.length === 0 ? (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-12 text-center">
|
||||||
|
<Stethoscope className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Nenhum médico encontrado
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{filteredMedicos.map((medico) => (
|
||||||
|
<div
|
||||||
|
key={medico.id}
|
||||||
|
className={`bg-white dark:bg-gray-800 rounded-lg shadow p-6 transition-all ${
|
||||||
|
selectedMedico?.id === medico.id ? "ring-2 ring-blue-500" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="h-16 w-16 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-blue-600 dark:text-blue-300 text-xl font-bold">
|
||||||
|
{medico.nome
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.substring(0, 2)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{medico.nome}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
{medico.especialidade}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||||
|
CRM: {medico.crm}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-lg font-semibold text-blue-600 dark:text-blue-400">
|
||||||
|
{medico.valorConsulta
|
||||||
|
? `R$ ${medico.valorConsulta.toFixed(2)}`
|
||||||
|
: "Consultar valor"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelectDoctor(medico)}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
selectedMedico?.id === medico.id
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedMedico?.id === medico.id
|
||||||
|
? "Selecionado"
|
||||||
|
: "Selecionar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Appointment Details */}
|
||||||
|
{selectedMedico && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Detalhes do Agendamento
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Consulta com {selectedMedico.nome} -{" "}
|
||||||
|
{selectedMedico.especialidade}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Appointment Type */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setAppointmentType("presencial")}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 transition-colors ${
|
||||||
|
appointmentType === "presencial"
|
||||||
|
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
|
||||||
|
: "border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MapPin className="h-5 w-5" />
|
||||||
|
<span className="font-medium">Presencial</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setAppointmentType("online")}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 transition-colors ${
|
||||||
|
appointmentType === "online"
|
||||||
|
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
|
||||||
|
: "border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Video className="h-5 w-5" />
|
||||||
|
<span className="font-medium">Online</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Calendar */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Selecione a Data
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
{/* Month/Year Navigation */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<button
|
||||||
|
onClick={handlePrevMonth}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{format(currentMonth, "MMMM yyyy", { locale: ptBR })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleNextMonth}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Grid */}
|
||||||
|
<div className="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||||
|
{/* Days of week header */}
|
||||||
|
<div className="grid grid-cols-7 bg-gray-50 dark:bg-gray-700">
|
||||||
|
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map(
|
||||||
|
(day) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className="text-center py-2 text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar days */}
|
||||||
|
<div className="grid grid-cols-7">
|
||||||
|
{calendarDays.map((day, index) => {
|
||||||
|
const isCurrentMonth = isSameMonth(day, currentMonth);
|
||||||
|
const isSelected =
|
||||||
|
selectedDate && isSameDay(day, selectedDate);
|
||||||
|
const isTodayDate = isToday(day);
|
||||||
|
const isAvailable =
|
||||||
|
isCurrentMonth && isDateAvailable(day);
|
||||||
|
const isBlocked = isCurrentMonth && isDateBlocked(day);
|
||||||
|
const isPast = isBefore(day, startOfDay(new Date()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => isAvailable && setSelectedDate(day)}
|
||||||
|
disabled={!isAvailable}
|
||||||
|
className={`
|
||||||
|
aspect-square p-2 text-sm border-r border-b border-gray-200 dark:border-gray-700
|
||||||
|
${
|
||||||
|
!isCurrentMonth
|
||||||
|
? "text-gray-300 dark:text-gray-600 bg-gray-50 dark:bg-gray-800"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${
|
||||||
|
isSelected
|
||||||
|
? "bg-blue-600 text-white font-bold"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${
|
||||||
|
isTodayDate && !isSelected
|
||||||
|
? "font-bold text-blue-600 dark:text-blue-400"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${
|
||||||
|
isAvailable && !isSelected
|
||||||
|
? "hover:bg-blue-50 dark:hover:bg-blue-900/20 cursor-pointer"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${
|
||||||
|
isBlocked
|
||||||
|
? "bg-red-50 dark:bg-red-900/20 text-red-400 line-through"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${
|
||||||
|
isPast && !isBlocked
|
||||||
|
? "text-gray-400 dark:text-gray-600"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${
|
||||||
|
!isAvailable &&
|
||||||
|
!isBlocked &&
|
||||||
|
isCurrentMonth &&
|
||||||
|
!isPast
|
||||||
|
? "text-gray-300 dark:text-gray-600"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{format(day, "d")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mt-3 space-y-1 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<p>🟢 Datas disponíveis</p>
|
||||||
|
<p>🔴 Datas bloqueadas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Slots and Details */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Horários Disponíveis
|
||||||
|
</label>
|
||||||
|
{selectedDate ? (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{format(selectedDate, "EEEE, d 'de' MMMM 'de' yyyy", {
|
||||||
|
locale: ptBR,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Selecione uma data
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedDate && availableSlots.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{availableSlots.map((slot) => (
|
||||||
|
<button
|
||||||
|
key={slot}
|
||||||
|
onClick={() => setSelectedTime(slot)}
|
||||||
|
className={`flex items-center justify-center gap-1 py-2 rounded-lg border-2 transition-colors ${
|
||||||
|
selectedTime === slot
|
||||||
|
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium"
|
||||||
|
: "border-gray-300 dark:border-gray-600 hover:border-blue-300 dark:hover:border-blue-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{slot}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : selectedDate ? (
|
||||||
|
<div className="p-4 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50 text-center">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Nenhum horário disponível para esta data
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50 text-center">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Selecione uma data para ver os horários
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reason */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Motivo da Consulta *
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
placeholder="Descreva brevemente o motivo da consulta..."
|
||||||
|
value={motivo}
|
||||||
|
onChange={(e) => setMotivo(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{selectedDate && selectedTime && (
|
||||||
|
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg space-y-2">
|
||||||
|
<h4 className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
Resumo
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<p>📅 Data: {format(selectedDate, "dd/MM/yyyy")}</p>
|
||||||
|
<p>⏰ Horário: {selectedTime}</p>
|
||||||
|
<p>
|
||||||
|
📍 Tipo:{" "}
|
||||||
|
{appointmentType === "online" ? "Online" : "Presencial"}
|
||||||
|
</p>
|
||||||
|
{selectedMedico.valorConsulta && (
|
||||||
|
<p>
|
||||||
|
💰 Valor: R$ {selectedMedico.valorConsulta.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleBookAppointment}
|
||||||
|
disabled={!selectedTime || !motivo.trim()}
|
||||||
|
className="w-full py-3 rounded-lg font-semibold bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-300 dark:disabled:bg-gray-700 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Confirmar Agendamento
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation Dialog */}
|
||||||
|
{showConfirmDialog && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6 space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Confirmar Agendamento
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Revise os detalhes da sua consulta antes de confirmar
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-blue-600 dark:text-blue-300 font-bold">
|
||||||
|
{selectedMedico?.nome
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.substring(0, 2)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{selectedMedico?.nome}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{selectedMedico?.especialidade}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<p>
|
||||||
|
📅 Data: {selectedDate && format(selectedDate, "dd/MM/yyyy")}
|
||||||
|
</p>
|
||||||
|
<p>⏰ Horário: {selectedTime}</p>
|
||||||
|
<p>
|
||||||
|
📍 Tipo:{" "}
|
||||||
|
{appointmentType === "online"
|
||||||
|
? "Consulta Online"
|
||||||
|
: "Consulta Presencial"}
|
||||||
|
</p>
|
||||||
|
{selectedMedico?.valorConsulta && (
|
||||||
|
<p>💰 Valor: R$ {selectedMedico.valorConsulta.toFixed(2)}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white mb-1">
|
||||||
|
Motivo:
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">{motivo}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirmDialog(false)}
|
||||||
|
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirmAppointment}
|
||||||
|
className="flex-1 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Confirmar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
698
MEDICONNECT 2/src/components/DisponibilidadeMedico.tsx
Normal file
698
MEDICONNECT 2/src/components/DisponibilidadeMedico.tsx
Normal file
@ -0,0 +1,698 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Save,
|
||||||
|
Copy,
|
||||||
|
Calendar as CalendarIcon,
|
||||||
|
AlertCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { format, addDays, startOfWeek } from "date-fns";
|
||||||
|
import { ptBR } from "date-fns/locale";
|
||||||
|
import availabilityService from "../services/availabilityService";
|
||||||
|
import exceptionService from "../services/exceptionService";
|
||||||
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
|
interface TimeSlot {
|
||||||
|
id: string;
|
||||||
|
inicio: string;
|
||||||
|
fim: string;
|
||||||
|
ativo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DaySchedule {
|
||||||
|
day: string;
|
||||||
|
dayOfWeek: number;
|
||||||
|
enabled: boolean;
|
||||||
|
slots: TimeSlot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysOfWeek = [
|
||||||
|
{ key: 0, label: "Domingo", dbKey: "domingo" },
|
||||||
|
{ key: 1, label: "Segunda-feira", dbKey: "segunda" },
|
||||||
|
{ key: 2, label: "Terça-feira", dbKey: "terca" },
|
||||||
|
{ key: 3, label: "Quarta-feira", dbKey: "quarta" },
|
||||||
|
{ key: 4, label: "Quinta-feira", dbKey: "quinta" },
|
||||||
|
{ key: 5, label: "Sexta-feira", dbKey: "sexta" },
|
||||||
|
{ key: 6, label: "Sábado", dbKey: "sabado" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DisponibilidadeMedico: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const medicoId = user?.id || "";
|
||||||
|
|
||||||
|
const [schedule, setSchedule] = useState<Record<number, DaySchedule>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<"weekly" | "blocked" | "settings">(
|
||||||
|
"weekly"
|
||||||
|
);
|
||||||
|
|
||||||
|
// States for adding slots
|
||||||
|
const [showAddSlotDialog, setShowAddSlotDialog] = useState(false);
|
||||||
|
const [selectedDay, setSelectedDay] = useState<number | null>(null);
|
||||||
|
const [newSlot, setNewSlot] = useState({ inicio: "09:00", fim: "10:00" });
|
||||||
|
|
||||||
|
// States for blocked dates
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
const [blockedDates, setBlockedDates] = useState<Date[]>([]);
|
||||||
|
const [exceptions, setExceptions] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
const [consultationDuration, setConsultationDuration] = useState("60");
|
||||||
|
const [breakTime, setBreakTime] = useState("0");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (medicoId) {
|
||||||
|
loadAvailability();
|
||||||
|
loadExceptions();
|
||||||
|
}
|
||||||
|
}, [medicoId]);
|
||||||
|
|
||||||
|
const loadAvailability = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await availabilityService.getAvailability(medicoId);
|
||||||
|
|
||||||
|
if (response.success && response.data && response.data.length > 0) {
|
||||||
|
const availabilityData = response.data[0];
|
||||||
|
const newSchedule: Record<number, DaySchedule> = {};
|
||||||
|
|
||||||
|
daysOfWeek.forEach(({ key, label, dbKey }) => {
|
||||||
|
const dayData = availabilityData[dbKey];
|
||||||
|
newSchedule[key] = {
|
||||||
|
day: label,
|
||||||
|
dayOfWeek: key,
|
||||||
|
enabled: dayData?.ativo || false,
|
||||||
|
slots:
|
||||||
|
dayData?.horarios?.map((h: any, index: number) => ({
|
||||||
|
id: `${key}-${index}`,
|
||||||
|
inicio: h.inicio,
|
||||||
|
fim: h.fim,
|
||||||
|
ativo: h.ativo !== false,
|
||||||
|
})) || [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setSchedule(newSchedule);
|
||||||
|
} else {
|
||||||
|
// Initialize empty schedule
|
||||||
|
const newSchedule: Record<number, DaySchedule> = {};
|
||||||
|
daysOfWeek.forEach(({ key, label }) => {
|
||||||
|
newSchedule[key] = {
|
||||||
|
day: label,
|
||||||
|
dayOfWeek: key,
|
||||||
|
enabled: false,
|
||||||
|
slots: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setSchedule(newSchedule);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao carregar disponibilidade:", error);
|
||||||
|
toast.error("Erro ao carregar disponibilidade");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadExceptions = async () => {
|
||||||
|
try {
|
||||||
|
const response = await exceptionService.listExceptions(medicoId);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setExceptions(response.data);
|
||||||
|
const blocked = response.data
|
||||||
|
.filter((exc: any) => exc.tipo === "bloqueio")
|
||||||
|
.map((exc: any) => new Date(exc.data));
|
||||||
|
setBlockedDates(blocked);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao carregar exceções:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDay = (dayKey: number) => {
|
||||||
|
setSchedule((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[dayKey]: {
|
||||||
|
...prev[dayKey],
|
||||||
|
enabled: !prev[dayKey].enabled,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTimeSlot = () => {
|
||||||
|
if (selectedDay !== null) {
|
||||||
|
const newSlotId = `${selectedDay}-${Date.now()}`;
|
||||||
|
setSchedule((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[selectedDay]: {
|
||||||
|
...prev[selectedDay],
|
||||||
|
slots: [
|
||||||
|
...prev[selectedDay].slots,
|
||||||
|
{
|
||||||
|
id: newSlotId,
|
||||||
|
inicio: newSlot.inicio,
|
||||||
|
fim: newSlot.fim,
|
||||||
|
ativo: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
setShowAddSlotDialog(false);
|
||||||
|
setNewSlot({ inicio: "09:00", fim: "10:00" });
|
||||||
|
setSelectedDay(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTimeSlot = (dayKey: number, slotId: string) => {
|
||||||
|
setSchedule((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[dayKey]: {
|
||||||
|
...prev[dayKey],
|
||||||
|
slots: prev[dayKey].slots.filter((slot) => slot.id !== slotId),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSlotAvailability = (dayKey: number, slotId: string) => {
|
||||||
|
setSchedule((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[dayKey]: {
|
||||||
|
...prev[dayKey],
|
||||||
|
slots: prev[dayKey].slots.map((slot) =>
|
||||||
|
slot.id === slotId ? { ...slot, ativo: !slot.ativo } : slot
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const copySchedule = (fromDay: number) => {
|
||||||
|
const sourceSchedule = schedule[fromDay];
|
||||||
|
if (!sourceSchedule.enabled || sourceSchedule.slots.length === 0) {
|
||||||
|
toast.error("Dia não tem horários configurados");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSchedule = { ...schedule };
|
||||||
|
Object.keys(updatedSchedule).forEach((key) => {
|
||||||
|
const dayKey = Number(key);
|
||||||
|
if (dayKey !== fromDay && updatedSchedule[dayKey].enabled) {
|
||||||
|
updatedSchedule[dayKey].slots = sourceSchedule.slots.map((slot) => ({
|
||||||
|
...slot,
|
||||||
|
id: `${dayKey}-${slot.id}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setSchedule(updatedSchedule);
|
||||||
|
toast.success("Horários copiados com sucesso!");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSchedule = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
// Build availability object
|
||||||
|
const availabilityData: any = {
|
||||||
|
medico_id: medicoId,
|
||||||
|
};
|
||||||
|
|
||||||
|
daysOfWeek.forEach(({ key, dbKey }) => {
|
||||||
|
const daySchedule = schedule[key];
|
||||||
|
availabilityData[dbKey] = {
|
||||||
|
ativo: daySchedule.enabled,
|
||||||
|
horarios: daySchedule.slots.map((slot) => ({
|
||||||
|
inicio: slot.inicio,
|
||||||
|
fim: slot.fim,
|
||||||
|
ativo: slot.ativo,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await availabilityService.createAvailability(
|
||||||
|
availabilityData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("Disponibilidade salva com sucesso!");
|
||||||
|
loadAvailability();
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || "Erro ao salvar");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Erro ao salvar disponibilidade:", error);
|
||||||
|
toast.error(error.message || "Erro ao salvar disponibilidade");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleBlockedDate = async () => {
|
||||||
|
if (!selectedDate) return;
|
||||||
|
|
||||||
|
const dateString = format(selectedDate, "yyyy-MM-dd");
|
||||||
|
const dateExists = blockedDates.some(
|
||||||
|
(d) => format(d, "yyyy-MM-dd") === dateString
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (dateExists) {
|
||||||
|
// Remove block
|
||||||
|
const exception = exceptions.find(
|
||||||
|
(exc) => format(new Date(exc.data), "yyyy-MM-dd") === dateString
|
||||||
|
);
|
||||||
|
if (exception) {
|
||||||
|
await exceptionService.deleteException(exception._id);
|
||||||
|
setBlockedDates(
|
||||||
|
blockedDates.filter((d) => format(d, "yyyy-MM-dd") !== dateString)
|
||||||
|
);
|
||||||
|
toast.success("Data desbloqueada");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add block
|
||||||
|
const response = await exceptionService.createException({
|
||||||
|
medicoId,
|
||||||
|
data: dateString,
|
||||||
|
tipo: "bloqueio",
|
||||||
|
motivo: "Data bloqueada pelo médico",
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
setBlockedDates([...blockedDates, selectedDate]);
|
||||||
|
toast.success("Data bloqueada");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadExceptions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao alternar bloqueio de data:", error);
|
||||||
|
toast.error("Erro ao bloquear/desbloquear data");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Gerenciar Disponibilidade
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Configure seus horários de atendimento
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveSchedule}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{saving ? "Salvando..." : "Salvar Alterações"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav className="-mb-px flex space-x-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("weekly")}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === "weekly"
|
||||||
|
? "border-indigo-500 text-indigo-600 dark:text-indigo-400"
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Horário Semanal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("blocked")}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === "blocked"
|
||||||
|
? "border-indigo-500 text-indigo-600 dark:text-indigo-400"
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Datas Bloqueadas ({blockedDates.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("settings")}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === "settings"
|
||||||
|
? "border-indigo-500 text-indigo-600 dark:text-indigo-400"
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Configurações
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content - Weekly Schedule */}
|
||||||
|
{activeTab === "weekly" && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
Horários por Dia da Semana
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Defina seus horários de atendimento para cada dia da semana
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{daysOfWeek.map(({ key, label }) => (
|
||||||
|
<div key={key} className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only peer"
|
||||||
|
checked={schedule[key]?.enabled || false}
|
||||||
|
onChange={() => toggleDay(key)}
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 dark:peer-focus:ring-indigo-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600"></div>
|
||||||
|
</label>
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{schedule[key]?.enabled && (
|
||||||
|
<span className="px-2 py-1 bg-indigo-100 dark:bg-indigo-900 text-indigo-600 dark:text-indigo-400 text-xs rounded">
|
||||||
|
{schedule[key]?.slots.length || 0} horário(s)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{schedule[key]?.enabled && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => copySchedule(key)}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Copiar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedDay(key);
|
||||||
|
setShowAddSlotDialog(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Adicionar Horário
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{schedule[key]?.enabled && (
|
||||||
|
<div className="ml-14 space-y-2">
|
||||||
|
{schedule[key]?.slots.length === 0 ? (
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
Nenhum horário configurado
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
schedule[key]?.slots.map((slot) => (
|
||||||
|
<div
|
||||||
|
key={slot.id}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only peer"
|
||||||
|
checked={slot.ativo}
|
||||||
|
onChange={() =>
|
||||||
|
toggleSlotAvailability(key, slot.id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 dark:peer-focus:ring-indigo-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600"></div>
|
||||||
|
</label>
|
||||||
|
<Clock className="h-4 w-4 text-gray-400" />
|
||||||
|
<span className="text-gray-900 dark:text-white">
|
||||||
|
{slot.inicio} - {slot.fim}
|
||||||
|
</span>
|
||||||
|
{!slot.ativo && (
|
||||||
|
<span className="px-2 py-1 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 text-xs rounded">
|
||||||
|
Bloqueado
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeTimeSlot(key, slot.id)}
|
||||||
|
className="p-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab Content - Blocked Dates */}
|
||||||
|
{activeTab === "blocked" && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Selecionar Datas
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Clique em uma data no calendário e depois no botão para
|
||||||
|
bloquear/desbloquear
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={selectedDate ? format(selectedDate, "yyyy-MM-dd") : ""}
|
||||||
|
onChange={(e) => setSelectedDate(new Date(e.target.value))}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={toggleBlockedDate}
|
||||||
|
disabled={!selectedDate}
|
||||||
|
className="w-full py-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{selectedDate &&
|
||||||
|
blockedDates.some(
|
||||||
|
(d) =>
|
||||||
|
format(d, "yyyy-MM-dd") ===
|
||||||
|
format(selectedDate, "yyyy-MM-dd")
|
||||||
|
)
|
||||||
|
? "Desbloquear Data"
|
||||||
|
: "Bloquear Data"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
Datas Bloqueadas
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
{blockedDates.length} data(s) bloqueada(s)
|
||||||
|
</p>
|
||||||
|
{blockedDates.length === 0 ? (
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
|
||||||
|
Nenhuma data bloqueada
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{blockedDates.map((date, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<span className="text-gray-900 dark:text-white">
|
||||||
|
{format(date, "EEEE, dd 'de' MMMM 'de' yyyy", {
|
||||||
|
locale: ptBR,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedDate(date);
|
||||||
|
toggleBlockedDate();
|
||||||
|
}}
|
||||||
|
className="p-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab Content - Settings */}
|
||||||
|
{activeTab === "settings" && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
Configurações de Consulta
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Defina as configurações padrão para suas consultas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
Duração Padrão da Consulta
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={consultationDuration}
|
||||||
|
onChange={(e) => setConsultationDuration(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="30">30 minutos</option>
|
||||||
|
<option value="45">45 minutos</option>
|
||||||
|
<option value="60">1 hora</option>
|
||||||
|
<option value="90">1 hora e 30 minutos</option>
|
||||||
|
<option value="120">2 horas</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||||
|
Esta duração será usada para calcular os horários disponíveis
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
Intervalo entre Consultas
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={breakTime}
|
||||||
|
onChange={(e) => setBreakTime(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="0">Sem intervalo</option>
|
||||||
|
<option value="15">15 minutos</option>
|
||||||
|
<option value="30">30 minutos</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||||
|
Tempo de descanso entre consultas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-900 dark:text-white font-medium">
|
||||||
|
Aceitar consultas online
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
Permitir agendamento de teleconsultas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only peer"
|
||||||
|
defaultChecked
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 dark:peer-focus:ring-indigo-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-900 dark:text-white font-medium">
|
||||||
|
Confirmação automática
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
Aprovar agendamentos automaticamente
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" className="sr-only peer" />
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 dark:peer-focus:ring-indigo-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Time Slot Dialog */}
|
||||||
|
{showAddSlotDialog && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Adicionar Horário
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Defina o período de atendimento para{" "}
|
||||||
|
{selectedDay !== null ? schedule[selectedDay]?.day : ""}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
Horário de Início
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={newSlot.inicio}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewSlot({ ...newSlot, inicio: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
Horário de Término
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={newSlot.fim}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewSlot({ ...newSlot, fim: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowAddSlotDialog(false);
|
||||||
|
setSelectedDay(null);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={addTimeSlot}
|
||||||
|
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Adicionar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DisponibilidadeMedico;
|
||||||
@ -24,6 +24,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import consultaService from "../services/consultaService";
|
import consultaService from "../services/consultaService";
|
||||||
import medicoService from "../services/medicoService";
|
import medicoService from "../services/medicoService";
|
||||||
|
import AgendamentoConsulta from "../components/AgendamentoConsulta";
|
||||||
|
|
||||||
interface Consulta {
|
interface Consulta {
|
||||||
_id: string;
|
_id: string;
|
||||||
@ -40,10 +41,12 @@ interface Consulta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Medico {
|
interface Medico {
|
||||||
_id: string;
|
_id?: string;
|
||||||
|
id?: string;
|
||||||
nome: string;
|
nome: string;
|
||||||
especialidade: string;
|
especialidade: string;
|
||||||
valorConsulta: number;
|
valorConsulta?: number;
|
||||||
|
valor_consulta?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AcompanhamentoPaciente: React.FC = () => {
|
const AcompanhamentoPaciente: React.FC = () => {
|
||||||
@ -68,58 +71,61 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
if (!pacienteId) return;
|
if (!pacienteId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Buscar consultas locais
|
// Buscar consultas da API
|
||||||
const raw = localStorage.getItem("consultas_local");
|
const consultasResp = await consultaService.listarConsultas({
|
||||||
let lista: any[] = [];
|
paciente_id: pacienteId,
|
||||||
if (raw) {
|
});
|
||||||
try {
|
|
||||||
lista = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
lista = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const minhasConsultas = lista.filter(
|
|
||||||
(c) => c.pacienteId === pacienteId || c.pacienteId === user?.email
|
|
||||||
);
|
|
||||||
|
|
||||||
// Buscar médicos
|
// Buscar médicos
|
||||||
const medicosResp = await medicoService.listarMedicos({});
|
const medicosResp = await medicoService.listarMedicos({});
|
||||||
if (medicosResp.success && medicosResp.data) {
|
if (medicosResp.success && medicosResp.data) {
|
||||||
setMedicos(medicosResp.data.data);
|
setMedicos(medicosResp.data.data as Medico[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (consultasResp.success && consultasResp.data) {
|
||||||
|
const consultasData = Array.isArray(consultasResp.data)
|
||||||
|
? consultasResp.data
|
||||||
|
: consultasResp.data.data || [];
|
||||||
|
|
||||||
setConsultas(
|
setConsultas(
|
||||||
minhasConsultas.map((c) => ({
|
consultasData.map((c) => ({
|
||||||
_id: c.id,
|
_id: c._id || c.id,
|
||||||
pacienteId: c.pacienteId,
|
pacienteId: c.pacienteId,
|
||||||
medicoId: c.medicoId,
|
medicoId: c.medicoId,
|
||||||
dataHora: c.dataHora,
|
dataHora: c.dataHora,
|
||||||
status: c.status || "agendada",
|
status: c.status || "agendada",
|
||||||
tipoConsulta: c.tipo || "presencial",
|
tipoConsulta: c.tipoConsulta || c.tipo || "presencial",
|
||||||
motivoConsulta: c.observacoes || "Consulta médica",
|
motivoConsulta:
|
||||||
|
c.motivoConsulta || c.observacoes || "Consulta médica",
|
||||||
observacoes: c.observacoes,
|
observacoes: c.observacoes,
|
||||||
|
resultados: c.resultados,
|
||||||
|
prescricoes: c.prescricoes,
|
||||||
|
proximaConsulta: c.proximaConsulta,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
setConsultas([]);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao carregar consultas:", error);
|
console.error("Erro ao carregar consultas:", error);
|
||||||
toast.error("Erro ao carregar consultas");
|
toast.error("Erro ao carregar consultas");
|
||||||
|
setConsultas([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [pacienteId, user?.email]);
|
}, [pacienteId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConsultas();
|
fetchConsultas();
|
||||||
}, [fetchConsultas]);
|
}, [fetchConsultas]);
|
||||||
|
|
||||||
const getMedicoNome = (medicoId: string) => {
|
const getMedicoNome = (medicoId: string) => {
|
||||||
const medico = medicos.find((m) => m._id === medicoId);
|
const medico = medicos.find((m) => m._id === medicoId || m.id === medicoId);
|
||||||
return medico?.nome || "Médico";
|
return medico?.nome || "Médico";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMedicoEspecialidade = (medicoId: string) => {
|
const getMedicoEspecialidade = (medicoId: string) => {
|
||||||
const medico = medicos.find((m) => m._id === medicoId);
|
const medico = medicos.find((m) => m._id === medicoId || m.id === medicoId);
|
||||||
return medico?.especialidade || "Especialidade";
|
return medico?.especialidade || "Especialidade";
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -625,24 +631,7 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Book Appointment Content
|
// Book Appointment Content
|
||||||
const renderBookAppointment = () => (
|
const renderBookAppointment = () => <AgendamentoConsulta />;
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
Agendar Consulta
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Escolha um médico e horário disponível
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
|
|
||||||
<p className="text-center py-16 text-gray-600 dark:text-gray-400">
|
|
||||||
Funcionalidade de agendamento em desenvolvimento
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Messages Content
|
// Messages Content
|
||||||
const renderMessages = () => (
|
const renderMessages = () => (
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { useAuth } from "../hooks/useAuth";
|
|||||||
import relatorioService, {
|
import relatorioService, {
|
||||||
RelatorioCreate,
|
RelatorioCreate,
|
||||||
} from "../services/relatorioService";
|
} from "../services/relatorioService";
|
||||||
|
import DisponibilidadeMedico from "../components/DisponibilidadeMedico";
|
||||||
import ConsultaModal from "../components/consultas/ConsultaModal";
|
import ConsultaModal from "../components/consultas/ConsultaModal";
|
||||||
import AvailabilityManager from "../components/agenda/AvailabilityManager";
|
import AvailabilityManager from "../components/agenda/AvailabilityManager";
|
||||||
import ExceptionsManager from "../components/agenda/ExceptionsManager";
|
import ExceptionsManager from "../components/agenda/ExceptionsManager";
|
||||||
@ -758,19 +759,7 @@ const PainelMedico: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderAvailability = () => (
|
const renderAvailability = () => <DisponibilidadeMedico />;
|
||||||
<div className="space-y-6">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
Gerenciar Disponibilidade
|
|
||||||
</h1>
|
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
|
|
||||||
<AvailabilityManager doctorId={medicoId} />
|
|
||||||
</div>
|
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
|
|
||||||
<ExceptionsManager doctorId={medicoId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderReports = () => (
|
const renderReports = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user