From 076ec25fd4598dde3010d14d3d03a874c2db1df6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Gustavo?= <166467972+JoaoGustavo-dev@users.noreply.github.com> Date: Fri, 31 Oct 2025 17:51:13 -0300 Subject: [PATCH] fix-patient-and-report-page --- .../dashboard/relatorios/page.tsx | 414 +++++++++++++++--- susconecta/app/paciente/page.tsx | 233 +++++++++- .../app/resultados/ResultadosClient.tsx | 157 ++++++- 3 files changed, 712 insertions(+), 92 deletions(-) diff --git a/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx b/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx index 126fc8e..4584c86 100644 --- a/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx +++ b/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx @@ -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: }, { label: "Absenteísmo", value: "7,2%", icon: }, - { label: "Satisfação", value: "92%", icon: }, + { label: "Satisfação", value: "Dados não foram disponibilizados", icon: }, { label: "Faturamento (Mês)", value: "R$ 45.000", icon: }, { label: "No-show", value: "5,1%", icon: }, ]; @@ -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>([]); + const [consultasData, setConsultasData] = useState>([]); + const [faturamentoData, setFaturamentoData] = useState>([]); + const [taxaNoShowState, setTaxaNoShowState] = useState>([]); + const [pacientesTop, setPacientesTop] = useState>([]); + const [medicosTop, setMedicosTop] = useState(medicosMaisProdutivos); + const [medicosPerformance, setMedicosPerformance] = useState>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [conveniosData, setConveniosData] = useState>(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: }, + { label: "Absenteísmo", value: "—", icon: }, + { label: "Satisfação", value: "Dados não foram disponibilizados", icon: }, + { label: "Faturamento (Mês)", value: "—", icon: }, + { label: "No-show", value: "—", icon: }, + ]); + + // 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 = {}; + 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 = {}; + 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(); + 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 = {}; + const doctorCounts: Record = {}; + const doctorNoShowCounts: Record = {}; + 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 = {}; + 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: }, + { label: "Absenteísmo", value: '—', icon: }, + { label: "Satisfação", value: 'Dados não foram disponibilizados', icon: }, + { label: "Faturamento (Mês)", value: `R$ ${faturamentoArr[faturamentoArr.length - 1]?.valor ?? 0}`, icon: }, + { label: "No-show", value: `${taxaArr[taxaArr.length - 1]?.noShow ?? 0}%`, icon: }, + ] 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 (

Dashboard Executivo de Relatórios

{/* Métricas principais */}
- {metricas.map((m) => ( -
- {m.icon} - {m.value} - {m.label} -
- ))} + {loading ? ( + // simple skeletons while loading to avoid showing fake data + Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+ )) + ) : ( + metricsState.map((m) => ( +
+ {m.icon} + {m.value} + {m.label} +
+ )) + )}
{/* Gráficos e Relatórios */} @@ -104,15 +352,19 @@ export default function RelatoriosPage() {

Consultas por Período

- - - - - - - - - + {loading ? ( +
Carregando dados...
+ ) : ( + + + + + + + + + + )}
{/* Faturamento mensal/anual */} @@ -121,15 +373,19 @@ export default function RelatoriosPage() {

Faturamento Mensal

- - - - - - - - - + {loading ? ( +
Carregando dados...
+ ) : ( + + + + + + + + + + )}
@@ -140,15 +396,19 @@ export default function RelatoriosPage() {

Taxa de No-show

- - - - - - - - - + {loading ? ( +
Carregando dados...
+ ) : ( + + + + + + + + + + )} {/* Indicadores de satisfação */} @@ -158,7 +418,7 @@ export default function RelatoriosPage() {
- 92% + Dados não foram disponibilizados Índice de satisfação geral
@@ -179,12 +439,22 @@ export default function RelatoriosPage() { - {pacientesMaisAtendidos.map((p) => ( - - {p.nome} - {p.consultas} + {loading ? ( + + Carregando pacientes... - ))} + ) : pacientesTop && pacientesTop.length ? ( + pacientesTop.map((p: { nome: string; consultas: number }) => ( + + {p.nome} + {p.consultas} + + )) + ) : ( + + Nenhum paciente encontrado + + )} @@ -203,12 +473,22 @@ export default function RelatoriosPage() { - {medicosMaisProdutivos.map((m) => ( - - {m.nome} - {m.consultas} + {loading ? ( + + Carregando médicos... - ))} + ) : medicosTop && medicosTop.length ? ( + medicosTop.map((m) => ( + + {m.nome} + {m.consultas} + + )) + ) : ( + + Nenhum médico encontrado + + )} @@ -221,17 +501,21 @@ export default function RelatoriosPage() {

Análise de Convênios

- - - - {convenios.map((entry, index) => ( - - ))} - - - - - + {loading ? ( +
Carregando dados...
+ ) : ( + + + + {conveniosData.map((entry, index) => ( + + ))} + + + + + + )} {/* Performance por médico */} @@ -249,7 +533,7 @@ export default function RelatoriosPage() { - {performancePorMedico.map((m) => ( + {(loading ? performancePorMedico : medicosPerformance).map((m) => ( {m.nome} {m.consultas} diff --git a/susconecta/app/paciente/page.tsx b/susconecta/app/paciente/page.tsx index 5e427d1..fd7d9bc 100644 --- a/susconecta/app/paciente/page.tsx +++ b/susconecta/app/paciente/page.tsx @@ -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 = {} + 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 = { 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} - {loading ? '—' : (nextAppt ?? '-')} + {loading ? strings.carregando : (nextAppt ?? '-')} @@ -367,7 +441,7 @@ export default function PacientePage() { {strings.ultimosExames} - {loading ? '—' : (examsCount !== null ? String(examsCount) : '-')} + {loading ? strings.carregando : (examsCount !== null ? String(examsCount) : '-')} @@ -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 = {} + 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 = { 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() { - + diff --git a/susconecta/app/resultados/ResultadosClient.tsx b/susconecta/app/resultados/ResultadosClient.tsx index 8c6391d..0fe62dc 100644 --- a/susconecta/app/resultados/ResultadosClient.tsx +++ b/susconecta/app/resultados/ResultadosClient.tsx @@ -31,6 +31,7 @@ import { getAvailableSlots, criarAgendamento, criarAgendamentoDireto, + listarAgendamentos, getUserInfo, buscarPacientes, listarDisponibilidades, @@ -61,6 +62,8 @@ export default function ResultadosClient() { const [especialidadeHero, setEspecialidadeHero] = useState(params?.get('especialidade') || 'Psicólogo') const [convenio, setConvenio] = useState('Todos') const [bairro, setBairro] = useState('Todos') + // Busca por nome do médico + const [searchQuery, setSearchQuery] = useState('') // Estado dinâmico const [patientId, setPatientId] = useState(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 (
@@ -642,13 +724,47 @@ export default function ResultadosClient() { - + {/* Search input para buscar médico por nome */} +
+ setSearchQuery(e.target.value)} + className="min-w-[220px] rounded-full" + /> + {searchQuery ? ( + + ) : ( + + )} +
+ + Página {currentPage} de {totalPages} + + +
+ + )} {/* Dialog de perfil completo (mantido e adaptado) */}