348 lines
14 KiB
TypeScript
348 lines
14 KiB
TypeScript
|
|
|
|
"use client";
|
|
|
|
import React, { useEffect, useState, useRef } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { FileDown, BarChart2, Users, CalendarCheck } from "lucide-react";
|
|
import jsPDF from "jspdf";
|
|
import html2canvas from "html2canvas";
|
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
|
import {
|
|
countAppointmentsToday,
|
|
getAppointmentsByDateRange,
|
|
listarAgendamentos,
|
|
buscarMedicosPorIds,
|
|
buscarPacientesPorIds,
|
|
} from "@/lib/api";
|
|
|
|
// ============================================================================
|
|
// Constants
|
|
// ============================================================================
|
|
|
|
const FALLBACK_MEDICOS = [
|
|
{ nome: "Dr. Carlos Andrade", consultas: 62 },
|
|
{ nome: "Dra. Paula Silva", consultas: 58 },
|
|
{ nome: "Dr. João Pedro", consultas: 54 },
|
|
{ nome: "Dra. Marina Costa", consultas: 51 },
|
|
];
|
|
|
|
// ============================================================================
|
|
// Helper Functions
|
|
// ============================================================================
|
|
|
|
async function exportPDF(title: string, content: string, chartElementId?: string) {
|
|
const doc = new jsPDF();
|
|
let yPosition = 15;
|
|
|
|
// Add title
|
|
doc.setFontSize(16);
|
|
doc.setFont(undefined, "bold");
|
|
doc.text(title, 15, yPosition);
|
|
yPosition += 10;
|
|
|
|
// Add description/content
|
|
doc.setFontSize(11);
|
|
doc.setFont(undefined, "normal");
|
|
const contentLines = doc.splitTextToSize(content, 180);
|
|
doc.text(contentLines, 15, yPosition);
|
|
yPosition += contentLines.length * 5 + 15;
|
|
|
|
// Capture chart if chartElementId is provided
|
|
if (chartElementId) {
|
|
try {
|
|
const chartElement = document.getElementById(chartElementId);
|
|
if (chartElement) {
|
|
// Create a canvas from the chart element
|
|
const canvas = await html2canvas(chartElement, {
|
|
backgroundColor: "#ffffff",
|
|
scale: 2,
|
|
logging: false,
|
|
});
|
|
|
|
// Convert canvas to image
|
|
const imgData = canvas.toDataURL("image/png");
|
|
const imgWidth = 180;
|
|
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
|
|
|
// Add image to PDF
|
|
doc.addImage(imgData, "PNG", 15, yPosition, imgWidth, imgHeight);
|
|
yPosition += imgHeight + 10;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error capturing chart:", error);
|
|
doc.text("(Erro ao capturar gráfico)", 15, yPosition);
|
|
yPosition += 10;
|
|
}
|
|
}
|
|
|
|
doc.save(`${title.toLowerCase().replace(/ /g, "-")}.pdf`);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Main Component
|
|
// ============================================================================
|
|
|
|
export default function RelatoriosPage() {
|
|
// State
|
|
const [metricsState, setMetricsState] = useState<Array<{ label: string; value: any; icon: any }>>([]);
|
|
const [consultasData, setConsultasData] = useState<Array<{ periodo: string; consultas: number }>>([]);
|
|
const [pacientesTop, setPacientesTop] = useState<Array<{ nome: string; consultas: number }>>([]);
|
|
const [medicosTop, setMedicosTop] = useState(FALLBACK_MEDICOS);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Data Loading
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
|
|
async function load() {
|
|
setLoading(true);
|
|
try {
|
|
// Fetch appointments
|
|
let appointments: any[] = [];
|
|
try {
|
|
appointments = await listarAgendamentos(
|
|
"select=patient_id,doctor_id,scheduled_at,status&order=scheduled_at.desc&limit=1000"
|
|
);
|
|
} catch (e) {
|
|
console.warn("[relatorios] listarAgendamentos failed, using fallback", e);
|
|
appointments = await getAppointmentsByDateRange(30).catch(() => []);
|
|
}
|
|
|
|
// Fetch today's appointments count
|
|
let appointmentsToday = 0;
|
|
try {
|
|
appointmentsToday = await countAppointmentsToday().catch(() => 0);
|
|
} catch (e) {
|
|
appointmentsToday = 0;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
// ===== Build Consultas Chart (last 30 days) =====
|
|
const daysCount = 30;
|
|
const now = new Date();
|
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
const startTs = start.getTime() - (daysCount - 1) * 86400000;
|
|
const dayBuckets: Record<string, { periodo: string; consultas: number }> = {};
|
|
|
|
for (let i = 0; i < daysCount; i++) {
|
|
const d = new Date(startTs + i * 86400000);
|
|
const iso = d.toISOString().split("T")[0];
|
|
const periodo = `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
dayBuckets[iso] = { periodo, consultas: 0 };
|
|
}
|
|
|
|
const appts = Array.isArray(appointments) ? appointments : [];
|
|
for (const a of appts) {
|
|
try {
|
|
const iso = (a.scheduled_at || "").toString().split("T")[0];
|
|
if (iso && dayBuckets[iso]) dayBuckets[iso].consultas += 1;
|
|
} catch (e) {
|
|
// ignore malformed
|
|
}
|
|
}
|
|
setConsultasData(Object.values(dayBuckets));
|
|
|
|
// ===== Aggregate Counts =====
|
|
const patientCounts: Record<string, number> = {};
|
|
const doctorCounts: Record<string, number> = {};
|
|
const doctorNoShowCounts: Record<string, number> = {};
|
|
|
|
for (const a of appts) {
|
|
if (a.patient_id) {
|
|
patientCounts[String(a.patient_id)] = (patientCounts[String(a.patient_id)] || 0) + 1;
|
|
}
|
|
if (a.doctor_id) {
|
|
const did = String(a.doctor_id);
|
|
doctorCounts[did] = (doctorCounts[did] || 0) + 1;
|
|
if (String(a.status || "").toLowerCase() === "no_show" || String(a.status || "").toLowerCase() === "no-show") {
|
|
doctorNoShowCounts[did] = (doctorNoShowCounts[did] || 0) + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Top 5 Patients & Doctors =====
|
|
const topPatientIds = Object.entries(patientCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 5)
|
|
.map((x) => x[0]);
|
|
const topDoctorIds = Object.entries(doctorCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 5)
|
|
.map((x) => x[0]);
|
|
|
|
const [patientsFetched, doctorsFetched] = await Promise.all([
|
|
topPatientIds.length ? buscarPacientesPorIds(topPatientIds) : Promise.resolve([]),
|
|
topDoctorIds.length ? buscarMedicosPorIds(topDoctorIds) : Promise.resolve([]),
|
|
]);
|
|
|
|
// ===== Build Patient List =====
|
|
const pacientesList = topPatientIds.map((id) => {
|
|
const p = (patientsFetched || []).find((x: any) => String(x.id) === String(id));
|
|
return { nome: p ? p.full_name : id, consultas: patientCounts[id] || 0 };
|
|
});
|
|
|
|
// ===== Build Doctor List =====
|
|
const medicosList = topDoctorIds.map((id) => {
|
|
const m = (doctorsFetched || []).find((x: any) => String(x.id) === String(id));
|
|
return { nome: m ? m.full_name : id, consultas: doctorCounts[id] || 0 };
|
|
});
|
|
|
|
// ===== Update State =====
|
|
setPacientesTop(pacientesList);
|
|
setMedicosTop(medicosList.length ? medicosList : FALLBACK_MEDICOS);
|
|
setMetricsState([
|
|
{ label: "Atendimentos", value: appointmentsToday ?? 0, icon: <CalendarCheck className="w-6 h-6 text-blue-500" /> },
|
|
] as any);
|
|
} catch (err: any) {
|
|
console.error("[relatorios] error loading data:", err);
|
|
if (mounted) setError(err?.message ?? String(err));
|
|
} finally {
|
|
if (mounted) setLoading(false);
|
|
}
|
|
}
|
|
|
|
load();
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, []); return (
|
|
<div className="p-6 bg-background min-h-screen">
|
|
<h1 className="text-2xl font-bold mb-6 text-foreground">Dashboard Executivo de Relatórios</h1>
|
|
|
|
{/* Métricas principais */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-1 gap-6 mb-8">
|
|
{loading ? (
|
|
// simple skeletons while loading to avoid showing fake data
|
|
Array.from({ length: 1 }).map((_, i) => (
|
|
<div key={i} className="p-4 bg-card border border-border rounded-lg shadow flex flex-col items-center justify-center">
|
|
<div className="h-6 w-6 bg-muted rounded mb-2 animate-pulse" />
|
|
<div className="h-6 w-20 bg-muted rounded mt-2 animate-pulse" />
|
|
<div className="h-3 w-28 bg-muted rounded mt-3 animate-pulse" />
|
|
</div>
|
|
))
|
|
) : (
|
|
metricsState.map((m) => (
|
|
<div key={m.label} className="p-4 bg-card border border-border rounded-lg shadow flex flex-col items-center justify-center">
|
|
{m.icon}
|
|
<span className="text-2xl font-bold mt-2 text-foreground">{m.value}</span>
|
|
<span className="text-sm text-muted-foreground mt-1 text-center">{m.label}</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Consultas Chart */}
|
|
<div className="grid grid-cols-1 gap-8 mb-8">
|
|
<div className="bg-card border border-border rounded-lg shadow p-6">
|
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-0 mb-4">
|
|
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2">
|
|
<BarChart2 className="w-5 h-5" /> Consultas por Período
|
|
</h2>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="hover:bg-primary! hover:text-white! transition-colors w-full md:w-auto"
|
|
onClick={() => exportPDF("Consultas por Período", "Resumo das consultas realizadas por período.", "chart-consultas")}
|
|
>
|
|
<FileDown className="w-4 h-4 mr-1" /> Exportar PDF
|
|
</Button>
|
|
</div>
|
|
{loading ? (
|
|
<div className="h-[220px] flex items-center justify-center text-muted-foreground">Carregando dados...</div>
|
|
) : (
|
|
<div id="chart-consultas">
|
|
<ResponsiveContainer width="100%" height={220}>
|
|
<BarChart data={consultasData}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="periodo" />
|
|
<YAxis />
|
|
<Tooltip />
|
|
<Bar dataKey="consultas" fill="#6366f1" name="Consultas" />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
|
{/* Pacientes mais atendidos */}
|
|
<div className="bg-card border border-border rounded-lg shadow p-6">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><Users className="w-5 h-5" /> Pacientes Mais Atendidos</h2>
|
|
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Pacientes Mais Atendidos", "Lista dos pacientes mais atendidos.", "table-pacientes")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
|
</div>
|
|
<div id="table-pacientes">
|
|
<table className="w-full text-sm mt-4">
|
|
<thead>
|
|
<tr className="text-muted-foreground">
|
|
<th className="text-left font-medium">Paciente</th>
|
|
<th className="text-left font-medium">Consultas</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr>
|
|
<td className="py-4 text-muted-foreground" colSpan={2}>Carregando pacientes...</td>
|
|
</tr>
|
|
) : pacientesTop && pacientesTop.length ? (
|
|
pacientesTop.map((p: { nome: string; consultas: number }) => (
|
|
<tr key={p.nome}>
|
|
<td className="py-1">{p.nome}</td>
|
|
<td className="py-1">{p.consultas}</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td className="py-4 text-muted-foreground" colSpan={2}>Nenhum paciente encontrado</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Médicos mais produtivos */}
|
|
<div className="bg-card border border-border rounded-lg shadow p-6">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><Users className="w-5 h-5" /> Médicos Mais Produtivos</h2>
|
|
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Médicos Mais Produtivos", "Lista dos médicos mais produtivos.", "table-medicos")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
|
</div>
|
|
<div id="table-medicos">
|
|
<table className="w-full text-sm mt-4">
|
|
<thead>
|
|
<tr className="text-muted-foreground">
|
|
<th className="text-left font-medium">Médico</th>
|
|
<th className="text-left font-medium">Consultas</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr>
|
|
<td className="py-4 text-muted-foreground" colSpan={2}>Carregando médicos...</td>
|
|
</tr>
|
|
) : medicosTop && medicosTop.length ? (
|
|
medicosTop.map((m) => (
|
|
<tr key={m.nome}>
|
|
<td className="py-1">{m.nome}</td>
|
|
<td className="py-1">{m.consultas}</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td className="py-4 text-muted-foreground" colSpan={2}>Nenhum médico encontrado</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|