fix-patient-and-report-page
This commit is contained in:
parent
1088e66f55
commit
076ec25fd4
@ -1,16 +1,29 @@
|
||||
|
||||
"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 jsPDF from "jspdf";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } 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: "92%", icon: <ThumbsUp className="w-6 h-6 text-green-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" /> },
|
||||
];
|
||||
@ -42,13 +55,7 @@ const taxaNoShow = [
|
||||
{ mes: "Jun", noShow: 4.7 },
|
||||
];
|
||||
|
||||
const pacientesMaisAtendidos = [
|
||||
{ nome: "Ana Souza", consultas: 18 },
|
||||
{ nome: "Bruno Lima", consultas: 15 },
|
||||
{ nome: "Carla Menezes", consultas: 13 },
|
||||
{ nome: "Diego Alves", consultas: 12 },
|
||||
{ nome: "Fernanda Dias", consultas: 11 },
|
||||
];
|
||||
// pacientesMaisAtendidos static list removed — data will be fetched from the API
|
||||
|
||||
const medicosMaisProdutivos = [
|
||||
{ nome: "Dr. Carlos Andrade", consultas: 62 },
|
||||
@ -81,19 +88,260 @@ function exportPDF(title: string, content: string) {
|
||||
}
|
||||
|
||||
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
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [conveniosData, setConveniosData] = useState<Array<{ nome: string; valor: number }>>(convenios);
|
||||
|
||||
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),
|
||||
]);
|
||||
|
||||
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');
|
||||
} 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);
|
||||
appointments = await getAppointmentsByDateRange(30).catch(() => []);
|
||||
}
|
||||
|
||||
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
|
||||
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 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 };
|
||||
}
|
||||
|
||||
// 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];
|
||||
if (iso && dayBuckets[iso]) dayBuckets[iso].consultas += 1;
|
||||
} catch (e) {
|
||||
// ignore malformed
|
||||
}
|
||||
}
|
||||
const consultasArr = Object.values(dayBuckets);
|
||||
setConsultasData(consultasArr);
|
||||
|
||||
// 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)
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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([]),
|
||||
]);
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
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
|
||||
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
|
||||
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[faturamentoArr.length - 1]?.valor ?? 0}`, icon: <DollarSign className="w-6 h-6 text-emerald-500" /> },
|
||||
{ label: "No-show", value: `${taxaArr[taxaArr.length - 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);
|
||||
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-5 gap-6 mb-8">
|
||||
{metricas.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>
|
||||
))}
|
||||
{loading ? (
|
||||
// simple skeletons while loading to avoid showing fake data
|
||||
Array.from({ length: 5 }).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>
|
||||
|
||||
{/* Gráficos e Relatórios */}
|
||||
@ -104,15 +352,19 @@ export default function RelatoriosPage() {
|
||||
<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>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={consultasPorPeriodo}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="periodo" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="consultas" fill="#6366f1" name="Consultas" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
{loading ? (
|
||||
<div className="h-[220px] flex items-center justify-center text-muted-foreground">Carregando dados...</div>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{/* Faturamento mensal/anual */}
|
||||
@ -121,15 +373,19 @@ export default function RelatoriosPage() {
|
||||
<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>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={faturamentoMensal}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="mes" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="valor" stroke="#10b981" name="Faturamento" strokeWidth={3} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
{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>
|
||||
|
||||
@ -140,15 +396,19 @@ export default function RelatoriosPage() {
|
||||
<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>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={taxaNoShow}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="mes" />
|
||||
<YAxis unit="%" />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="noShow" stroke="#ef4444" name="No-show (%)" strokeWidth={3} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
{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 */}
|
||||
@ -158,7 +418,7 @@ export default function RelatoriosPage() {
|
||||
<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-5xl font-bold text-green-500">92%</span>
|
||||
<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>
|
||||
@ -179,12 +439,22 @@ export default function RelatoriosPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pacientesMaisAtendidos.map((p) => (
|
||||
<tr key={p.nome}>
|
||||
<td className="py-1">{p.nome}</td>
|
||||
<td className="py-1">{p.consultas}</td>
|
||||
{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>
|
||||
@ -203,12 +473,22 @@ export default function RelatoriosPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{medicosMaisProdutivos.map((m) => (
|
||||
<tr key={m.nome}>
|
||||
<td className="py-1">{m.nome}</td>
|
||||
<td className="py-1">{m.consultas}</td>
|
||||
{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>
|
||||
@ -221,17 +501,21 @@ export default function RelatoriosPage() {
|
||||
<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>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={convenios} dataKey="valor" nameKey="nome" cx="50%" cy="50%" outerRadius={80} label>
|
||||
{convenios.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
{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 */}
|
||||
@ -249,7 +533,7 @@ export default function RelatoriosPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{performancePorMedico.map((m) => (
|
||||
{(loading ? performancePorMedico : medicosPerformance).map((m) => (
|
||||
<tr key={m.nome}>
|
||||
<td className="py-1">{m.nome}</td>
|
||||
<td className="py-1">{m.consultas}</td>
|
||||
|
||||
@ -324,10 +324,84 @@ export default function PacientePage() {
|
||||
setNextAppt(null)
|
||||
}
|
||||
|
||||
// Load reports/laudos count
|
||||
// Load reports/laudos and compute count matching the Laudos session rules
|
||||
const reports = await listarRelatoriosPorPaciente(String(patientId)).catch(() => [])
|
||||
if (!mounted) return
|
||||
setExamsCount(Array.isArray(reports) ? reports.length : 0)
|
||||
let count = 0
|
||||
try {
|
||||
if (!Array.isArray(reports) || reports.length === 0) {
|
||||
count = 0
|
||||
} else {
|
||||
// Use the same robust doctor-resolution strategy as ExamesLaudos so
|
||||
// the card matches the list: try buscarMedicosPorIds, then per-id
|
||||
// getDoctorById and finally a REST fallback by user_id.
|
||||
const ids = Array.from(new Set((reports as any[]).map((r:any) => r.doctor_id || r.created_by || r.doctor).filter(Boolean).map(String)))
|
||||
if (ids.length === 0) {
|
||||
// fallback: count reports that have any direct doctor reference
|
||||
count = (reports as any[]).filter((r:any) => !!(r && (r.doctor_id || r.created_by || r.doctor || r.user_id))).length
|
||||
} else {
|
||||
const docs = await buscarMedicosPorIds(ids).catch(() => [])
|
||||
const map: Record<string, any> = {}
|
||||
for (const d of docs || []) {
|
||||
if (!d) continue
|
||||
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
|
||||
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = map[String(d.user_id)] || d } catch {}
|
||||
}
|
||||
|
||||
// Try per-id fallback using getDoctorById for any unresolved ids
|
||||
const unresolved = ids.filter(i => !map[i])
|
||||
if (unresolved.length) {
|
||||
for (const u of unresolved) {
|
||||
try {
|
||||
const d = await getDoctorById(String(u)).catch(() => null)
|
||||
if (d) {
|
||||
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
|
||||
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore per-id failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// REST fallback: try lookup by user_id for still unresolved ids
|
||||
const stillUnresolved = ids.filter(i => !map[i])
|
||||
if (stillUnresolved.length) {
|
||||
for (const u of stillUnresolved) {
|
||||
try {
|
||||
const token = (typeof window !== 'undefined') ? (localStorage.getItem('auth_token') || localStorage.getItem('token') || sessionStorage.getItem('auth_token') || sessionStorage.getItem('token')) : null
|
||||
const headers: Record<string,string> = { apikey: ENV_CONFIG.SUPABASE_ANON_KEY, Accept: 'application/json' }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
const url = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/doctors?user_id=eq.${encodeURIComponent(String(u))}&limit=1`
|
||||
const res = await fetch(url, { method: 'GET', headers })
|
||||
if (!res || res.status >= 400) continue
|
||||
const rows = await res.json().catch(() => [])
|
||||
if (rows && Array.isArray(rows) && rows.length) {
|
||||
const d = rows[0]
|
||||
if (d) {
|
||||
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
|
||||
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore network errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count only reports whose referenced doctor record has user_id
|
||||
count = (reports as any[]).filter((r:any) => {
|
||||
const maybeId = String(r.doctor_id || r.created_by || r.doctor || '')
|
||||
const doc = map[maybeId]
|
||||
return !!(doc && (doc.user_id || (doc as any).user_id))
|
||||
}).length
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
count = Array.isArray(reports) ? reports.length : 0
|
||||
}
|
||||
if (!mounted) return
|
||||
setExamsCount(count)
|
||||
} catch (e) {
|
||||
console.warn('[DashboardCards] erro ao carregar dados', e)
|
||||
if (!mounted) return
|
||||
@ -353,7 +427,7 @@ export default function PacientePage() {
|
||||
{strings.proximaConsulta}
|
||||
</span>
|
||||
<span className="text-lg md:text-xl font-semibold text-foreground" aria-live="polite">
|
||||
{loading ? '—' : (nextAppt ?? '-')}
|
||||
{loading ? strings.carregando : (nextAppt ?? '-')}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
@ -367,7 +441,7 @@ export default function PacientePage() {
|
||||
{strings.ultimosExames}
|
||||
</span>
|
||||
<span className="text-lg md:text-xl font-semibold text-foreground" aria-live="polite">
|
||||
{loading ? '—' : (examsCount !== null ? String(examsCount) : '-')}
|
||||
{loading ? strings.carregando : (examsCount !== null ? String(examsCount) : '-')}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
@ -847,9 +921,24 @@ export default function PacientePage() {
|
||||
if (q.length >= 2) {
|
||||
const docs = await buscarMedicos(q).catch(() => [])
|
||||
if (!mounted) return
|
||||
if (docs && Array.isArray(docs) && docs.length) {
|
||||
// fetch reports for matching doctors in parallel
|
||||
const promises = docs.map(d => listarRelatoriosPorMedico(String(d.id)).catch(() => []))
|
||||
if (docs && Array.isArray(docs) && docs.length) {
|
||||
// fetch reports for matching doctors in parallel. Some report rows
|
||||
// reference the doctor's account `user_id` in `requested_by` while
|
||||
// others reference the doctor's record `id`. Try both per doctor.
|
||||
const promises = docs.map(async (d: any) => {
|
||||
try {
|
||||
const byId = await listarRelatoriosPorMedico(String(d.id)).catch(() => [])
|
||||
if (Array.isArray(byId) && byId.length) return byId
|
||||
// fallback: if the doctor record has a user_id, try that too
|
||||
if (d && (d.user_id || d.userId)) {
|
||||
const byUser = await listarRelatoriosPorMedico(String(d.user_id || d.userId)).catch(() => [])
|
||||
if (Array.isArray(byUser) && byUser.length) return byUser
|
||||
}
|
||||
return []
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const arrays = await Promise.all(promises)
|
||||
if (!mounted) return
|
||||
const combined = ([] as any[]).concat(...arrays)
|
||||
@ -981,6 +1070,22 @@ export default function PacientePage() {
|
||||
}
|
||||
|
||||
setDoctorsMap(map)
|
||||
// After resolving doctor records, filter out reports whose doctor
|
||||
// record doesn't have a user_id (doctor_userid). If a report's
|
||||
// referenced doctor lacks user_id, we hide that laudo.
|
||||
try {
|
||||
const filtered = (reports || []).filter((r: any) => {
|
||||
const maybeId = String(r?.doctor_id || r?.created_by || r?.doctor || '')
|
||||
const doc = map[maybeId]
|
||||
return !!(doc && (doc.user_id || (doc as any).user_id))
|
||||
})
|
||||
// Only update when different to avoid extra cycles
|
||||
if (Array.isArray(filtered) && filtered.length !== (reports || []).length) {
|
||||
setReports(filtered)
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore filtering errors
|
||||
}
|
||||
setResolvingDoctors(false)
|
||||
} catch (e) {
|
||||
// ignore resolution errors
|
||||
@ -995,17 +1100,101 @@ export default function PacientePage() {
|
||||
if (!patientId) return
|
||||
setLoadingReports(true)
|
||||
setReportsError(null)
|
||||
listarRelatoriosPorPaciente(String(patientId))
|
||||
.then(res => {
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await listarRelatoriosPorPaciente(String(patientId)).catch(() => [])
|
||||
if (!mounted) return
|
||||
setReports(Array.isArray(res) ? res : [])
|
||||
})
|
||||
.catch(err => {
|
||||
|
||||
// If no reports, set empty and return
|
||||
if (!Array.isArray(res) || res.length === 0) {
|
||||
setReports([])
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve referenced doctor ids and only keep reports whose
|
||||
// referenced doctor record has a truthy user_id (i.e., created by a doctor)
|
||||
try {
|
||||
setResolvingDoctors(true)
|
||||
const ids = Array.from(new Set((res as any[]).map((r:any) => r.doctor_id || r.created_by || r.doctor).filter(Boolean).map(String)))
|
||||
const map: Record<string, any> = {}
|
||||
if (ids.length) {
|
||||
const docs = await buscarMedicosPorIds(ids).catch(() => [])
|
||||
for (const d of docs || []) {
|
||||
if (!d) continue
|
||||
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
|
||||
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = map[String(d.user_id)] || d } catch {}
|
||||
}
|
||||
|
||||
// per-id fallback
|
||||
const unresolved = ids.filter(i => !map[i])
|
||||
if (unresolved.length) {
|
||||
for (const u of unresolved) {
|
||||
try {
|
||||
const d = await getDoctorById(String(u)).catch(() => null)
|
||||
if (d) {
|
||||
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
|
||||
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// REST fallback by user_id
|
||||
const stillUnresolved = ids.filter(i => !map[i])
|
||||
if (stillUnresolved.length) {
|
||||
for (const u of stillUnresolved) {
|
||||
try {
|
||||
const token = (typeof window !== 'undefined') ? (localStorage.getItem('auth_token') || localStorage.getItem('token') || sessionStorage.getItem('auth_token') || sessionStorage.getItem('token')) : null
|
||||
const headers: Record<string,string> = { apikey: ENV_CONFIG.SUPABASE_ANON_KEY, Accept: 'application/json' }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
const url = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/doctors?user_id=eq.${encodeURIComponent(String(u))}&limit=1`
|
||||
const r = await fetch(url, { method: 'GET', headers })
|
||||
if (!r || r.status >= 400) continue
|
||||
const rows = await r.json().catch(() => [])
|
||||
if (rows && Array.isArray(rows) && rows.length) {
|
||||
const d = rows[0]
|
||||
if (d) {
|
||||
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
|
||||
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now filter reports to only those whose referenced doctor has user_id
|
||||
const filtered = (res || []).filter((r: any) => {
|
||||
const maybeId = String(r?.doctor_id || r?.created_by || r?.doctor || '')
|
||||
const doc = map[maybeId]
|
||||
return !!(doc && (doc.user_id || (doc as any).user_id))
|
||||
})
|
||||
|
||||
// Update doctorsMap and reports
|
||||
setDoctorsMap(map)
|
||||
setReports(filtered)
|
||||
setResolvingDoctors(false)
|
||||
return
|
||||
} catch (e) {
|
||||
// If resolution fails, fall back to setting raw results
|
||||
console.warn('[ExamesLaudos] falha ao resolver médicos para filtragem', e)
|
||||
setReports(Array.isArray(res) ? res : [])
|
||||
setResolvingDoctors(false)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[ExamesLaudos] erro ao carregar laudos', err)
|
||||
if (!mounted) return
|
||||
setReportsError('Falha ao carregar laudos.')
|
||||
})
|
||||
.finally(() => { if (mounted) setLoadingReports(false) })
|
||||
} finally {
|
||||
if (mounted) setLoadingReports(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => { mounted = false }
|
||||
}, [patientId])
|
||||
@ -1099,11 +1288,13 @@ export default function PacientePage() {
|
||||
) : (
|
||||
(() => {
|
||||
const total = Array.isArray(filteredReports) ? filteredReports.length : 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / reportsPerPage))
|
||||
// enforce a maximum of 5 laudos per page
|
||||
const perPage = Math.max(1, Math.min(reportsPerPage || 5, 5))
|
||||
const totalPages = Math.max(1, Math.ceil(total / perPage))
|
||||
// keep page inside bounds
|
||||
const page = Math.min(Math.max(1, reportsPage), totalPages)
|
||||
const start = (page - 1) * reportsPerPage
|
||||
const end = start + reportsPerPage
|
||||
const start = (page - 1) * perPage
|
||||
const end = start + perPage
|
||||
const pageItems = (filteredReports || []).slice(start, end)
|
||||
|
||||
return (
|
||||
@ -1223,7 +1414,13 @@ export default function PacientePage() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSelectedReport(null)}>Fechar</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedReport(null)}
|
||||
className="transition duration-200 hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground"
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -31,6 +31,7 @@ import {
|
||||
getAvailableSlots,
|
||||
criarAgendamento,
|
||||
criarAgendamentoDireto,
|
||||
listarAgendamentos,
|
||||
getUserInfo,
|
||||
buscarPacientes,
|
||||
listarDisponibilidades,
|
||||
@ -61,6 +62,8 @@ export default function ResultadosClient() {
|
||||
const [especialidadeHero, setEspecialidadeHero] = useState<string>(params?.get('especialidade') || 'Psicólogo')
|
||||
const [convenio, setConvenio] = useState<string>('Todos')
|
||||
const [bairro, setBairro] = useState<string>('Todos')
|
||||
// Busca por nome do médico
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
|
||||
// Estado dinâmico
|
||||
const [patientId, setPatientId] = useState<string | null>(null)
|
||||
@ -126,6 +129,9 @@ export default function ResultadosClient() {
|
||||
|
||||
// 2) Buscar médicos conforme especialidade selecionada
|
||||
useEffect(() => {
|
||||
// If the user is actively searching by name, this effect should not run
|
||||
if (searchQuery && String(searchQuery).trim().length > 1) return
|
||||
|
||||
let mounted = true
|
||||
;(async () => {
|
||||
try {
|
||||
@ -147,6 +153,31 @@ export default function ResultadosClient() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [especialidadeHero])
|
||||
|
||||
// Debounced search by doctor name. When searchQuery is non-empty (>=2 chars), call buscarMedicos
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
const term = String(searchQuery || '').trim()
|
||||
const handle = setTimeout(async () => {
|
||||
if (!mounted) return
|
||||
// if no meaningful search, do nothing (the specialidade effect will run)
|
||||
if (!term || term.length < 2) return
|
||||
try {
|
||||
setLoadingMedicos(true)
|
||||
setMedicos([])
|
||||
setAgendaByDoctor({})
|
||||
setAgendasExpandida({})
|
||||
const list = await buscarMedicos(term).catch(() => [])
|
||||
if (!mounted) return
|
||||
setMedicos(Array.isArray(list) ? list : [])
|
||||
} catch (e: any) {
|
||||
showToast('error', e?.message || 'Falha ao buscar profissionais')
|
||||
} finally {
|
||||
if (mounted) setLoadingMedicos(false)
|
||||
}
|
||||
}, 350)
|
||||
return () => { mounted = false; clearTimeout(handle) }
|
||||
}, [searchQuery])
|
||||
|
||||
// 3) Carregar horários disponíveis para um médico (próximos 7 dias) e agrupar por dia
|
||||
async function loadAgenda(doctorId: string) {
|
||||
if (!doctorId) return
|
||||
@ -237,7 +268,26 @@ export default function ResultadosClient() {
|
||||
}
|
||||
|
||||
// Open confirmation dialog for a selected slot instead of immediately booking
|
||||
function openConfirmDialog(doctorId: string, iso: string) {
|
||||
async function openConfirmDialog(doctorId: string, iso: string) {
|
||||
// Pre-check: ensure there is no existing appointment for this doctor at this exact datetime
|
||||
try {
|
||||
// build query: exact match on doctor_id and scheduled_at
|
||||
const params = new URLSearchParams();
|
||||
params.set('doctor_id', `eq.${String(doctorId)}`);
|
||||
params.set('scheduled_at', `eq.${String(iso)}`);
|
||||
params.set('limit', '1');
|
||||
const existing = await listarAgendamentos(params.toString()).catch(() => [])
|
||||
if (existing && (existing as any).length) {
|
||||
showToast('error', 'Não é possível agendar: já existe uma consulta neste horário para o profissional selecionado.')
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
// If checking fails (auth or network), surface a friendly error and avoid opening the dialog to prevent accidental duplicates.
|
||||
console.warn('[ResultadosClient] falha ao checar conflitos de agendamento', err)
|
||||
showToast('error', 'Não foi possível verificar disponibilidade. Tente novamente em instantes.')
|
||||
return
|
||||
}
|
||||
|
||||
setPendingAppointment({ doctorId, iso })
|
||||
setConfirmOpen(true)
|
||||
}
|
||||
@ -255,6 +305,24 @@ export default function ResultadosClient() {
|
||||
showToast('success', 'Iniciando agendamento...')
|
||||
setConfirmLoading(true)
|
||||
try {
|
||||
// Final conflict check to avoid race conditions: query appointments for same doctor + scheduled_at
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('doctor_id', `eq.${String(doctorId)}`);
|
||||
params.set('scheduled_at', `eq.${String(iso)}`);
|
||||
params.set('limit', '1');
|
||||
const existing = await listarAgendamentos(params.toString()).catch(() => [])
|
||||
if (existing && (existing as any).length) {
|
||||
showToast('error', 'Não é possível agendar: já existe uma consulta neste horário para o profissional selecionado.')
|
||||
setConfirmLoading(false)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[ResultadosClient] falha ao checar conflito antes de criar agendamento', err)
|
||||
showToast('error', 'Falha ao verificar conflito de agendamento. Tente novamente.')
|
||||
setConfirmLoading(false)
|
||||
return
|
||||
}
|
||||
// Use direct POST to ensure creation even if availability checks would block
|
||||
await criarAgendamentoDireto({
|
||||
patient_id: String(patientId),
|
||||
@ -505,6 +573,20 @@ export default function ResultadosClient() {
|
||||
})
|
||||
}, [medicos, convenio, bairro])
|
||||
|
||||
// Paginação local para a lista de médicos
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [itemsPerPage, setItemsPerPage] = useState(5)
|
||||
|
||||
// Resetar para página 1 quando o conjunto de profissionais (filtro) ou itemsPerPage mudar
|
||||
useEffect(() => {
|
||||
setCurrentPage(1)
|
||||
}, [profissionais, itemsPerPage])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((profissionais || []).length / itemsPerPage))
|
||||
const paginatedProfissionais = (profissionais || []).slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
|
||||
const startItem = (profissionais || []).length ? (currentPage - 1) * itemsPerPage + 1 : 0
|
||||
const endItem = Math.min(currentPage * itemsPerPage, (profissionais || []).length)
|
||||
|
||||
// Render
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@ -642,13 +724,47 @@ export default function ResultadosClient() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-full border border-primary/40 bg-primary/10 text-primary hover:!bg-primary hover:!text-white transition-colors"
|
||||
>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Mais filtros
|
||||
</Button>
|
||||
{/* Search input para buscar médico por nome */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Buscar médico por nome"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="min-w-[220px] rounded-full"
|
||||
/>
|
||||
{searchQuery ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-10"
|
||||
onClick={async () => {
|
||||
// limpar o termo de busca e restaurar a lista por especialidade
|
||||
setSearchQuery('')
|
||||
setCurrentPage(1)
|
||||
try {
|
||||
setLoadingMedicos(true)
|
||||
setMedicos([])
|
||||
setAgendaByDoctor({})
|
||||
setAgendasExpandida({})
|
||||
const termo = (especialidadeHero && especialidadeHero !== 'Veja mais') ? especialidadeHero : (params?.get('q') || 'medico')
|
||||
const list = await buscarMedicos(termo).catch(() => [])
|
||||
setMedicos(Array.isArray(list) ? list : [])
|
||||
} catch (e: any) {
|
||||
showToast('error', e?.message || 'Falha ao buscar profissionais')
|
||||
} finally {
|
||||
setLoadingMedicos(false)
|
||||
}
|
||||
}}
|
||||
>Limpar</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-full border border-primary/40 bg-primary/10 text-primary hover:!bg-primary hover:!text-white transition-colors"
|
||||
>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Mais filtros
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -668,7 +784,7 @@ export default function ResultadosClient() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loadingMedicos && profissionais.map((medico) => {
|
||||
{!loadingMedicos && paginatedProfissionais.map((medico) => {
|
||||
const id = String(medico.id)
|
||||
const agenda = agendaByDoctor[id]
|
||||
const isLoadingAgenda = !!agendaLoading[id]
|
||||
@ -859,6 +975,29 @@ export default function ResultadosClient() {
|
||||
Nenhum profissional encontrado. Ajuste os filtros para ver outras opções.
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pagination controls */}
|
||||
{!loadingMedicos && profissionais.length > 0 && (
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span>Itens por página:</span>
|
||||
<select value={itemsPerPage} onChange={(e) => setItemsPerPage(Number(e.target.value))} className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary cursor-pointer">
|
||||
<option value={5}>5</option>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
</select>
|
||||
<span>Mostrando {startItem} a {endItem} de {profissionais.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="hover:!bg-primary hover:!text-white">Primeira</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="hover:!bg-primary hover:!text-white">Anterior</Button>
|
||||
<span className="text-sm text-muted-foreground">Página {currentPage} de {totalPages}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="hover:!bg-primary hover:!text-white">Próxima</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="hover:!bg-primary hover:!text-white">Última</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Dialog de perfil completo (mantido e adaptado) */}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user