- Fix: Avatar upload usando Supabase Client com RLS policies - Fix: Profile update usando Supabase Client - Fix: Timezone handling em datas de consultas - Fix: Filtros de consultas passadas/futuras - Fix: Appointment cancellation com Supabase Client - Fix: Navegação após booking de consulta - Fix: Report service usando Supabase Client - Fix: Campo created_by em relatórios - Fix: URL pública de avatares no Storage - Fix: Modal de criação de usuário com scroll - Feat: Sistema completo de gestão de consultas - Feat: Painéis para paciente, médico, secretária e admin - Feat: Upload de avatares - Feat: Sistema de relatórios médicos - Feat: Gestão de disponibilidade de médicos
944 lines
37 KiB
TypeScript
944 lines
37 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import toast from "react-hot-toast";
|
|
import { Search, Plus, Eye, Edit, Trash2, X } from "lucide-react";
|
|
import {
|
|
appointmentService,
|
|
type Appointment,
|
|
patientService,
|
|
type Patient,
|
|
doctorService,
|
|
type Doctor,
|
|
} from "../../services";
|
|
import { Avatar } from "../ui/Avatar";
|
|
import { CalendarPicker } from "../agenda/CalendarPicker";
|
|
import AvailableSlotsPicker from "../agenda/AvailableSlotsPicker";
|
|
|
|
interface AppointmentWithDetails extends Appointment {
|
|
patient?: Patient;
|
|
doctor?: Doctor;
|
|
}
|
|
|
|
export function SecretaryAppointmentList() {
|
|
const [appointments, setAppointments] = useState<AppointmentWithDetails[]>(
|
|
[]
|
|
);
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState("Todos");
|
|
const [typeFilter, setTypeFilter] = useState("Todos");
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [itemsPerPage] = useState(10);
|
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
|
const [selectedAppointment, setSelectedAppointment] =
|
|
useState<AppointmentWithDetails | null>(null);
|
|
const [patients, setPatients] = useState<Patient[]>([]);
|
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
|
const [formData, setFormData] = useState<any>({
|
|
id: undefined,
|
|
patient_id: "",
|
|
doctor_id: "",
|
|
scheduled_at: "",
|
|
appointment_type: "presencial",
|
|
notes: "",
|
|
});
|
|
const [selectedDate, setSelectedDate] = useState<string>("");
|
|
const [selectedTime, setSelectedTime] = useState<string>("");
|
|
|
|
const loadAppointments = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await appointmentService.list();
|
|
|
|
// Buscar detalhes de pacientes e médicos
|
|
const appointmentsWithDetails = await Promise.all(
|
|
(Array.isArray(data) ? data : []).map(async (appointment) => {
|
|
try {
|
|
const [patient, doctor] = await Promise.all([
|
|
appointment.patient_id
|
|
? patientService.getById(appointment.patient_id)
|
|
: null,
|
|
appointment.doctor_id
|
|
? doctorService.getById(appointment.doctor_id)
|
|
: null,
|
|
]);
|
|
|
|
return {
|
|
...appointment,
|
|
patient: patient || undefined,
|
|
doctor: doctor || undefined,
|
|
};
|
|
} catch (error) {
|
|
console.error("Erro ao carregar detalhes:", error);
|
|
return appointment;
|
|
}
|
|
})
|
|
);
|
|
|
|
setAppointments(appointmentsWithDetails);
|
|
console.log("✅ Consultas carregadas:", appointmentsWithDetails);
|
|
} catch (error) {
|
|
console.error("❌ Erro ao carregar consultas:", error);
|
|
toast.error("Erro ao carregar consultas");
|
|
setAppointments([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadAppointments();
|
|
loadDoctorsAndPatients();
|
|
}, []);
|
|
|
|
// Se outro componente pediu para abrir o modal de criação com paciente pré-selecionado
|
|
useEffect(() => {
|
|
const openFromSession = () => {
|
|
const patientId = sessionStorage.getItem("selectedPatientForAppointment");
|
|
if (patientId) {
|
|
setFormData((prev: any) => ({ ...prev, patient_id: patientId }));
|
|
setModalMode("create");
|
|
setShowCreateModal(true);
|
|
sessionStorage.removeItem("selectedPatientForAppointment");
|
|
}
|
|
};
|
|
|
|
// Try on mount
|
|
openFromSession();
|
|
|
|
// Listen for explicit events
|
|
const handler = () => openFromSession();
|
|
window.addEventListener("open-create-appointment", handler);
|
|
return () => window.removeEventListener("open-create-appointment", handler);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Função de filtro
|
|
const filteredAppointments = appointments.filter((appointment) => {
|
|
// Filtro de busca por nome do paciente ou médico
|
|
const searchLower = searchTerm.toLowerCase();
|
|
const matchesSearch =
|
|
!searchTerm ||
|
|
appointment.patient?.full_name?.toLowerCase().includes(searchLower) ||
|
|
appointment.doctor?.full_name?.toLowerCase().includes(searchLower) ||
|
|
appointment.order_number?.toString().includes(searchTerm);
|
|
|
|
// Mapeia o valor selecionado no select para o valor real usado na API/data
|
|
const mapStatusFilterToValue = (label: string) => {
|
|
if (label === "Todos") return null;
|
|
// Se já é um valor de API, retorna ele mesmo
|
|
if (['requested', 'confirmed', 'checked_in', 'in_progress', 'completed', 'cancelled', 'no_show'].includes(label)) {
|
|
return label;
|
|
}
|
|
// Mantém mapeamento legado por compatibilidade
|
|
const map: Record<string, string> = {
|
|
Confirmada: "confirmed",
|
|
Agendada: "requested",
|
|
Cancelada: "cancelled",
|
|
Concluída: "completed",
|
|
Concluida: "completed",
|
|
};
|
|
return map[label] || label.toLowerCase();
|
|
};
|
|
|
|
const mapTypeFilterToValue = (label: string) => {
|
|
if (label === "Todos") return null;
|
|
const map: Record<string, string> = {
|
|
Presencial: "presencial",
|
|
Telemedicina: "telemedicina",
|
|
};
|
|
return map[label] || label.toLowerCase();
|
|
};
|
|
|
|
const statusValue = mapStatusFilterToValue(statusFilter);
|
|
const typeValue = mapTypeFilterToValue(typeFilter);
|
|
|
|
// Filtro de status
|
|
const matchesStatus =
|
|
statusValue === null || appointment.status === statusValue;
|
|
|
|
// Filtro de tipo
|
|
const matchesType =
|
|
typeValue === null || appointment.appointment_type === typeValue;
|
|
|
|
return matchesSearch && matchesStatus && matchesType;
|
|
});
|
|
|
|
// Cálculos de paginação
|
|
const totalPages = Math.ceil(filteredAppointments.length / itemsPerPage);
|
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
const endIndex = startIndex + itemsPerPage;
|
|
const paginatedAppointments = filteredAppointments.slice(startIndex, endIndex);
|
|
|
|
const loadDoctorsAndPatients = async () => {
|
|
try {
|
|
const [patientsData, doctorsData] = await Promise.all([
|
|
patientService.list(),
|
|
doctorService.list(),
|
|
]);
|
|
setPatients(Array.isArray(patientsData) ? patientsData : []);
|
|
setDoctors(Array.isArray(doctorsData) ? doctorsData : []);
|
|
} catch (error) {
|
|
console.error("Erro ao carregar pacientes e médicos:", error);
|
|
}
|
|
};
|
|
|
|
const handleOpenCreateModal = () => {
|
|
setFormData({
|
|
patient_id: "",
|
|
doctor_id: "",
|
|
scheduled_at: "",
|
|
appointment_type: "presencial",
|
|
notes: "",
|
|
});
|
|
setSelectedDate("");
|
|
setSelectedTime("");
|
|
setShowCreateModal(true);
|
|
};
|
|
|
|
const handleCreateAppointment = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!formData.patient_id || !formData.doctor_id || !formData.scheduled_at) {
|
|
toast.error("Preencha todos os campos obrigatórios");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (modalMode === "edit" && formData.id) {
|
|
// Update only allowed fields per API types
|
|
const updatePayload: any = {};
|
|
if (formData.scheduled_at)
|
|
updatePayload.scheduled_at = new Date(
|
|
formData.scheduled_at
|
|
).toISOString();
|
|
if (formData.notes) updatePayload.notes = formData.notes;
|
|
await appointmentService.update(formData.id, updatePayload);
|
|
toast.success("Consulta atualizada com sucesso!");
|
|
} else {
|
|
await appointmentService.create({
|
|
patient_id: formData.patient_id,
|
|
doctor_id: formData.doctor_id,
|
|
scheduled_at: new Date(formData.scheduled_at).toISOString(),
|
|
appointment_type: formData.appointment_type as
|
|
| "presencial"
|
|
| "telemedicina",
|
|
patient_notes: formData.notes,
|
|
});
|
|
toast.success("Consulta agendada com sucesso!");
|
|
}
|
|
setShowCreateModal(false);
|
|
loadAppointments();
|
|
} catch (error) {
|
|
console.error("Erro ao criar consulta:", error);
|
|
toast.error("Erro ao agendar consulta");
|
|
}
|
|
};
|
|
|
|
const handleSearch = () => {
|
|
loadAppointments();
|
|
};
|
|
|
|
const handleViewAppointment = (appointment: AppointmentWithDetails) => {
|
|
setSelectedAppointment(appointment);
|
|
};
|
|
|
|
const handleEditAppointment = (appointment: AppointmentWithDetails) => {
|
|
setModalMode("edit");
|
|
setFormData({
|
|
id: appointment.id,
|
|
patient_id: appointment.patient_id || "",
|
|
doctor_id: appointment.doctor_id || "",
|
|
scheduled_at: appointment.scheduled_at || "",
|
|
appointment_type: appointment.appointment_type || "presencial",
|
|
notes: appointment.notes || "",
|
|
});
|
|
setShowCreateModal(true);
|
|
};
|
|
|
|
const handleClear = () => {
|
|
setSearchTerm("");
|
|
setStatusFilter("Todos");
|
|
setTypeFilter("Todos");
|
|
setCurrentPage(1);
|
|
loadAppointments();
|
|
};
|
|
|
|
const handleStatusChange = async (appointmentId: string, newStatus: string) => {
|
|
try {
|
|
console.log(`[SecretaryAppointmentList] Atualizando status da consulta ${appointmentId} para ${newStatus}`);
|
|
|
|
await appointmentService.update(appointmentId, {
|
|
status: newStatus as any
|
|
});
|
|
|
|
toast.success(`Status atualizado para: ${
|
|
newStatus === 'requested' ? 'Solicitada' :
|
|
newStatus === 'confirmed' ? 'Confirmada' :
|
|
newStatus === 'checked_in' ? 'Check-in' :
|
|
newStatus === 'in_progress' ? 'Em Atendimento' :
|
|
newStatus === 'completed' ? 'Concluída' :
|
|
newStatus === 'cancelled' ? 'Cancelada' :
|
|
newStatus === 'no_show' ? 'Não Compareceu' : newStatus
|
|
}`);
|
|
|
|
loadAppointments();
|
|
} catch (error) {
|
|
console.error("Erro ao atualizar status:", error);
|
|
toast.error("Erro ao atualizar status da consulta");
|
|
}
|
|
};
|
|
|
|
const handleDeleteAppointment = async (appointmentId: string) => {
|
|
if (!confirm("Tem certeza que deseja cancelar esta consulta?")) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await appointmentService.update(appointmentId, {
|
|
status: "cancelled",
|
|
cancelled_at: new Date().toISOString(),
|
|
cancellation_reason: "Cancelado pela secretaria"
|
|
});
|
|
toast.success("Consulta cancelada com sucesso!");
|
|
loadAppointments();
|
|
} catch (error) {
|
|
console.error("Erro ao cancelar consulta:", error);
|
|
toast.error("Erro ao cancelar consulta");
|
|
}
|
|
};
|
|
|
|
// Reset página quando filtros mudarem
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [searchTerm, statusFilter, typeFilter]);
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
const statusMap: Record<string, { label: string; className: string }> = {
|
|
confirmada: {
|
|
label: "Confirmada",
|
|
className: "bg-green-100 text-green-700",
|
|
},
|
|
agendada: { label: "Agendada", className: "bg-blue-100 text-blue-700" },
|
|
cancelada: { label: "Cancelada", className: "bg-red-100 text-red-700" },
|
|
concluida: { label: "Concluída", className: "bg-gray-100 text-gray-700" },
|
|
};
|
|
const config = statusMap[status] || {
|
|
label: status,
|
|
className: "bg-gray-100 text-gray-700",
|
|
};
|
|
return (
|
|
<span
|
|
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${config.className}`}
|
|
>
|
|
{config.label}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
try {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString("pt-BR", {
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
});
|
|
} catch {
|
|
return "—";
|
|
}
|
|
};
|
|
|
|
const formatTime = (dateString: string) => {
|
|
try {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleTimeString("pt-BR", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
} catch {
|
|
return "—";
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Consultas</h1>
|
|
<p className="text-gray-600 dark:text-gray-400 mt-1">Gerencie as consultas agendadas</p>
|
|
</div>
|
|
<button
|
|
onClick={handleOpenCreateModal}
|
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Nova Consulta
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search and Filters */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
|
<div className="flex gap-3">
|
|
<div className="flex-1 relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 dark:text-gray-500" />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar consultas por paciente ou médico..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleSearch}
|
|
className="px-6 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
|
>
|
|
Buscar
|
|
</button>
|
|
<button
|
|
onClick={handleClear}
|
|
className="px-6 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
>
|
|
Limpar
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-6">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-gray-600 dark:text-gray-400">Status:</span>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
className="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
>
|
|
<option>Todos</option>
|
|
<option value="requested">Solicitada</option>
|
|
<option value="confirmed">Confirmada</option>
|
|
<option value="checked_in">Check-in</option>
|
|
<option value="in_progress">Em Atendimento</option>
|
|
<option value="completed">Concluída</option>
|
|
<option value="cancelled">Cancelada</option>
|
|
<option value="no_show">Não Compareceu</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-gray-600 dark:text-gray-400">Tipo:</span>
|
|
<select
|
|
value={typeFilter}
|
|
onChange={(e) => setTypeFilter(e.target.value)}
|
|
className="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
>
|
|
<option>Todos</option>
|
|
<option>Presencial</option>
|
|
<option>Telemedicina</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
|
<tr>
|
|
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
|
Paciente
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
|
Médico
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
|
Data/Hora
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
|
Tipo
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
|
Status
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
|
Ações
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{loading ? (
|
|
<tr>
|
|
<td
|
|
colSpan={6}
|
|
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
|
|
>
|
|
Carregando consultas...
|
|
</td>
|
|
</tr>
|
|
) : filteredAppointments.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={6}
|
|
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
|
|
>
|
|
{searchTerm ||
|
|
statusFilter !== "Todos" ||
|
|
typeFilter !== "Todos"
|
|
? "Nenhuma consulta encontrada com esses filtros"
|
|
: "Nenhuma consulta encontrada"}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
paginatedAppointments.map((appointment) => (
|
|
<tr
|
|
key={appointment.id}
|
|
className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
>
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<Avatar
|
|
src={appointment.patient}
|
|
name={appointment.patient?.full_name || ""}
|
|
size="md"
|
|
color="blue"
|
|
/>
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
{appointment.patient?.full_name ||
|
|
"Paciente não encontrado"}
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
{appointment.patient?.email || "—"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<Avatar
|
|
src={appointment.doctor}
|
|
name={appointment.doctor?.full_name || ""}
|
|
size="md"
|
|
color="green"
|
|
/>
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
{appointment.doctor?.full_name ||
|
|
"Médico não encontrado"}
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
{appointment.doctor?.specialty || "—"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-900">
|
|
{appointment.scheduled_at ? (
|
|
<>
|
|
<div className="font-medium">
|
|
{formatDate(appointment.scheduled_at)}
|
|
</div>
|
|
<div className="text-gray-500 text-xs">
|
|
{formatTime(appointment.scheduled_at)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-700">
|
|
<span
|
|
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${
|
|
appointment.appointment_type === "telemedicina"
|
|
? "bg-purple-100 text-purple-700"
|
|
: "bg-blue-100 text-blue-700"
|
|
}`}
|
|
>
|
|
{appointment.appointment_type === "telemedicina"
|
|
? "Telemedicina"
|
|
: "Presencial"}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<select
|
|
value={appointment.status || "requested"}
|
|
onChange={(e) => handleStatusChange(appointment.id, e.target.value)}
|
|
className="px-3 py-1.5 text-xs font-medium rounded-full border-0 focus:ring-2 focus:ring-green-500 cursor-pointer"
|
|
style={{
|
|
backgroundColor:
|
|
appointment.status === "requested" ? "#fef3c7" :
|
|
appointment.status === "confirmed" ? "#d1fae5" :
|
|
appointment.status === "checked_in" ? "#fed7aa" :
|
|
appointment.status === "in_progress" ? "#fed7aa" :
|
|
appointment.status === "completed" ? "#dbeafe" :
|
|
appointment.status === "cancelled" ? "#f3f4f6" :
|
|
appointment.status === "no_show" ? "#fee2e2" : "#f3f4f6",
|
|
color:
|
|
appointment.status === "requested" ? "#92400e" :
|
|
appointment.status === "confirmed" ? "#065f46" :
|
|
appointment.status === "checked_in" ? "#9a3412" :
|
|
appointment.status === "in_progress" ? "#9a3412" :
|
|
appointment.status === "completed" ? "#1e40af" :
|
|
appointment.status === "cancelled" ? "#4b5563" :
|
|
appointment.status === "no_show" ? "#991b1b" : "#4b5563"
|
|
}}
|
|
>
|
|
<option value="requested">Solicitada</option>
|
|
<option value="confirmed">Confirmada</option>
|
|
<option value="checked_in">Check-in</option>
|
|
<option value="in_progress">Em Atendimento</option>
|
|
<option value="completed">Concluída</option>
|
|
<option value="cancelled">Cancelada</option>
|
|
<option value="no_show">Não Compareceu</option>
|
|
</select>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => handleViewAppointment(appointment)}
|
|
title="Visualizar"
|
|
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900 rounded-lg transition-colors"
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleEditAppointment(appointment)}
|
|
title="Editar"
|
|
className="p-2 text-orange-600 hover:bg-orange-50 dark:hover:bg-orange-900 rounded-lg transition-colors"
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteAppointment(appointment.id)}
|
|
title="Cancelar"
|
|
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900 rounded-lg transition-colors"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Paginação */}
|
|
{filteredAppointments.length > 0 && (
|
|
<div className="flex items-center justify-between bg-white dark:bg-gray-800 px-6 py-4 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
|
Mostrando {startIndex + 1} até {Math.min(endIndex, filteredAppointments.length)} de {filteredAppointments.length} consultas
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
|
|
disabled={currentPage === 1}
|
|
className="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300"
|
|
>
|
|
Anterior
|
|
</button>
|
|
<div className="flex items-center gap-1">
|
|
{(() => {
|
|
const maxPagesToShow = 4;
|
|
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
|
|
let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
|
|
|
|
// Ajusta startPage se estivermos próximos do fim
|
|
if (endPage - startPage < maxPagesToShow - 1) {
|
|
startPage = Math.max(1, endPage - maxPagesToShow + 1);
|
|
}
|
|
|
|
const pages = [];
|
|
for (let i = startPage; i <= endPage; i++) {
|
|
pages.push(i);
|
|
}
|
|
|
|
return pages.map((page) => (
|
|
<button
|
|
key={page}
|
|
onClick={() => setCurrentPage(page)}
|
|
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
|
|
currentPage === page
|
|
? "bg-green-600 text-white"
|
|
: "border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
|
}`}
|
|
>
|
|
{page}
|
|
</button>
|
|
));
|
|
})()}
|
|
</div>
|
|
<button
|
|
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
|
|
disabled={currentPage === totalPages}
|
|
className="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300"
|
|
>
|
|
Próxima
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal de Criar Consulta */}
|
|
{showCreateModal && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{modalMode === "edit" ? "Editar Consulta" : "Nova Consulta"}
|
|
</h2>
|
|
</div>
|
|
|
|
<form onSubmit={handleCreateAppointment} className="p-6 space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Paciente *
|
|
</label>
|
|
<select
|
|
value={formData.patient_id}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, patient_id: e.target.value })
|
|
}
|
|
className="form-input"
|
|
required
|
|
>
|
|
<option value="">Selecione um paciente</option>
|
|
{patients.map((patient) => (
|
|
<option key={patient.id} value={patient.id}>
|
|
{patient.full_name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Médico *
|
|
</label>
|
|
<select
|
|
value={formData.doctor_id}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, doctor_id: e.target.value })
|
|
}
|
|
className="form-input"
|
|
required
|
|
>
|
|
<option value="">Selecione um médico</option>
|
|
{doctors.map((doctor) => (
|
|
<option key={doctor.id} value={doctor.id}>
|
|
{doctor.full_name} - {doctor.specialty}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Tipo de Consulta *
|
|
</label>
|
|
<select
|
|
value={formData.appointment_type}
|
|
onChange={(e) =>
|
|
setFormData({
|
|
...formData,
|
|
appointment_type: e.target.value,
|
|
})
|
|
}
|
|
className="form-input"
|
|
required
|
|
>
|
|
<option value="presencial">Presencial</option>
|
|
<option value="telemedicina">Telemedicina</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Calendário Visual */}
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Data da Consulta *
|
|
</label>
|
|
{formData.doctor_id ? (
|
|
<CalendarPicker
|
|
doctorId={formData.doctor_id}
|
|
selectedDate={selectedDate}
|
|
onSelectDate={(date) => {
|
|
setSelectedDate(date);
|
|
setSelectedTime(""); // Resetar horário ao mudar data
|
|
setFormData({ ...formData, scheduled_at: "" });
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center text-gray-500">
|
|
Selecione um médico primeiro para ver a disponibilidade
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Seletor de Horários */}
|
|
{selectedDate && formData.doctor_id && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Horário *{" "}
|
|
{selectedTime && (
|
|
<span className="text-blue-600 font-semibold">
|
|
({selectedTime})
|
|
</span>
|
|
)}
|
|
</label>
|
|
<AvailableSlotsPicker
|
|
doctorId={formData.doctor_id}
|
|
date={selectedDate}
|
|
onSelect={(time) => {
|
|
setSelectedTime(time);
|
|
// Combinar data + horário no formato ISO
|
|
const datetime = `${selectedDate}T${time}:00`;
|
|
setFormData({ ...formData, scheduled_at: datetime });
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Observações
|
|
</label>
|
|
<textarea
|
|
value={formData.notes || ""}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, notes: e.target.value })
|
|
}
|
|
className="form-input"
|
|
rows={3}
|
|
placeholder="Observações da consulta"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowCreateModal(false)}
|
|
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
|
>
|
|
{modalMode === "edit"
|
|
? "Salvar Alterações"
|
|
: "Agendar Consulta"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal de Visualizar Consulta */}
|
|
{selectedAppointment && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
Visualizar Consulta
|
|
</h2>
|
|
<button
|
|
onClick={() => setSelectedAppointment(null)}
|
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
|
>
|
|
<X className="h-5 w-5 text-gray-500" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-500 mb-1">
|
|
Paciente
|
|
</label>
|
|
<p className="text-gray-900 font-medium">
|
|
{selectedAppointment.patient?.full_name || "—"}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-500 mb-1">
|
|
Médico
|
|
</label>
|
|
<p className="text-gray-900">
|
|
{selectedAppointment.doctor?.full_name || "—"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-500 mb-1">
|
|
Data
|
|
</label>
|
|
<p className="text-gray-900">
|
|
{selectedAppointment.scheduled_at
|
|
? formatDate(selectedAppointment.scheduled_at)
|
|
: "—"}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-500 mb-1">
|
|
Hora
|
|
</label>
|
|
<p className="text-gray-900">
|
|
{selectedAppointment.scheduled_at
|
|
? formatTime(selectedAppointment.scheduled_at)
|
|
: "—"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-500 mb-1">
|
|
Tipo
|
|
</label>
|
|
<p className="text-gray-900">
|
|
{selectedAppointment.appointment_type === "telemedicina"
|
|
? "Telemedicina"
|
|
: "Presencial"}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-500 mb-1">
|
|
Status
|
|
</label>
|
|
<div>
|
|
{getStatusBadge(selectedAppointment.status || "agendada")}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-500 mb-1">
|
|
Observações
|
|
</label>
|
|
<p className="text-gray-900 whitespace-pre-wrap">
|
|
{selectedAppointment.notes || "—"}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-4">
|
|
<button
|
|
onClick={() => setSelectedAppointment(null)}
|
|
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
Fechar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|