Descrição curta do que foi alterado

This commit is contained in:
Seu Nome 2025-11-02 22:41:13 -03:00
parent 591d8681ac
commit 6c0c7d75b8
7 changed files with 376 additions and 26 deletions

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import toast from "react-hot-toast";
import { Search, Plus, Eye, Edit, Trash2 } from "lucide-react";
import { Search, Plus, Eye, Edit, Trash2, X } from "lucide-react";
import {
appointmentService,
type Appointment,
@ -25,9 +25,14 @@ export function SecretaryAppointmentList() {
const [statusFilter, setStatusFilter] = useState("Todos");
const [typeFilter, setTypeFilter] = useState("Todos");
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({
const [formData, setFormData] = useState<any>({
id: undefined,
patient_id: "",
doctor_id: "",
scheduled_at: "",
@ -81,6 +86,28 @@ export function SecretaryAppointmentList() {
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
@ -91,13 +118,36 @@ export function SecretaryAppointmentList() {
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;
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 =
statusFilter === "Todos" || appointment.status === statusFilter;
const matchesStatus = statusValue === null || appointment.status === statusValue;
// Filtro de tipo
const matchesType =
typeFilter === "Todos" || appointment.appointment_type === typeFilter;
const matchesType = typeValue === null || appointment.appointment_type === typeValue;
return matchesSearch && matchesStatus && matchesType;
});
@ -135,16 +185,25 @@ export function SecretaryAppointmentList() {
}
try {
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",
});
toast.success("Consulta agendada com sucesso!");
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) {
@ -157,6 +216,23 @@ export function SecretaryAppointmentList() {
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");
@ -411,12 +487,14 @@ export function SecretaryAppointmentList() {
<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 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 rounded-lg transition-colors"
>
@ -443,7 +521,7 @@ export function SecretaryAppointmentList() {
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200">
<h2 className="text-2xl font-bold text-gray-900">
Nova Consulta
{modalMode === "edit" ? "Editar Consulta" : "Nova Consulta"}
</h2>
</div>
@ -527,6 +605,22 @@ export function SecretaryAppointmentList() {
</div>
</div>
<div className="space-y-4">
<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="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 h-24"
placeholder="Observações da consulta"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
@ -539,13 +633,78 @@ export function SecretaryAppointmentList() {
type="submit"
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Agendar Consulta
{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 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">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>
);
}

View File

@ -61,7 +61,11 @@ const formatDoctorName = (fullName: string): string => {
return `Dr. ${name}`;
};
export function SecretaryDoctorList() {
export function SecretaryDoctorList({
onOpenSchedule,
}: {
onOpenSchedule?: (doctorId: string) => void;
}) {
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
@ -79,6 +83,8 @@ export function SecretaryDoctorList() {
crm_uf: "",
specialty: "",
});
const [showViewModal, setShowViewModal] = useState(false);
const [selectedDoctor, setSelectedDoctor] = useState<Doctor | null>(null);
const loadDoctors = async () => {
setLoading(true);
@ -378,12 +384,26 @@ export function SecretaryDoctorList() {
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<button
onClick={() => {
setSelectedDoctor(doctor);
setShowViewModal(true);
}}
title="Visualizar"
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Eye className="h-4 w-4" />
</button>
<button
onClick={() => {
// Prefer callback from parent to switch tab; fallback to sessionStorage
if (onOpenSchedule) {
onOpenSchedule(doctor.id);
} else {
sessionStorage.setItem("selectedDoctorForSchedule", doctor.id);
// dispatch a custom event to inform parent (optional)
window.dispatchEvent(new CustomEvent("open-doctor-schedule"));
}
}}
title="Gerenciar agenda"
className="p-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
>
@ -597,6 +617,51 @@ export function SecretaryDoctorList() {
</div>
</div>
)}
{/* Modal de Visualizar Médico */}
{showViewModal && selectedDoctor && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">Visualizar Médico</h2>
<button
onClick={() => setShowViewModal(false)}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-4">
<div>
<p className="text-sm text-gray-500">Nome</p>
<p className="text-gray-900 font-medium">{selectedDoctor.full_name}</p>
</div>
<div>
<p className="text-sm text-gray-500">Especialidade</p>
<p className="text-gray-900">{selectedDoctor.specialty || '—'}</p>
</div>
<div>
<p className="text-sm text-gray-500">CRM</p>
<p className="text-gray-900">{selectedDoctor.crm || '—'}</p>
</div>
<div>
<p className="text-sm text-gray-500">Email</p>
<p className="text-gray-900">{selectedDoctor.email || '—'}</p>
</div>
</div>
</div>
<div className="p-6 border-t border-gray-200 flex justify-end gap-3">
<button
onClick={() => setShowViewModal(false)}
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>
);
}

View File

@ -92,6 +92,15 @@ export function SecretaryDoctorSchedule() {
loadDoctors();
}, []);
// If a doctor id was requested by other components (via sessionStorage), select it
useEffect(() => {
const requested = sessionStorage.getItem("selectedDoctorForSchedule");
if (requested) {
setSelectedDoctorId(requested);
sessionStorage.removeItem("selectedDoctorForSchedule");
}
}, [doctors]);
useEffect(() => {
console.log("[SecretaryDoctorSchedule] Estado availabilities atualizado:", {
count: availabilities.length,

View File

@ -40,7 +40,11 @@ const buscarEnderecoViaCEP = async (cep: string) => {
}
};
export function SecretaryPatientList() {
export function SecretaryPatientList({
onOpenAppointment,
}: {
onOpenAppointment?: (patientId: string) => void;
}) {
const { user } = useAuth();
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(false);
@ -54,6 +58,8 @@ export function SecretaryPatientList() {
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [patientToDelete, setPatientToDelete] = useState<Patient | null>(null);
const [showViewModal, setShowViewModal] = useState(false);
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
const [formData, setFormData] = useState<PacienteFormData>({
nome: "",
social_name: "",
@ -136,7 +142,15 @@ export function SecretaryPatientList() {
return currentMonth === birthMonth;
})();
return matchesSearch && matchesBirthday;
// Filtro de convênio
const matchesInsurance =
insuranceFilter === "Todos" ||
((patient as any).convenio || "Particular") === insuranceFilter;
// Filtro VIP (se o backend fornecer uma flag 'is_vip' ou 'vip')
const matchesVIP = !showVIP || ((patient as any).is_vip === true || (patient as any).vip === true);
return matchesSearch && matchesBirthday && matchesInsurance && matchesVIP;
});
const handleSearch = () => {
@ -333,6 +347,21 @@ export function SecretaryPatientList() {
setShowDeleteDialog(true);
};
const handleViewPatient = (patient: Patient) => {
setSelectedPatient(patient);
setShowViewModal(true);
};
const handleSchedulePatient = (patient: Patient) => {
if (onOpenAppointment) {
onOpenAppointment(patient.id as string);
} else {
// fallback: store in sessionStorage and dispatch event
sessionStorage.setItem("selectedPatientForAppointment", patient.id as string);
window.dispatchEvent(new CustomEvent("open-create-appointment"));
}
};
const handleConfirmDelete = async () => {
if (!patientToDelete?.id) return;
@ -518,17 +547,19 @@ export function SecretaryPatientList() {
{/* TODO: Buscar próximo agendamento */}
</td>
<td className="px-6 py-4 text-sm text-gray-700">
Particular
{(patient as any).convenio || "Particular"}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<button
onClick={() => handleViewPatient(patient)}
title="Visualizar"
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Eye className="h-4 w-4" />
</button>
<button
onClick={() => handleSchedulePatient(patient)}
title="Agendar consulta"
className="p-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
>
@ -596,6 +627,50 @@ export function SecretaryPatientList() {
</div>
)}
{/* Modal de Visualizar Paciente */}
{showViewModal && selectedPatient && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">Visualizar Paciente</h2>
<button
onClick={() => setShowViewModal(false)}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<p className="text-sm text-gray-500">Nome</p>
<p className="text-gray-900 font-medium">{selectedPatient.full_name}</p>
</div>
<div>
<p className="text-sm text-gray-500">Email</p>
<p className="text-gray-900">{selectedPatient.email || '—'}</p>
</div>
<div>
<p className="text-sm text-gray-500">Telefone</p>
<p className="text-gray-900">{selectedPatient.phone_mobile || '—'}</p>
</div>
<div>
<p className="text-sm text-gray-500">Convênio</p>
<p className="text-gray-900">{(selectedPatient as any).convenio || 'Particular'}</p>
</div>
<div className="flex justify-end">
<button
onClick={() => setShowViewModal(false)}
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>
)}
{/* Delete Confirmation Dialog */}
{showDeleteDialog && patientToDelete && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">

View File

@ -35,6 +35,14 @@ export function SecretaryReportList() {
loadPatients();
}, []);
// Recarrega automaticamente quando o filtro de status muda
// (evita depender do clique em Buscar)
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
loadReports();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter]);
const loadPatients = async () => {
try {
const data = await patientService.list();
@ -267,9 +275,24 @@ export function SecretaryReportList() {
const loadReports = async () => {
setLoading(true);
try {
const data = await reportService.list();
// Se um filtro de status estiver aplicado, encaminhar para o serviço
// Cast explícito para o tipo esperado pelo serviço (ReportStatus)
const filters = statusFilter ? { status: statusFilter as any } : undefined;
console.log("[SecretaryReportList] loadReports filters:", filters);
const data = await reportService.list(filters);
console.log("✅ Relatórios carregados:", data);
setReports(Array.isArray(data) ? data : []);
// Garantir filtro por status no cliente como fallback caso o backend
// não aplique corretamente o filtro. Normaliza para evitar problemas
// de case/whitespace.
let reportsList = Array.isArray(data) ? data : [];
if (statusFilter) {
const normalized = statusFilter.toString().toLowerCase().trim();
reportsList = reportsList.filter((r) => {
const rs = (r.status || "").toString().toLowerCase().trim();
return rs === normalized;
});
}
setReports(reportsList);
if (Array.isArray(data) && data.length === 0) {
console.warn("⚠️ Nenhum relatório encontrado na API");
}

View File

@ -90,8 +90,24 @@ export default function PainelSecretaria() {
{/* Main Content */}
<main className="max-w-[1400px] mx-auto px-6 py-8">
{activeTab === "pacientes" && <SecretaryPatientList />}
{activeTab === "medicos" && <SecretaryDoctorList />}
{activeTab === "pacientes" && (
<SecretaryPatientList
onOpenAppointment={(patientId: string) => {
// store selected patient for appointment and switch to consultas tab
sessionStorage.setItem("selectedPatientForAppointment", patientId);
setActiveTab("consultas");
}}
/>
)}
{activeTab === "medicos" && (
<SecretaryDoctorList
onOpenSchedule={(doctorId: string) => {
// store selected doctor for schedule and switch to agenda tab
sessionStorage.setItem("selectedDoctorForSchedule", doctorId);
setActiveTab("agenda");
}}
/>
)}
{activeTab === "consultas" && <SecretaryAppointmentList />}
{activeTab === "agenda" && <SecretaryDoctorSchedule />}
{activeTab === "relatorios" && <SecretaryReportList />}

View File

@ -27,6 +27,9 @@ class ReportService {
params["status"] = `eq.${filters.status}`;
}
// Log para depuração: mostra quais params serão enviados
console.log("[ReportService] list() params:", params);
const response = await apiClient.get<Report[]>(this.basePath, { params });
return response.data;
}