fix/report #70
@ -3,7 +3,6 @@ import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { ENV_CONFIG } from '@/lib/env-config'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@ -55,83 +54,7 @@ export default function LoginPacientePage() {
|
||||
|
||||
|
||||
|
||||
// --- Auto-cadastro (client-side) ---
|
||||
const [showRegister, setShowRegister] = useState(false)
|
||||
const [reg, setReg] = useState({ email: '', full_name: '', phone_mobile: '', cpf: '', birth_date: '' })
|
||||
const [regLoading, setRegLoading] = useState(false)
|
||||
const [regError, setRegError] = useState('')
|
||||
const [regSuccess, setRegSuccess] = useState('')
|
||||
|
||||
function cleanCpf(cpf: string) {
|
||||
return String(cpf || '').replace(/\D/g, '')
|
||||
}
|
||||
|
||||
function validateCPF(cpfRaw: string) {
|
||||
const cpf = cleanCpf(cpfRaw)
|
||||
if (!/^\d{11}$/.test(cpf)) return false
|
||||
if (/^([0-9])\1+$/.test(cpf)) return false
|
||||
const digits = cpf.split('').map((d) => Number(d))
|
||||
const calc = (len: number) => {
|
||||
let sum = 0
|
||||
for (let i = 0; i < len; i++) sum += digits[i] * (len + 1 - i)
|
||||
const v = (sum * 10) % 11
|
||||
return v === 10 ? 0 : v
|
||||
}
|
||||
return calc(9) === digits[9] && calc(10) === digits[10]
|
||||
}
|
||||
|
||||
const handleRegister = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault()
|
||||
setRegError('')
|
||||
setRegSuccess('')
|
||||
|
||||
// client-side validation
|
||||
if (!reg.email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(reg.email)) return setRegError('Email inválido')
|
||||
if (!reg.full_name || reg.full_name.trim().length < 3) return setRegError('Nome deve ter ao menos 3 caracteres')
|
||||
if (!reg.phone_mobile || !/^\d{10,11}$/.test(reg.phone_mobile)) return setRegError('Telefone inválido (10-11 dígitos)')
|
||||
if (!reg.cpf || !/^\d{11}$/.test(cleanCpf(reg.cpf))) return setRegError('CPF deve conter 11 dígitos')
|
||||
if (!validateCPF(reg.cpf)) return setRegError('CPF inválido')
|
||||
|
||||
setRegLoading(true)
|
||||
try {
|
||||
const url = `${ENV_CONFIG.SUPABASE_URL}/functions/v1/register-patient`
|
||||
const body = {
|
||||
email: reg.email,
|
||||
full_name: reg.full_name,
|
||||
phone_mobile: reg.phone_mobile,
|
||||
cpf: cleanCpf(reg.cpf),
|
||||
// always include redirect to patient landing as requested
|
||||
redirect_url: 'https://mediconecta-app-liart.vercel.app/'
|
||||
} as any
|
||||
if (reg.birth_date) body.birth_date = reg.birth_date
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', apikey: ENV_CONFIG.SUPABASE_ANON_KEY, Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const json = await res.json().catch(() => null)
|
||||
if (res.ok) {
|
||||
setRegSuccess(json?.message ?? 'Cadastro realizado com sucesso! Verifique seu email para acessar a plataforma.')
|
||||
// clear form but keep email for convenience
|
||||
setReg({ ...reg, full_name: '', phone_mobile: '', cpf: '', birth_date: '' })
|
||||
} else if (res.status === 400) {
|
||||
setRegError(json?.error ?? json?.message ?? 'Dados inválidos')
|
||||
} else if (res.status === 409) {
|
||||
setRegError(json?.error ?? 'CPF ou email já cadastrado')
|
||||
} else if (res.status === 429) {
|
||||
setRegError(json?.error ?? 'Rate limit excedido. Tente novamente mais tarde.')
|
||||
} else {
|
||||
setRegError(json?.error ?? json?.message ?? `Erro (${res.status})`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[REGISTER PACIENTE] erro', err)
|
||||
setRegError(err?.message ?? String(err))
|
||||
} finally {
|
||||
setRegLoading(false)
|
||||
}
|
||||
}
|
||||
// Auto-cadastro foi removido (UI + client-side endpoint call)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8">
|
||||
@ -206,53 +129,7 @@ export default function LoginPacientePage() {
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="text-sm text-muted-foreground mb-2">Ainda não tem conta? <button className="text-primary underline ml-2" onClick={() => setShowRegister(!showRegister)}>{showRegister ? 'Fechar' : 'Criar conta'}</button></div>
|
||||
|
||||
{showRegister && (
|
||||
<Card className="mt-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">Auto-cadastro de Paciente</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">Nome completo</label>
|
||||
<Input value={reg.full_name} onChange={(e) => setReg({...reg, full_name: e.target.value})} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">Email</label>
|
||||
<Input type="email" value={reg.email} onChange={(e) => setReg({...reg, email: e.target.value})} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">Telefone (apenas números)</label>
|
||||
<Input value={reg.phone_mobile} onChange={(e) => setReg({...reg, phone_mobile: e.target.value.replace(/\D/g,'')})} placeholder="11999998888" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">CPF (11 dígitos)</label>
|
||||
<Input value={reg.cpf} onChange={(e) => setReg({...reg, cpf: e.target.value.replace(/\D/g,'')})} placeholder="12345678901" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">Data de nascimento (opcional)</label>
|
||||
<Input type="date" value={reg.birth_date} onChange={(e) => setReg({...reg, birth_date: e.target.value})} />
|
||||
</div>
|
||||
|
||||
{regError && (
|
||||
<Alert variant="destructive"><AlertDescription>{regError}</AlertDescription></Alert>
|
||||
)}
|
||||
{regSuccess && (
|
||||
<Alert><AlertDescription>{regSuccess}</AlertDescription></Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="flex-1" disabled={regLoading}>{regLoading ? 'Criando...' : 'Criar Conta'}</Button>
|
||||
<Button variant="ghost" onClick={() => setShowRegister(false)} disabled={regLoading}>Cancelar</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{/* Auto-cadastro UI removed */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -1,332 +1,180 @@
|
||||
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileDown, BarChart2, Users, DollarSign, TrendingUp, UserCheck, CalendarCheck, ThumbsUp, User, Briefcase } from "lucide-react";
|
||||
import { FileDown, BarChart2, Users, CalendarCheck } from "lucide-react";
|
||||
import jsPDF from "jspdf";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||
import {
|
||||
countTotalPatients,
|
||||
countTotalDoctors,
|
||||
countAppointmentsToday,
|
||||
getAppointmentsByDateRange,
|
||||
listarAgendamentos,
|
||||
getUpcomingAppointments,
|
||||
getNewUsersLastDays,
|
||||
getPendingReports,
|
||||
buscarMedicosPorIds,
|
||||
buscarPacientesPorIds,
|
||||
} from "@/lib/api";
|
||||
|
||||
// Dados fictícios para demonstração
|
||||
const metricas = [
|
||||
{ label: "Atendimentos", value: 1240, icon: <CalendarCheck className="w-6 h-6 text-blue-500" /> },
|
||||
{ label: "Absenteísmo", value: "7,2%", icon: <UserCheck className="w-6 h-6 text-red-500" /> },
|
||||
{ label: "Satisfação", value: "Dados não foram disponibilizados", icon: <ThumbsUp className="w-6 h-6 text-green-500" /> },
|
||||
{ label: "Faturamento (Mês)", value: "R$ 45.000", icon: <DollarSign className="w-6 h-6 text-emerald-500" /> },
|
||||
{ label: "No-show", value: "5,1%", icon: <User className="w-6 h-6 text-yellow-500" /> },
|
||||
];
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const consultasPorPeriodo = [
|
||||
{ periodo: "Jan", consultas: 210 },
|
||||
{ periodo: "Fev", consultas: 180 },
|
||||
{ periodo: "Mar", consultas: 250 },
|
||||
{ periodo: "Abr", consultas: 230 },
|
||||
{ periodo: "Mai", consultas: 270 },
|
||||
{ periodo: "Jun", consultas: 220 },
|
||||
];
|
||||
|
||||
const faturamentoMensal = [
|
||||
{ mes: "Jan", valor: 35000 },
|
||||
{ mes: "Fev", valor: 29000 },
|
||||
{ mes: "Mar", valor: 42000 },
|
||||
{ mes: "Abr", valor: 38000 },
|
||||
{ mes: "Mai", valor: 45000 },
|
||||
{ mes: "Jun", valor: 41000 },
|
||||
];
|
||||
|
||||
const taxaNoShow = [
|
||||
{ mes: "Jan", noShow: 6.2 },
|
||||
{ mes: "Fev", noShow: 5.8 },
|
||||
{ mes: "Mar", noShow: 4.9 },
|
||||
{ mes: "Abr", noShow: 5.5 },
|
||||
{ mes: "Mai", noShow: 5.1 },
|
||||
{ mes: "Jun", noShow: 4.7 },
|
||||
];
|
||||
|
||||
// pacientesMaisAtendidos static list removed — data will be fetched from the API
|
||||
|
||||
const medicosMaisProdutivos = [
|
||||
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 },
|
||||
];
|
||||
|
||||
const convenios = [
|
||||
{ nome: "Unimed", valor: 18000 },
|
||||
{ nome: "Bradesco", valor: 12000 },
|
||||
{ nome: "SulAmérica", valor: 9000 },
|
||||
{ nome: "Particular", valor: 15000 },
|
||||
];
|
||||
|
||||
const performancePorMedico = [
|
||||
{ nome: "Dr. Carlos Andrade", consultas: 62, absenteismo: 4.8 },
|
||||
{ nome: "Dra. Paula Silva", consultas: 58, absenteismo: 6.1 },
|
||||
{ nome: "Dr. João Pedro", consultas: 54, absenteismo: 7.5 },
|
||||
{ nome: "Dra. Marina Costa", consultas: 51, absenteismo: 5.2 },
|
||||
];
|
||||
|
||||
const COLORS = ["#10b981", "#6366f1", "#f59e42", "#ef4444"];
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function exportPDF(title: string, content: string) {
|
||||
const doc = new jsPDF();
|
||||
doc.text(title, 10, 10);
|
||||
doc.text(content, 10, 20);
|
||||
doc.save(`${title.toLowerCase().replace(/ /g, '-')}.pdf`);
|
||||
doc.save(`${title.toLowerCase().replace(/ /g, "-")}.pdf`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export default function RelatoriosPage() {
|
||||
// Local state that will be replaced by API data when available
|
||||
// Start with empty data to avoid showing fictitious frontend data while loading
|
||||
// State
|
||||
const [metricsState, setMetricsState] = useState<Array<{ label: string; value: any; icon: any }>>([]);
|
||||
const [consultasData, setConsultasData] = useState<Array<{ periodo: string; consultas: number }>>([]);
|
||||
const [faturamentoData, setFaturamentoData] = useState<Array<{ mes: string; valor: number }>>([]);
|
||||
const [taxaNoShowState, setTaxaNoShowState] = useState<Array<{ mes: string; noShow: number }>>([]);
|
||||
const [pacientesTop, setPacientesTop] = useState<Array<{ nome: string; consultas: number }>>([]);
|
||||
const [medicosTop, setMedicosTop] = useState(medicosMaisProdutivos);
|
||||
const [medicosPerformance, setMedicosPerformance] = useState<Array<{ nome: string; consultas: number; absenteismo: number }>>([]);
|
||||
const [medicosTop, setMedicosTop] = useState(FALLBACK_MEDICOS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [conveniosData, setConveniosData] = useState<Array<{ nome: string; valor: number }>>(convenios);
|
||||
|
||||
// Data Loading
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch counts in parallel, then try to fetch a larger appointments list via listarAgendamentos.
|
||||
// If listarAgendamentos fails (for example: unauthenticated), fall back to getAppointmentsByDateRange(30).
|
||||
const [patientsCount, doctorsCount, appointmentsToday] = await Promise.all([
|
||||
countTotalPatients().catch(() => 0),
|
||||
countTotalDoctors().catch(() => 0),
|
||||
countAppointmentsToday().catch(() => 0),
|
||||
]);
|
||||
|
||||
// Fetch appointments
|
||||
let appointments: any[] = [];
|
||||
try {
|
||||
// Try to get a larger set of appointments (up to 1000) to compute top patients
|
||||
// select=patient_id,doctor_id,scheduled_at,status to reduce payload
|
||||
// include insurance_provider so we can aggregate convênios client-side
|
||||
appointments = await listarAgendamentos('select=patient_id,doctor_id,scheduled_at,status,insurance_provider&order=scheduled_at.desc&limit=1000');
|
||||
appointments = await listarAgendamentos(
|
||||
"select=patient_id,doctor_id,scheduled_at,status&order=scheduled_at.desc&limit=1000"
|
||||
);
|
||||
} catch (e) {
|
||||
// Fallback to the smaller helper if listarAgendamentos cannot be used (e.g., no auth token)
|
||||
console.warn('[relatorios] listarAgendamentos falhou, usando getAppointmentsByDateRange fallback', 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;
|
||||
|
||||
// Update top metrics card
|
||||
setMetricsState([
|
||||
{ label: "Atendimentos", value: appointmentsToday ?? 0, icon: <CalendarCheck className="w-6 h-6 text-blue-500" /> },
|
||||
{ label: "Absenteísmo", value: "—", icon: <UserCheck className="w-6 h-6 text-red-500" /> },
|
||||
{ label: "Satisfação", value: "Dados não foram disponibilizados", icon: <ThumbsUp className="w-6 h-6 text-green-500" /> },
|
||||
{ label: "Faturamento (Mês)", value: "—", icon: <DollarSign className="w-6 h-6 text-emerald-500" /> },
|
||||
{ label: "No-show", value: "—", icon: <User className="w-6 h-6 text-yellow-500" /> },
|
||||
]);
|
||||
|
||||
// Build last 30 days series for consultas
|
||||
// ===== 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; // include today
|
||||
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')}`;
|
||||
const periodo = `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
dayBuckets[iso] = { periodo, consultas: 0 };
|
||||
}
|
||||
|
||||
// Count appointments per day
|
||||
const appts = Array.isArray(appointments) ? appointments : [];
|
||||
for (const a of appts) {
|
||||
try {
|
||||
const iso = (a.scheduled_at || '').toString().split('T')[0];
|
||||
const iso = (a.scheduled_at || "").toString().split("T")[0];
|
||||
if (iso && dayBuckets[iso]) dayBuckets[iso].consultas += 1;
|
||||
} catch (e) {
|
||||
// ignore malformed
|
||||
}
|
||||
}
|
||||
const consultasArr = Object.values(dayBuckets);
|
||||
setConsultasData(consultasArr);
|
||||
setConsultasData(Object.values(dayBuckets));
|
||||
|
||||
// Estimate monthly faturamento for last 6 months using doctor.valor_consulta when available
|
||||
const monthsBack = 6;
|
||||
const monthMap: Record<string, { mes: string; valor: number; totalAppointments: number; noShowCount: number }> = {};
|
||||
const nowMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const monthKeys: string[] = [];
|
||||
for (let i = monthsBack - 1; i >= 0; i--) {
|
||||
const m = new Date(nowMonth.getFullYear(), nowMonth.getMonth() - i, 1);
|
||||
const key = `${m.getFullYear()}-${String(m.getMonth() + 1).padStart(2, '0')}`;
|
||||
monthKeys.push(key);
|
||||
monthMap[key] = { mes: m.toLocaleString('pt-BR', { month: 'short' }), valor: 0, totalAppointments: 0, noShowCount: 0 };
|
||||
}
|
||||
|
||||
// Filter appointments within monthsBack and group
|
||||
const apptsForMonths = appts.filter((a) => {
|
||||
try {
|
||||
const d = new Date(a.scheduled_at);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
return key in monthMap;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Collect unique doctor ids to fetch valor_consulta in bulk
|
||||
const doctorIds = Array.from(new Set(apptsForMonths.map((a: any) => String(a.doctor_id).trim()).filter(Boolean)));
|
||||
const doctors = doctorIds.length ? await buscarMedicosPorIds(doctorIds) : [];
|
||||
const doctorMap = new Map<string, any>();
|
||||
for (const d of doctors) doctorMap.set(String(d.id), d);
|
||||
|
||||
for (const a of apptsForMonths) {
|
||||
try {
|
||||
const d = new Date(a.scheduled_at);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
const doc = doctorMap.get(String(a.doctor_id));
|
||||
const price = doc && doc.valor_consulta ? Number(doc.valor_consulta) : 0;
|
||||
monthMap[key].valor += price;
|
||||
monthMap[key].totalAppointments += 1;
|
||||
if (String(a.status || '').toLowerCase() === 'no_show' || String(a.status || '').toLowerCase() === 'no-show') {
|
||||
monthMap[key].noShowCount += 1;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const faturamentoArr = monthKeys.map((k) => ({ mes: monthMap[k].mes, valor: Math.round(monthMap[k].valor) }));
|
||||
setFaturamentoData(faturamentoArr);
|
||||
|
||||
// Taxa no-show per month
|
||||
const taxaArr = monthKeys.map((k) => {
|
||||
const total = monthMap[k].totalAppointments || 0;
|
||||
const noShow = monthMap[k].noShowCount || 0;
|
||||
const pct = total ? Number(((noShow / total) * 100).toFixed(1)) : 0;
|
||||
return { mes: monthMap[k].mes, noShow: pct };
|
||||
});
|
||||
setTaxaNoShowState(taxaArr);
|
||||
|
||||
// Top patients and doctors (by number of appointments in the period)
|
||||
// ===== Aggregate Counts =====
|
||||
const patientCounts: Record<string, number> = {};
|
||||
const doctorCounts: Record<string, number> = {};
|
||||
const doctorNoShowCounts: Record<string, number> = {};
|
||||
for (const a of apptsForMonths) {
|
||||
if (a.patient_id) patientCounts[String(a.patient_id)] = (patientCounts[String(a.patient_id)] || 0) + 1;
|
||||
|
||||
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;
|
||||
const status = String(a.status || '').toLowerCase();
|
||||
if (status === 'no_show' || status === 'no-show') doctorNoShowCounts[did] = (doctorNoShowCounts[did] || 0) + 1;
|
||||
if (String(a.status || "").toLowerCase() === "no_show" || String(a.status || "").toLowerCase() === "no-show") {
|
||||
doctorNoShowCounts[did] = (doctorNoShowCounts[did] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
// ===== 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 };
|
||||
});
|
||||
|
||||
// Build performance list (consultas + absenteísmo)
|
||||
const perfIds = Object.keys(doctorCounts).sort((a, b) => (doctorCounts[b] || 0) - (doctorCounts[a] || 0)).slice(0, 5);
|
||||
const perfDoctors = (doctorsFetched && doctorsFetched.length) ? doctorsFetched : doctors;
|
||||
const perfList = perfIds.map((id) => {
|
||||
const d = (perfDoctors || []).find((x: any) => String(x.id) === String(id));
|
||||
const consultas = doctorCounts[id] || 0;
|
||||
const noShow = doctorNoShowCounts[id] || 0;
|
||||
const absenteismo = consultas ? Number(((noShow / consultas) * 100).toFixed(1)) : 0;
|
||||
return { nome: d ? d.full_name : id, consultas, absenteismo };
|
||||
});
|
||||
|
||||
// Use fetched list (may be empty) — do not fall back to static data for patients, but keep fallback for medicosTop
|
||||
// ===== Update State =====
|
||||
setPacientesTop(pacientesList);
|
||||
setMedicosTop(medicosList.length ? medicosList : medicosMaisProdutivos);
|
||||
setMedicosPerformance(perfList.length ? perfList.slice(0,5) : performancePorMedico.map((p) => ({ nome: p.nome, consultas: p.consultas, absenteismo: p.absenteismo })).slice(0,5));
|
||||
|
||||
// Aggregate convênios (insurance providers) from appointments in the period
|
||||
try {
|
||||
const providerCounts: Record<string, number> = {};
|
||||
for (const a of apptsForMonths) {
|
||||
let prov: any = a?.insurance_provider ?? a?.insuranceProvider ?? a?.insurance ?? '';
|
||||
// If provider is an object, try to extract a human-friendly name
|
||||
if (prov && typeof prov === 'object') prov = prov.name || prov.full_name || prov.title || '';
|
||||
prov = String(prov || '').trim();
|
||||
const key = prov || 'Não disponibilizado';
|
||||
providerCounts[key] = (providerCounts[key] || 0) + 1;
|
||||
}
|
||||
|
||||
let conveniosArr = Object.entries(providerCounts).map(([nome, valor]) => ({ nome, valor }));
|
||||
if (!conveniosArr.length) {
|
||||
// No provider info at all — present a single bucket showing the total count as 'Não disponibilizado'
|
||||
conveniosArr = [{ nome: 'Não disponibilizado', valor: apptsForMonths.length }];
|
||||
} else {
|
||||
// Sort and keep top 5, group the rest into 'Outros'
|
||||
conveniosArr.sort((a, b) => b.valor - a.valor);
|
||||
if (conveniosArr.length > 5) {
|
||||
const top = conveniosArr.slice(0, 5);
|
||||
const others = conveniosArr.slice(5).reduce((s, c) => s + c.valor, 0);
|
||||
top.push({ nome: 'Outros', valor: others });
|
||||
conveniosArr = top;
|
||||
}
|
||||
}
|
||||
setConveniosData(conveniosArr);
|
||||
} catch (e) {
|
||||
// keep existing static conveniosData if something goes wrong
|
||||
console.warn('[relatorios] erro ao agregar convênios', e);
|
||||
}
|
||||
|
||||
// Update metrics cards with numbers we fetched
|
||||
setMedicosTop(medicosList.length ? medicosList : FALLBACK_MEDICOS);
|
||||
setMetricsState([
|
||||
{ label: "Atendimentos", value: appointmentsToday ?? 0, icon: <CalendarCheck className="w-6 h-6 text-blue-500" /> },
|
||||
{ label: "Absenteísmo", value: '—', icon: <UserCheck className="w-6 h-6 text-red-500" /> },
|
||||
{ label: "Satisfação", value: 'Dados não foram disponibilizados', icon: <ThumbsUp className="w-6 h-6 text-green-500" /> },
|
||||
{ label: "Faturamento (Mês)", value: `R$ ${faturamentoArr.at(-1)?.valor ?? 0}`, icon: <DollarSign className="w-6 h-6 text-emerald-500" /> },
|
||||
{ label: "No-show", value: `${taxaArr.at(-1)?.noShow ?? 0}%`, icon: <User className="w-6 h-6 text-yellow-500" /> },
|
||||
] as any);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('[relatorios] erro ao carregar dados', err);
|
||||
console.error("[relatorios] error loading data:", err);
|
||||
if (mounted) setError(err?.message ?? String(err));
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
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-5 gap-6 mb-8">
|
||||
<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: 5 }).map((_, i) => (
|
||||
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" />
|
||||
@ -344,13 +192,21 @@ export default function RelatoriosPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gráficos e Relatórios */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Consultas realizadas por período */}
|
||||
{/* 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 items-center justify-between mb-2">
|
||||
<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" onClick={() => exportPDF("Consultas por Período", "Resumo das consultas realizadas por período.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
<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.")}
|
||||
>
|
||||
<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>
|
||||
@ -366,62 +222,6 @@ export default function RelatoriosPage() {
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Faturamento mensal/anual */}
|
||||
<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"><DollarSign className="w-5 h-5" /> Faturamento Mensal</h2>
|
||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Faturamento Mensal", "Resumo do faturamento mensal.")}> <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>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={faturamentoData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="mes" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="valor" stroke="#10b981" name="Faturamento" strokeWidth={3} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Taxa de no-show */}
|
||||
<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"><UserCheck className="w-5 h-5" /> Taxa de No-show</h2>
|
||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Taxa de No-show", "Resumo da taxa de no-show.")}> <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>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={taxaNoShowState}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="mes" />
|
||||
<YAxis unit="%" />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="noShow" stroke="#ef4444" name="No-show (%)" strokeWidth={3} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Indicadores de satisfação */}
|
||||
<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"><ThumbsUp className="w-5 h-5" /> Satisfação dos Pacientes</h2>
|
||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Satisfação dos Pacientes", "Resumo dos indicadores de satisfação.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center h-[220px]">
|
||||
<span className="text-2xl font-bold text-foreground">Dados não foram disponibilizados</span>
|
||||
<span className="text-muted-foreground mt-2">Índice de satisfação geral</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
@ -462,7 +262,7 @@ export default function RelatoriosPage() {
|
||||
{/* 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"><Briefcase className="w-5 h-5" /> Médicos Mais Produtivos</h2>
|
||||
<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.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
</div>
|
||||
<table className="w-full text-sm mt-4">
|
||||
@ -493,59 +293,6 @@ export default function RelatoriosPage() {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Análise de convênios */}
|
||||
<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"><DollarSign className="w-5 h-5" /> Análise de Convênios</h2>
|
||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Análise de Convênios", "Resumo da análise de convênios.")}> <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>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={conveniosData} dataKey="valor" nameKey="nome" cx="50%" cy="50%" outerRadius={80} label>
|
||||
{conveniosData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Performance por médico */}
|
||||
<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-2 md:gap-0 mb-4">
|
||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><TrendingUp className="w-5 h-5" /> Performance por Médico</h2>
|
||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors w-full md:w-auto" onClick={() => exportPDF("Performance por Médico", "Resumo da performance por médico.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b border-border">
|
||||
<th className="text-left font-medium py-3 px-2 md:px-0">Médico</th>
|
||||
<th className="text-center font-medium py-3 px-2 md:px-0">Consultas</th>
|
||||
<th className="text-center font-medium py-3 px-2 md:px-0">Absenteísmo (%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(loading ? performancePorMedico : medicosPerformance).map((m) => (
|
||||
<tr key={m.nome} className="border-b border-border/50 hover:bg-muted/30 transition-colors">
|
||||
<td className="py-3 px-2 md:px-0">{m.nome}</td>
|
||||
<td className="py-3 px-2 md:px-0 text-center font-medium">{m.consultas}</td>
|
||||
<td className="py-3 px-2 md:px-0 text-center text-blue-500 font-medium">{m.absenteismo}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user