Criação do componente de agendamento

This commit is contained in:
Lucas Deiró Rodrigues 2025-11-08 22:44:03 -03:00
parent 4376cdefd1
commit a52f10d362
3 changed files with 490 additions and 901 deletions

View File

@ -1,557 +1,12 @@
"use client";
import { usersService } from "services/usersApi.mjs";
import { doctorsService } from "services/doctorsApi.mjs";
import { appointmentsService } from "services/appointmentsApi.mjs";
import { AvailabilityService } from "services/availabilityApi.mjs";
import { Calendar as CalendarShadcn } from "@/components/ui/calendar";
import { format, addDays } from "date-fns";
import { useState, useEffect, useCallback, useRef } from "react";
import { Calendar, Clock, User, StickyNote } from "lucide-react";
// app/patient/appointments/page.tsx
import PatientLayout from "@/components/patient-layout";
import {Card,CardContent,CardHeader,CardTitle,} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {Select,SelectContent,SelectItem,SelectTrigger,SelectValue,} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "@/hooks/use-toast";
import ScheduleForm from "@/components/schedule/schedule-form";
interface Doctor {
id: string;
full_name: string;
specialty: string;
}
interface Disponibilidade {
id?: string;
doctor_id?: string;
weekday: string;
start_time: string;
end_time: string;
slot_minutes?: number;
}
export default function ScheduleAppointment() {
const [selectedDoctor, setSelectedDoctor] = useState("");
const [selectedDate, setSelectedDate] = useState("");
const [selectedTime, setSelectedTime] = useState("");
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [loadingDoctors, setLoadingDoctors] = useState(true);
const [tipoConsulta, setTipoConsulta] = useState("presencial");
const [duracao, setDuracao] = useState("30");
const [notes, setNotes] = useState("");
const [disponibilidades, setDisponibilidades] = useState<Disponibilidade[]>([]);
const [availableWeekdays, setAvailableWeekdays] = useState<number[]>([]); // 1..7
const [availabilityCounts, setAvailabilityCounts] = useState<Record<string, number>>({}); // "yyyy-MM-dd" -> count
const calendarRef = useRef<HTMLDivElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
// --- Helpers ---
const getWeekdayNumber = (weekday: string) =>
["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
.indexOf(weekday.toLowerCase()) + 1; // monday=1 ... sunday=7
const getBrazilDate = (date: Date) =>
new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0));
// --- Fetch doctors ---
const fetchDoctors = useCallback(async () => {
setLoadingDoctors(true);
try {
const data: Doctor[] = await doctorsService.list();
setDoctors(data || []);
} catch (e) {
console.error("Erro ao buscar médicos:", e);
toast({ title: "Erro", description: "Não foi possível carregar médicos." });
} finally {
setLoadingDoctors(false);
}
}, []);
// --- Load disponibilidades details for selected doctor and compute weekdays ---
const loadDoctorDisponibilidades = useCallback(async (doctorId?: string) => {
if (!doctorId) {
setDisponibilidades([]);
setAvailableWeekdays([]);
setAvailabilityCounts({});
return;
}
try {
const disp: Disponibilidade[] = await AvailabilityService.listById(doctorId);
setDisponibilidades(disp || []);
const nums = (disp || []).map((d) => getWeekdayNumber(d.weekday)).filter(Boolean);
setAvailableWeekdays(Array.from(new Set(nums)));
// compute counts preview for next 90 days
await computeAvailabilityCountsPreview(doctorId, disp || []);
} catch (e) {
console.error("Erro disponibilidades:", e);
setDisponibilidades([]);
setAvailableWeekdays([]);
setAvailabilityCounts({});
}
}, []);
const computeAvailabilityCountsPreview = async (doctorId: string, dispList: Disponibilidade[]) => {
try {
const today = new Date();
const start = format(today, "yyyy-MM-dd");
const endDate = addDays(today, 90);
const end = format(endDate, "yyyy-MM-dd");
// fetch appointments for this doctor for the whole window (one call)
const appointments = await appointmentsService.search_appointment(
`doctor_id=eq.${doctorId}&scheduled_at=gte.${start}T00:00:00Z&scheduled_at=lt.${end}T23:59:59Z`
);
// group appointments by date
const apptsByDate: Record<string, number> = {};
(appointments || []).forEach((a: any) => {
const d = String(a.scheduled_at).split("T")[0];
apptsByDate[d] = (apptsByDate[d] || 0) + 1;
});
const counts: Record<string, number> = {};
for (let i = 0; i <= 90; i++) {
const d = addDays(today, i);
const key = format(d, "yyyy-MM-dd");
const dayOfWeek = d.getDay() === 0 ? 7 : d.getDay(); // 1..7
// find all disponibilidades matching this weekday
const dailyDisp = dispList.filter((p) => getWeekdayNumber(p.weekday) === dayOfWeek);
if (dailyDisp.length === 0) {
counts[key] = 0;
continue;
}
// compute total possible slots for the day summing multiple intervals
let possible = 0;
dailyDisp.forEach((p) => {
const [sh, sm] = p.start_time.split(":").map(Number);
const [eh, em] = p.end_time.split(":").map(Number);
const startMin = sh * 60 + sm;
const endMin = eh * 60 + em;
const slot = p.slot_minutes || 30;
// inclusive handling: if start==end -> 1 slot? normally not, we do Math.floor((end - start)/slot) + 1 if end >= start
if (endMin >= startMin) {
possible += Math.floor((endMin - startMin) / slot) + 1;
}
});
const occupied = apptsByDate[key] || 0;
const free = Math.max(0, possible - occupied);
counts[key] = free;
}
setAvailabilityCounts(counts);
} catch (e) {
console.error("Erro ao calcular contagens de disponibilidade:", e);
setAvailabilityCounts({});
}
};
// --- When doctor changes ---
useEffect(() => {
fetchDoctors();
}, [fetchDoctors]);
useEffect(() => {
if (selectedDoctor) {
loadDoctorDisponibilidades(selectedDoctor);
} else {
setDisponibilidades([]);
setAvailableWeekdays([]);
setAvailabilityCounts({});
}
setSelectedDate("");
setSelectedTime("");
setAvailableTimes([]);
}, [selectedDoctor, loadDoctorDisponibilidades]);
// --- Fetch available times for date --- (same logic, but shows toast if none)
const fetchAvailableSlots = useCallback(
async (doctorId: string, date: string) => {
if (!doctorId || !date) return;
setLoadingSlots(true);
setAvailableTimes([]);
try {
const disponibilidades: Disponibilidade[] = await AvailabilityService.listById(doctorId);
const consultas = await appointmentsService.search_appointment(
`doctor_id=eq.${doctorId}&scheduled_at=gte.${date}T00:00:00Z&scheduled_at=lt.${date}T23:59:59Z`
);
const diaJS = new Date(date).getDay(); // 0..6
const diaAPI = diaJS === 0 ? 7 : diaJS;
const disponibilidadeDia = disponibilidades.find(
(d) => getWeekdayNumber(d.weekday) === diaAPI
);
if (!disponibilidadeDia) {
setAvailableTimes([]);
toast({ title: "Nenhuma disponibilidade", description: "Nenhuma disponibilidade cadastrada para este dia." });
setLoadingSlots(false);
return;
}
const [startHour, startMin] = disponibilidadeDia.start_time.split(":").map(Number);
const [endHour, endMin] = disponibilidadeDia.end_time.split(":").map(Number);
const slot = disponibilidadeDia.slot_minutes || 30;
const horariosGerados: string[] = [];
let atual = new Date(date);
atual.setHours(startHour, startMin, 0, 0);
const end = new Date(date);
end.setHours(endHour, endMin, 0, 0);
while (atual <= end) {
horariosGerados.push(atual.toTimeString().slice(0, 5));
atual = new Date(atual.getTime() + slot * 60000);
}
const ocupados = (consultas || []).map((c: any) =>
String(c.scheduled_at).split("T")[1]?.slice(0, 5)
);
const livres = horariosGerados.filter((h) => !ocupados.includes(h));
if (livres.length === 0) {
toast({ title: "Sem horários livres", description: "Todos os horários estão ocupados neste dia." });
}
setAvailableTimes(livres);
} catch (err) {
console.error(err);
setAvailableTimes([]);
toast({ title: "Erro", description: "Falha ao carregar horários." });
} finally {
setLoadingSlots(false);
}
},
[]
);
// run fetchAvailableSlots when date changes
useEffect(() => {
if (selectedDoctor && selectedDate) {
fetchAvailableSlots(selectedDoctor, selectedDate);
} else {
setAvailableTimes([]);
}
setSelectedTime("");
}, [selectedDoctor, selectedDate, fetchAvailableSlots]);
// --- Submit ---
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedDoctor || !selectedDate || !selectedTime) {
toast({ title: "Preencha os campos", description: "Selecione médico, data e horário." });
return;
}
try {
const doctor = doctors.find((d) => d.id === selectedDoctor);
const paciente = await usersService.getMe();
const body = {
doctor_id: doctor?.id,
patient_id: paciente.user.id,
scheduled_at: `${selectedDate}T${selectedTime}:00`, // saving as local-ish string (you chose UTC elsewhere)
duration_minutes: Number(duracao),
notes,
appointment_type: tipoConsulta,
};
await appointmentsService.create(body);
toast({ title: "Agendado", description: "Consulta agendada com sucesso." });
// reset
setSelectedDoctor("");
setSelectedDate("");
setSelectedTime("");
setAvailableTimes([]);
setNotes("");
// refresh counts
if (selectedDoctor) computeAvailabilityCountsPreview(selectedDoctor, disponibilidades);
} catch (err) {
console.error(err);
toast({ title: "Erro", description: "Falha ao agendar consulta." });
}
};
// --- Calendar tooltip via event delegation ---
useEffect(() => {
const cont = calendarRef.current;
if (!cont) return;
const onMove = (ev: MouseEvent) => {
const target = ev.target as HTMLElement | null;
if (!target) return;
// find closest button that likely is a day cell
const btn = target.closest("button");
if (!btn) {
setTooltip(null);
return;
}
// many calendar implementations put the date in aria-label, e.g. "November 13, 2025"
const aria = btn.getAttribute("aria-label") || btn.textContent || "";
// try to parse date from aria-label: new Date(aria) works for many locales
const parsed = new Date(aria);
if (isNaN(parsed.getTime())) {
// sometimes aria-label is like "13" (just day) - try data-day attribute
const dataDay = btn.getAttribute("data-day");
if (dataDay) {
// try parse yyyy-mm-dd
const pd = new Date(dataDay);
if (!isNaN(pd.getTime())) {
const key = format(pd, "yyyy-MM-dd");
const count = availabilityCounts[key] ?? 0;
setTooltip({
x: ev.pageX + 10,
y: ev.pageY + 10,
text: `${count} horário${count !== 1 ? "s" : ""} disponíveis`,
});
return;
}
}
setTooltip(null);
return;
}
// parsed is valid - convert to yyyy-MM-dd
const key = format(getBrazilDate(parsed), "yyyy-MM-dd");
const count = availabilityCounts[key] ?? 0;
setTooltip({
x: ev.pageX + 10,
y: ev.pageY + 10,
text: `${count} horário${count !== 1 ? "s" : ""} disponíveis`,
});
};
const onLeave = () => setTooltip(null);
cont.addEventListener("mousemove", onMove);
cont.addEventListener("mouseleave", onLeave);
return () => {
cont.removeEventListener("mousemove", onMove);
cont.removeEventListener("mouseleave", onLeave);
};
}, [availabilityCounts]);
export default function PatientAppointments() {
return (
<PatientLayout>
<div className="max-w-6xl mx-auto space-y-4 px-4">
<h1 className="text-2xl font-semibold">Agendar Consulta</h1>
<Card className="border rounded-xl shadow-sm">
<CardHeader>
<CardTitle>Dados da Consulta</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
{/* LEFT */}
<div className="space-y-3">
<div>
<Label className="text-sm">Médico</Label>
<Select value={selectedDoctor} onValueChange={setSelectedDoctor}>
<SelectTrigger>
<SelectValue placeholder="Selecione o médico">
{selectedDoctor && doctors.find(d => d.id === selectedDoctor)?.full_name}
</SelectValue>
</SelectTrigger>
<SelectContent>
{loadingDoctors ? (
<SelectItem value="loading" disabled>Carregando...</SelectItem>
) : (
doctors.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.full_name} {d.specialty}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm">Data</Label>
<div ref={calendarRef} className="rounded-lg border p-2">
<CalendarShadcn
mode="single"
disabled={!selectedDoctor}
selected={selectedDate ? new Date(selectedDate + "T12:00:00") : undefined}
onSelect={(date) => {
if (!date) return;
const fixedDate = new Date(date.getTime() + 12 * 60 * 60 * 1000);
const formatted = format(fixedDate, "yyyy-MM-dd");
setSelectedDate(formatted);
}}
className="rounded-md border shadow-sm p-2"
modifiers={{ selected: selectedDate ? new Date(selectedDate + 'T12:00:00') : undefined }}
modifiersClassNames={{
selected:
"bg-blue-600 text-white hover:bg-blue-700 rounded-md",
}}
/>
</div>
</div>
<div>
<Label className="text-sm">Observações</Label>
<Textarea
placeholder="Instruções para o médico..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="mt-2"
/>
</div>
</div>
{/* RIGHT */}
<div className="space-y-3">
<Card className="shadow-md rounded-xl bg-blue-50 border border-blue-200">
<CardHeader>
<CardTitle className="text-blue-700">Resumo</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-gray-900 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-blue-600" />
<div className="text-xs">
{selectedDoctor ? (
<div className="font-medium">
{doctors.find((d) => d.id === selectedDoctor)?.full_name}
</div>
) : (
<div className="text-gray-500">Médico</div>
)}
<div className="text-xs text-gray-500">
{selectedDoctor ? doctors.find(d => d.id === selectedDoctor)?.specialty : ""}
</div>
</div>
</div>
<div className="text-xs text-gray-600">{tipoConsulta} {duracao} min</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-blue-600" />
<div>
{selectedDate ? (
<div className="font-medium">{selectedDate.split("-").reverse().join("/")}</div>
) : (
<div className="text-gray-500">Data</div>
)}
</div>
</div>
<div className="text-xs text-gray-600"></div>
</div>
{/* Horário */}
<div className="space-y-2">
<Label>Horário</Label>
<Select onValueChange={setSelectedTime} disabled={
loadingSlots || availableTimes.length === 0
} >
<SelectTrigger> <SelectValue placeholder={
loadingSlots ? "Carregando horários..." : availableTimes.length === 0 ? "Nenhum horário disponível" : "Selecione o horário"
} />
</SelectTrigger>
<SelectContent> {
availableTimes.map((h) => ( <SelectItem key={h} value={h}> {h} </SelectItem> ))
} </SelectContent>
</Select>
</div>
{notes && (
<div className="flex items-start gap-2 text-sm">
<StickyNote className="h-4 w-4" />
<div className="italic text-gray-700">{notes}</div>
</div>
)}
</CardContent>
</Card>
{/* Card de Informações Importantes */}
<Card className="shadow-md rounded-xl border border-gray-200">
<CardHeader>
<CardTitle className="text-gray-800 text-base">Informações Importantes</CardTitle>
</CardHeader>
<CardContent className="text-sm text-gray-700 space-y-2">
<ul className="list-disc pl-5 space-y-1">
<li>Chegue com 15 minutos de antecedência</li>
<li>Traga documento com foto</li>
<li>Traga carteirinha do convênio</li>
</ul>
</CardContent>
</Card>
<div className="flex gap-2">
<Button
type="submit"
className="w-full md:w-auto px-4 py-1.5 text-sm bg-blue-600 text-white hover:bg-blue-700"
disabled={!selectedDoctor || !selectedDate || !selectedTime}
>
Agendar
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setSelectedDoctor("");
setSelectedDate("");
setSelectedTime("");
setAvailableTimes([]);
setNotes("");
}}
className="px-3"
>
Limpar
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
{/* Tooltip element */}
{tooltip && (
<div
ref={tooltipRef}
style={{
position: "absolute",
left: tooltip.x,
top: tooltip.y,
zIndex: 60,
background: "rgba(0,0,0,0.85)",
color: "white",
padding: "6px 8px",
borderRadius: 6,
fontSize: 12,
}}
>
{tooltip.text}
</div>
)}
</div>
<ScheduleForm />
</PatientLayout>
);
}

View File

@ -1,355 +1,12 @@
"use client";
import type React from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
// app/secretary/appointments/page.tsx
import SecretaryLayout from "@/components/secretary-layout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Calendar, Clock, User } from "lucide-react"; // Importações que você já tinha
import { patientsService } from "@/services/patientsApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs";
import { appointmentsService } from "@/services/appointmentsApi.mjs";
import { usersService } from "@/services/usersApi.mjs";
import { toast } from "sonner"; // Para notificações
import ScheduleForm from "@/components/schedule/schedule-form";
export default function ScheduleAppointment() {
const router = useRouter();
const [patients, setPatients] = useState<any[]>([]);
const [doctors, setDoctors] = useState<any[]>([]);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
// Estados de loading e error para feedback visual e depuração
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Estados do formulário
const [selectedPatient, setSelectedPatient] = useState("");
const [selectedDoctor, setSelectedDoctor] = useState("");
const [selectedDate, setSelectedDate] = useState("");
const [selectedTime, setSelectedTime] = useState("");
const [appointmentType, setAppointmentType] = useState("presencial");
const [durationMinutes, setDurationMinutes] = useState("30");
const [chiefComplaint, setChiefComplaint] = useState("");
const [patientNotes, setPatientNotes] = useState("");
const [internalNotes, setInternalNotes] = useState("");
const [insuranceProvider, setInsuranceProvider] = useState("");
const availableTimes = [
"08:00", "08:30", "09:00", "09:30", "10:00", "10:30", "11:00", "11:30",
"14:00", "14:30", "15:00", "15:30", "16:00", "16:30", "17:00", "17:30"
];
// --- NOVO/ATUALIZADO useEffect COM LOGS PARA DEPURAR ---
useEffect(() => {
const fetchInitialData = async () => {
setLoading(true);
setError(null); // Limpa qualquer erro anterior ao iniciar uma nova busca
const results = await Promise.allSettled([
patientsService.list(),
doctorsService.list(),
usersService.getMe()
]);
const [patientResult, doctorResult, userResult] = results;
let hasFetchError = false; // Flag para saber se houve algum erro geral
// Checar pacientes
if (patientResult.status === 'fulfilled') {
setPatients(patientResult.value || []);
console.log("Pacientes carregados com sucesso:", patientResult.value);
} else {
console.error("ERRO AO CARREGAR PACIENTES:", patientResult.reason);
hasFetchError = true;
toast.error("Erro ao carregar lista de pacientes."); // Notificação para o usuário
}
// Checar médicos
if (doctorResult.status === 'fulfilled') {
setDoctors(doctorResult.value || []);
console.log("Médicos carregados com sucesso:", doctorResult.value); // <-- CRÍTICO PARA DEPURAR
} else {
console.error("ERRO AO CARREGAR MÉDICOS:", doctorResult.reason);
hasFetchError = true;
setError("Falha ao carregar médicos."); // Define o erro para ser exibido no dropdown
toast.error("Erro ao carregar lista de médicos."); // Notificação para o usuário
}
// Checar usuário logado
if (userResult.status === 'fulfilled' && userResult.value?.user?.id) {
setCurrentUserId(userResult.value.user.id);
console.log("ID do usuário logado carregado:", userResult.value.user.id);
} else {
const reason = userResult.status === 'rejected' ? userResult.reason : "API não retornou um ID de usuário.";
console.error("ERRO AO CARREGAR USUÁRIO:", reason);
hasFetchError = true;
toast.error("Não foi possível identificar o usuário logado. Por favor, faça login novamente."); // Notificação
// Não definimos setError aqui, pois um erro no usuário não impede a renderização de médicos/pacientes
}
// Se houve qualquer erro na busca, defina uma mensagem geral de erro se não houver uma mais específica.
if (hasFetchError && !error) { // Se 'error' já foi definido por um problema específico, mantenha-o.
setError("Alguns dados não puderam ser carregados. Verifique o console.");
}
setLoading(false); // Finaliza o estado de carregamento
console.log("Estado de carregamento finalizado:", false);
};
fetchInitialData();
}, []); // O array de dependências vazio significa que ele roda apenas uma vez após a montagem inicial
// --- LOGS PARA VERIFICAR OS ESTADOS ANTES DA RENDERIZAÇÃO ---
console.log("Estado 'loading' no render:", loading);
console.log("Estado 'error' no render:", error);
console.log("Conteúdo de 'doctors' no render:", doctors);
console.log("Número de médicos em 'doctors':", doctors.length);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log("Botão de submit clicado!"); // Log para confirmar que o clique funciona
if (!currentUserId) {
toast.error("Sessão de usuário inválida. Por favor, faça login novamente.");
return;
}
if (!selectedPatient || !selectedDoctor || !selectedDate || !selectedTime) {
toast.error("Paciente, médico, data e horário são obrigatórios.");
return;
}
try {
const scheduledAt = new Date(`${selectedDate}T${selectedTime}:00Z`).toISOString();
const newAppointmentData = {
patient_id: selectedPatient,
doctor_id: selectedDoctor,
scheduled_at: scheduledAt,
duration_minutes: parseInt(durationMinutes, 10),
appointment_type: appointmentType,
status: "requested",
chief_complaint: chiefComplaint || null,
patient_notes: patientNotes || null,
notes: internalNotes || null,
insurance_provider: insuranceProvider || null,
created_by: currentUserId,
};
console.log("🚀 Enviando os seguintes dados para a API:", newAppointmentData);
// A chamada para a API de criação
await appointmentsService.create(newAppointmentData);
toast.success("Consulta agendada com sucesso!");
router.push("/secretary/appointments");
} catch (error) {
console.error("❌ Erro ao criar agendamento:", error);
toast.error("Ocorreu um erro ao agendar a consulta. Verifique o console.");
}
};
return (
<SecretaryLayout>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Agendar Consulta</h1>
<p className="text-gray-600">Preencha os detalhes para criar um novo agendamento</p>
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle>Dados da Consulta</CardTitle>
<CardDescription>Preencha as informações para agendar a consulta</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="patient">Paciente</Label>
<Select value={selectedPatient} onValueChange={setSelectedPatient}>
<SelectTrigger>
<SelectValue placeholder="Selecione um paciente" />
</SelectTrigger>
<SelectContent>
{loading ? (
<SelectItem value="loading-patients" disabled>Carregando pacientes...</SelectItem>
) : error && patients.length === 0 ? ( // Se erro e não há pacientes
<SelectItem value="error-patients" disabled>Erro ao carregar pacientes</SelectItem>
) : patients.length === 0 ? ( // Se não há erro mas a lista está vazia
<SelectItem value="no-patients" disabled>Nenhum paciente encontrado</SelectItem>
) : (
patients.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.full_name}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="doctor">Médico</Label>
<Select value={selectedDoctor} onValueChange={setSelectedDoctor}>
<SelectTrigger>
<SelectValue placeholder="Selecione um médico" />
</SelectTrigger>
<SelectContent>
{/* Lógica condicional para o estado de carregamento, erro ou lista vazia */}
{loading ? (
<SelectItem value="loading" disabled>Carregando médicos...</SelectItem>
) : error && doctors.length === 0 ? ( // Se há erro E a lista de médicos está vazia
<SelectItem value="error" disabled>Erro ao carregar médicos</SelectItem>
) : doctors.length === 0 ? ( // Se não há erro mas a lista está vazia
<SelectItem value="no-doctors" disabled>Nenhum médico encontrado</SelectItem>
) : (
doctors.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.full_name} - {d.specialty}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* O restante do formulário permanece o mesmo */}
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="date">Data</Label>
<Input
id="date"
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
min={new Date().toISOString().split("T")[0]} // Garante que a data mínima é hoje
/>
</div>
<div className="space-y-2">
<Label htmlFor="time">Horário</Label>
<Select value={selectedTime} onValueChange={setSelectedTime}>
<SelectTrigger>
<SelectValue placeholder="Selecione um horário" />
</SelectTrigger>
<SelectContent>
{availableTimes.map((time) => (
<SelectItem key={time} value={time}>
{time}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="appointmentType">Tipo de Consulta</Label>
<Select value={appointmentType} onValueChange={setAppointmentType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="presencial">Presencial</SelectItem>
<SelectItem value="telemedicina">Telemedicina</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="duration">Duração (minutos)</Label>
<Input
id="duration"
type="number"
value={durationMinutes}
onChange={(e) => setDurationMinutes(e.target.value)}
placeholder="Ex: 30"
min="1" // Duração mínima
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="insurance">Convênio (opcional)</Label>
<Input
id="insurance"
placeholder="Nome do convênio do paciente"
value={insuranceProvider}
onChange={(e) => setInsuranceProvider(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="chiefComplaint">Queixa Principal (opcional)</Label>
<Textarea
id="chiefComplaint"
placeholder="Descreva brevemente o motivo da consulta..."
value={chiefComplaint}
onChange={(e) => setChiefComplaint(e.target.value)}
rows={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="patientNotes">Observações do Paciente (opcional)</Label>
<Textarea
id="patientNotes"
placeholder="Anotações relevantes informadas pelo paciente..."
value={patientNotes}
onChange={(e) => setPatientNotes(e.target.value)}
rows={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="internalNotes">Observações Internas (opcional)</Label>
<Textarea
id="internalNotes"
placeholder="Anotações para a equipe da clínica..."
value={internalNotes}
onChange={(e) => setInternalNotes(e.target.value)}
rows={2}
/>
</div>
<Button
type="submit"
className="w-full"
// Remova temporariamente '|| !currentUserId || loading' para testar
disabled={!selectedPatient || !selectedDoctor || !selectedDate || !selectedTime /* || !currentUserId || loading */}
>
Agendar Consulta
</Button>
</form>
</CardContent>
</Card>
</div>
<div className="space-y-6">
{/* Card de Resumo e Informações Importantes (se houver, adicione aqui) */}
<Card>
<CardHeader>
<CardTitle>Informações Rápidas</CardTitle>
<CardDescription>Ajuda e status</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loading && (
<p className="text-sm text-blue-600 flex items-center"><Clock className="mr-2 h-4 w-4" /> Carregando dados iniciais...</p>
)}
{error && (
<p className="text-sm text-red-600 flex items-center">
<User className="mr-2 h-4 w-4" /> {error}
</p>
)}
{!currentUserId && !loading && (
<p className="text-sm text-red-600 flex items-center"><User className="mr-2 h-4 w-4" /> Usuário não identificado. Recarregue a página.</p>
)}
<p className="text-sm text-gray-500 flex items-center">
<Calendar className="mr-2 h-4 w-4" /> Selecione uma data e horário válidos.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
</SecretaryLayout>
);
}
export default function SecretaryAppointments() {
return (
<SecretaryLayout>
<ScheduleForm />
</SecretaryLayout>
);
}

View File

@ -0,0 +1,477 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { usersService } from "@/services/usersApi.mjs";
import { patientsService } from "@/services/patientsApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs";
import { appointmentsService } from "@/services/appointmentsApi.mjs";
import { AvailabilityService } from "@/services/availabilityApi.mjs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Calendar as CalendarShadcn } from "@/components/ui/calendar";
import { format, addDays } from "date-fns";
import { User, StickyNote } from "lucide-react";
import { toast } from "@/hooks/use-toast";
import {api} from "@/services/api.mjs"
export default function ScheduleForm() {
// Estado do usuário e role
const [role, setRole] = useState<string>("paciente");
const [userId, setUserId] = useState<string | null>(null);
// Listas e seleções
const [patients, setPatients] = useState<any[]>([]);
const [selectedPatient, setSelectedPatient] = useState("");
const [doctors, setDoctors] = useState<any[]>([]);
const [selectedDoctor, setSelectedDoctor] = useState("");
const [selectedDate, setSelectedDate] = useState("");
const [selectedTime, setSelectedTime] = useState("");
const [notes, setNotes] = useState("");
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
const [loadingDoctors, setLoadingDoctors] = useState(true);
const [loadingSlots, setLoadingSlots] = useState(false);
// Outras configs
const [tipoConsulta] = useState("presencial");
const [duracao] = useState("30");
const [disponibilidades, setDisponibilidades] = useState<any[]>([]);
const [availabilityCounts, setAvailabilityCounts] = useState<Record<string, number>>({});
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
const calendarRef = useRef<HTMLDivElement | null>(null);
const getWeekdayNumber = (weekday: string) =>
["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
.indexOf(weekday.toLowerCase()) + 1;
const getBrazilDate = (date: Date) =>
new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0));
// 🔹 Buscar dados do usuário e role
useEffect(() => {
(async () => {
try {
const me = await usersService.getMe();
const currentRole = me?.roles?.[0] || "paciente";
setRole(currentRole);
setUserId(me?.user?.id || null);
if (["secretaria", "gestor", "admin"].includes(currentRole)) {
const pats = await patientsService.list();
setPatients(pats || []);
}
} catch (err) {
console.error("Erro ao carregar usuário:", err);
}
})();
}, []);
// 🔹 Buscar médicos
const fetchDoctors = useCallback(async () => {
setLoadingDoctors(true);
try {
const data = await doctorsService.list();
setDoctors(data || []);
} catch (err) {
console.error("Erro ao buscar médicos:", err);
toast({ title: "Erro", description: "Não foi possível carregar médicos." });
} finally {
setLoadingDoctors(false);
}
}, []);
useEffect(() => {
fetchDoctors();
}, [fetchDoctors]);
// 🔹 Buscar disponibilidades
const loadDoctorDisponibilidades = useCallback(async (doctorId?: string) => {
if (!doctorId) return;
try {
const disp = await AvailabilityService.listById(doctorId);
setDisponibilidades(disp || []);
await computeAvailabilityCountsPreview(doctorId, disp || []);
} catch (err) {
console.error("Erro ao buscar disponibilidades:", err);
setDisponibilidades([]);
}
}, []);
const computeAvailabilityCountsPreview = async (doctorId: string, dispList: any[]) => {
try {
const today = new Date();
const start = format(today, "yyyy-MM-dd");
const endDate = addDays(today, 90);
const end = format(endDate, "yyyy-MM-dd");
const appointments = await appointmentsService.search_appointment(
`doctor_id=eq.${doctorId}&scheduled_at=gte.${start}T00:00:00Z&scheduled_at=lt.${end}T23:59:59Z`
);
const apptsByDate: Record<string, number> = {};
(appointments || []).forEach((a: any) => {
const d = String(a.scheduled_at).split("T")[0];
apptsByDate[d] = (apptsByDate[d] || 0) + 1;
});
const counts: Record<string, number> = {};
for (let i = 0; i <= 90; i++) {
const d = addDays(today, i);
const key = format(d, "yyyy-MM-dd");
const dayOfWeek = d.getDay() === 0 ? 7 : d.getDay();
const dailyDisp = dispList.filter((p) => getWeekdayNumber(p.weekday) === dayOfWeek);
if (dailyDisp.length === 0) {
counts[key] = 0;
continue;
}
let possible = 0;
dailyDisp.forEach((p) => {
const [sh, sm] = p.start_time.split(":").map(Number);
const [eh, em] = p.end_time.split(":").map(Number);
const startMin = sh * 60 + sm;
const endMin = eh * 60 + em;
const slot = p.slot_minutes || 30;
if (endMin >= startMin) possible += Math.floor((endMin - startMin) / slot) + 1;
});
const occupied = apptsByDate[key] || 0;
counts[key] = Math.max(0, possible - occupied);
}
setAvailabilityCounts(counts);
} catch (err) {
console.error("Erro ao calcular contagens:", err);
setAvailabilityCounts({});
}
};
// 🔹 Quando médico muda
useEffect(() => {
if (selectedDoctor) {
loadDoctorDisponibilidades(selectedDoctor);
} else {
setDisponibilidades([]);
setAvailabilityCounts({});
}
setSelectedDate("");
setSelectedTime("");
setAvailableTimes([]);
}, [selectedDoctor, loadDoctorDisponibilidades]);
// 🔹 Buscar horários disponíveis
const fetchAvailableSlots = useCallback(async (doctorId: string, date: string) => {
if (!doctorId || !date) return;
setLoadingSlots(true);
setAvailableTimes([]);
try {
const disponibilidades = await AvailabilityService.listById(doctorId);
const consultas = await appointmentsService.search_appointment(
`doctor_id=eq.${doctorId}&scheduled_at=gte.${date}T00:00:00Z&scheduled_at=lt.${date}T23:59:59Z`
);
const diaJS = new Date(date).getDay();
const diaAPI = diaJS === 0 ? 7 : diaJS;
const disponibilidadeDia = disponibilidades.find(
(d: any) => getWeekdayNumber(d.weekday) === diaAPI
);
if (!disponibilidadeDia) {
toast({ title: "Nenhuma disponibilidade", description: "Nenhum horário para este dia." });
return setAvailableTimes([]);
}
const [startHour, startMin] = disponibilidadeDia.start_time.split(":").map(Number);
const [endHour, endMin] = disponibilidadeDia.end_time.split(":").map(Number);
const slot = disponibilidadeDia.slot_minutes || 30;
const horariosGerados: string[] = [];
let atual = new Date(date);
atual.setHours(startHour, startMin, 0, 0);
const end = new Date(date);
end.setHours(endHour, endMin, 0, 0);
while (atual <= end) {
horariosGerados.push(atual.toTimeString().slice(0, 5));
atual = new Date(atual.getTime() + slot * 60000);
}
const ocupados = (consultas || []).map((c: any) =>
String(c.scheduled_at).split("T")[1]?.slice(0, 5)
);
const livres = horariosGerados.filter((h) => !ocupados.includes(h));
setAvailableTimes(livres);
} catch (err) {
console.error(err);
toast({ title: "Erro", description: "Falha ao carregar horários." });
} finally {
setLoadingSlots(false);
}
}, []);
useEffect(() => {
if (selectedDoctor && selectedDate) fetchAvailableSlots(selectedDoctor, selectedDate);
}, [selectedDoctor, selectedDate, fetchAvailableSlots]);
// 🔹 Submeter agendamento
// 🔹 Submeter agendamento
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const isSecretaryLike = ["secretaria", "admin", "gestor"].includes(role);
let patientId = selectedPatient;
try {
// 🔹 Se for paciente, buscamos o ID real na tabela `patients`
if (!isSecretaryLike) {
const me = await usersService.getMe();
const authId = me?.user?.id;
if (!authId) {
toast({ title: "Erro", description: "Usuário não autenticado." });
return;
}
// Busca o registro de paciente correspondente ao usuário autenticado
const patientsData = await api.get(`/rest/v1/patients?user_id=eq.${authId}`);
if (!patientsData || patientsData.length === 0) {
toast({ title: "Erro", description: "Registro de paciente não encontrado." });
return;
}
patientId = patientsData[0].id;
}
if (!patientId || !selectedDoctor || !selectedDate || !selectedTime) {
toast({ title: "Campos obrigatórios", description: "Preencha todos os campos." });
return;
}
const body = {
doctor_id: selectedDoctor,
patient_id: patientId,
scheduled_at: `${selectedDate}T${selectedTime}:00Z`,
duration_minutes: 30,
notes,
appointment_type: "presencial",
created_by: userId,
};
console.log("🩵 Enviando agendamento:", body);
try {
await appointmentsService.create(body);
toast({ title: "Sucesso", description: "Consulta agendada com sucesso!" });
} catch (err) {
console.warn("⚠️ Tentando método alternativo...");
await appointmentsService.create?.(body);
}
setSelectedDoctor("");
setSelectedDate("");
setSelectedTime("");
setNotes("");
setSelectedPatient("");
} catch (err) {
console.error("❌ Erro ao agendar:", err);
toast({ title: "Erro", description: "Falha ao agendar consulta." });
}
};
// 🔹 Tooltip no calendário
useEffect(() => {
const cont = calendarRef.current;
if (!cont) return;
const onMove = (ev: MouseEvent) => {
const target = ev.target as HTMLElement | null;
const btn = target?.closest("button");
if (!btn) return setTooltip(null);
const aria = btn.getAttribute("aria-label") || btn.textContent || "";
const parsed = new Date(aria);
if (isNaN(parsed.getTime())) return setTooltip(null);
const key = format(getBrazilDate(parsed), "yyyy-MM-dd");
const count = availabilityCounts[key] ?? 0;
setTooltip({
x: ev.pageX + 10,
y: ev.pageY + 10,
text: `${count} horário${count !== 1 ? "s" : ""} disponíveis`,
});
};
const onLeave = () => setTooltip(null);
cont.addEventListener("mousemove", onMove);
cont.addEventListener("mouseleave", onLeave);
return () => {
cont.removeEventListener("mousemove", onMove);
cont.removeEventListener("mouseleave", onLeave);
};
}, [availabilityCounts]);
return (
<div className="max-w-6xl mx-auto space-y-4 px-4">
<h1 className="text-2xl font-semibold">Agendar Consulta</h1>
<Card className="border rounded-xl shadow-sm">
<CardHeader>
<CardTitle>Dados da Consulta</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-3">
{/* Se secretária/gestor/admin → mostrar campo Paciente */}
{["secretaria", "gestor", "admin"].includes(role) && (
<div>
<Label>Paciente</Label>
<Select value={selectedPatient} onValueChange={setSelectedPatient}>
<SelectTrigger>
<SelectValue placeholder="Selecione o paciente" />
</SelectTrigger>
<SelectContent>
{patients.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.full_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label>Médico</Label>
<Select value={selectedDoctor} onValueChange={setSelectedDoctor}>
<SelectTrigger>
<SelectValue placeholder="Selecione o médico" />
</SelectTrigger>
<SelectContent>
{loadingDoctors ? (
<SelectItem value="loading" disabled>Carregando...</SelectItem>
) : (
doctors.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.full_name} {d.specialty}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div>
<Label>Data</Label>
<div ref={calendarRef} className="rounded-lg border p-2">
<CalendarShadcn
mode="single"
disabled={!selectedDoctor}
selected={selectedDate ? new Date(selectedDate + "T12:00:00") : undefined}
onSelect={(date) => {
if (!date) return;
const formatted = format(new Date(date.getTime() + 12 * 60 * 60 * 1000), "yyyy-MM-dd");
setSelectedDate(formatted);
}}
/>
</div>
</div>
<div>
<Label>Observações</Label>
<Textarea
placeholder="Instruções para o médico..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="mt-2"
/>
</div>
</div>
<div className="space-y-3">
<Card className="shadow-md rounded-xl bg-blue-50 border border-blue-200">
<CardHeader>
<CardTitle className="text-blue-700">Resumo</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-gray-900 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-blue-600" />
<div className="text-xs">
{selectedDoctor
? doctors.find((d) => d.id === selectedDoctor)?.full_name
: "Médico"}
</div>
</div>
<div className="text-xs text-gray-600">
{tipoConsulta} {duracao} min
</div>
</div>
<div className="space-y-2">
<Label>Horário</Label>
<Select onValueChange={setSelectedTime} disabled={loadingSlots || availableTimes.length === 0}>
<SelectTrigger>
<SelectValue
placeholder={
loadingSlots
? "Carregando horários..."
: availableTimes.length === 0
? "Nenhum horário disponível"
: "Selecione o horário"
}
/>
</SelectTrigger>
<SelectContent>
{availableTimes.map((h) => (
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{notes && (
<div className="flex items-start gap-2 text-sm">
<StickyNote className="h-4 w-4" />
<div className="italic text-gray-700">{notes}</div>
</div>
)}
</CardContent>
</Card>
<div className="flex gap-2">
<Button
type="submit"
className="w-full md:w-auto px-4 py-1.5 text-sm bg-blue-600 text-white hover:bg-blue-700"
disabled={!selectedDoctor || !selectedDate || !selectedTime}
>
Agendar
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setSelectedDoctor("");
setSelectedDate("");
setSelectedTime("");
setNotes("");
setSelectedPatient("");
}}
className="px-3"
>
Limpar
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
{tooltip && (
<div
style={{
position: "absolute",
left: tooltip.x,
top: tooltip.y,
zIndex: 60,
background: "rgba(0,0,0,0.85)",
color: "white",
padding: "6px 8px",
borderRadius: 6,
fontSize: 12,
}}
>
{tooltip.text}
</div>
)}
</div>
);
}