diff --git a/susconecta/app/paciente/resultados/ResultadosClient.tsx b/susconecta/app/paciente/resultados/ResultadosClient.tsx index f121bbd..35aa356 100644 --- a/susconecta/app/paciente/resultados/ResultadosClient.tsx +++ b/susconecta/app/paciente/resultados/ResultadosClient.tsx @@ -1,1260 +1,1226 @@ -"use client" - -import React, { useEffect, useMemo, useState } from 'react' -import { Input } from '@/components/ui/input' -import { Textarea } from '@/components/ui/textarea' -import { useSearchParams, useRouter } from 'next/navigation' -import { Button } from '@/components/ui/button' -import { Card } from '@/components/ui/card' -import { Toggle } from '@/components/ui/toggle' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Badge } from '@/components/ui/badge' -import { Avatar, AvatarFallback } from '@/components/ui/avatar' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { - Building2, - Filter, - Globe, - HeartPulse, - Languages, - MapPin, - ShieldCheck, - Star, - Stethoscope, - ChevronRight, - UserRound -} from 'lucide-react' -import { cn } from '@/lib/utils' -import { - buscarMedicos, - getAvailableSlots, - criarAgendamento, - criarAgendamentoDireto, - listarAgendamentos, - getUserInfo, - buscarPacientes, - listarDisponibilidades, - listarExcecoes, - type Medico, -} from '@/lib/api' - -// ...existing code (tipagens locais de UI)... -type TipoConsulta = 'teleconsulta' | 'local' - -// Utilidades de formatação/agenda -const shortWeek = ['DOM.', 'SEG.', 'TER.', 'QUA.', 'QUI.', 'SEX.', 'SÁB.'] -const monthPt = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez'] -const fmtDay = (d: Date) => `${d.getDate()} ${monthPt[d.getMonth()]}` - -type DayAgenda = { label: string; data: string; dateKey: string; horarios: Array<{ iso: string; label: string }> } - -const especialidadesHero = ['Psicólogo', 'Médico clínico geral', 'Pediatra', 'Dentista', 'Ginecologista', 'Veja mais'] - -export default function ResultadosClient() { - const params = useSearchParams() - const router = useRouter() - - // Filtros/controles da UI - initialize with defaults to avoid hydration mismatch - const [tipoConsulta, setTipoConsulta] = useState('teleconsulta') - const [especialidadeHero, setEspecialidadeHero] = useState('Psicólogo') - const [convenio, setConvenio] = useState('Todos') - const [bairro, setBairro] = useState('Todos') - // Busca por nome do médico - const [searchQuery, setSearchQuery] = useState('') - // Filtro de médico específico vindo da URL (quando clicado no dashboard) - const [medicoFiltro, setMedicoFiltro] = useState(null) - - // Track if URL params have been synced to avoid race condition - const [paramsSync, setParamsSync] = useState(false) - - // Estado dinâmico - const [patientId, setPatientId] = useState(null) - const [medicos, setMedicos] = useState([]) - const [loadingMedicos, setLoadingMedicos] = useState(false) - - // agenda por médico e loading por médico - const [agendaByDoctor, setAgendaByDoctor] = useState>({}) - const [agendaLoading, setAgendaLoading] = useState>({}) - const [agendasExpandida, setAgendasExpandida] = useState>({}) - const [nearestSlotByDoctor, setNearestSlotByDoctor] = useState>({}) - - // "Mostrar mais horários" modal state - const [moreTimesForDoctor, setMoreTimesForDoctor] = useState(null) - const [moreTimesDate, setMoreTimesDate] = useState(() => new Date().toISOString().slice(0,10)) - const [moreTimesLoading, setMoreTimesLoading] = useState(false) - const [moreTimesSlots, setMoreTimesSlots] = useState>([]) - const [moreTimesException, setMoreTimesException] = useState(null) - - // Seleção para o Dialog de perfil completo - const [medicoSelecionado, setMedicoSelecionado] = useState(null) - const [abaDetalhe, setAbaDetalhe] = useState('experiencia') - - // Confirmation dialog for booking: hold pending selection until user confirms - const [confirmOpen, setConfirmOpen] = useState(false) - const [pendingAppointment, setPendingAppointment] = useState<{ doctorId: string; iso: string } | null>(null) - const [confirmLoading, setConfirmLoading] = useState(false) - // Fields editable in the confirmation dialog to be sent to the create endpoint - const [confirmDuration, setConfirmDuration] = useState(30) - const [confirmInsurance, setConfirmInsurance] = useState('') - const [confirmChiefComplaint, setConfirmChiefComplaint] = useState('') - const [confirmPatientNotes, setConfirmPatientNotes] = useState('') - - // Toast simples - const [toast, setToast] = useState<{ type: 'success' | 'error', msg: string } | null>(null) - const showToast = (type: 'success' | 'error', msg: string) => { - setToast({ type, msg }) - setTimeout(() => setToast(null), 3000) - } - // booking success modal (used when origin=paciente) - const [bookingSuccessOpen, setBookingSuccessOpen] = useState(false) - const [bookedWhenLabel, setBookedWhenLabel] = useState(null) - - // 1) Sincronize URL params with state after client mount (prevent hydration mismatch) - useEffect(() => { - if (!params) return - const tipoParam = params.get('tipo') - if (tipoParam === 'presencial') setTipoConsulta('local') - - const especialidadeParam = params.get('especialidade') - if (especialidadeParam) setEspecialidadeHero(especialidadeParam) - - // Ler filtro de médico específico da URL - const medicoParam = params.get('medico') - if (medicoParam) setMedicoFiltro(medicoParam) - - // Mark params as synced - setParamsSync(true) - }, [params]) - - // 2) Fetch patient ID from auth - useEffect(() => { - let mounted = true - ;(async () => { - try { - const info = await getUserInfo().catch(() => null) - const uid = info?.user?.id ?? null - const email = info?.user?.email ?? null - if (!email) return - const results = await buscarPacientes(email).catch(() => []) - // preferir linha com user_id igual ao auth id - const row = (results || []).find((p: any) => String(p.user_id) === String(uid)) || results?.[0] - if (row && mounted) setPatientId(String(row.id)) - } catch { - // silencioso - } - })() - return () => { mounted = false } - }, []) - - // 3) Initial doctors fetch on mount (one-time initialization) - useEffect(() => { - let mounted = true - ;(async () => { - try { - setLoadingMedicos(true) - console.log('[ResultadosClient] Initial doctors fetch starting') - const list = await buscarMedicos('').catch((err) => { - console.error('[ResultadosClient] Initial fetch error:', err) - return [] - }) - if (!mounted) return - console.log('[ResultadosClient] Initial fetch completed, got:', list?.length || 0, 'doctors') - setMedicos(Array.isArray(list) ? list : []) - } finally { - if (mounted) setLoadingMedicos(false) - } - })() - return () => { mounted = false } - }, []) - - // 4) Re-fetch doctors when especialidade changes (after initial sync) - // SKIP this if medicoFiltro está definido (médico específico selecionado) - useEffect(() => { - // Skip if this is the initial render or if user is searching by name or if a specific doctor is selected - if (!paramsSync || medicoFiltro || (searchQuery && String(searchQuery).trim().length > 1)) return - - let mounted = true - ;(async () => { - try { - setLoadingMedicos(true) - setMedicos([]) - setAgendaByDoctor({}) - setAgendasExpandida({}) - // termo de busca: usar a especialidade escolhida - const termo = (especialidadeHero && especialidadeHero !== 'Veja mais') ? especialidadeHero : '' - console.log('[ResultadosClient] Fetching doctors with term:', termo) - const list = await buscarMedicos(termo).catch((err) => { - console.error('[ResultadosClient] buscarMedicos error:', err) - return [] - }) - if (!mounted) return - console.log('[ResultadosClient] Doctors fetched:', list?.length || 0) - setMedicos(Array.isArray(list) ? list : []) - } catch (e: any) { - showToast('error', e?.message || 'Falha ao buscar profissionais') - } finally { - if (mounted) setLoadingMedicos(false) - } - })() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [especialidadeHero, paramsSync, medicoFiltro]) - - // 5) Debounced search by doctor name - // SKIP this if medicoFiltro está definido - useEffect(() => { - if (medicoFiltro) return // Skip se médico específico foi selecionado - - 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, medicoFiltro]) - - // 5b) Quando um médico específico é selecionado, fazer uma busca por ele (PRIORIDADE MÁXIMA) - useEffect(() => { - if (!medicoFiltro || !paramsSync) return - - let mounted = true - ;(async () => { - try { - setLoadingMedicos(true) - // Resetar agenda e expandidas quando mudar o médico - setAgendaByDoctor({}) - setAgendasExpandida({}) - console.log('[ResultadosClient] Buscando médico específico:', medicoFiltro) - // Tentar buscar pelo nome do médico - const list = await buscarMedicos(medicoFiltro).catch(() => []) - if (!mounted) return - console.log('[ResultadosClient] Médicos encontrados:', list?.length || 0) - setMedicos(Array.isArray(list) ? list : []) - } catch (e: any) { - console.warn('[ResultadosClient] Erro ao buscar médico:', e) - showToast('error', e?.message || 'Falha ao buscar profissional') - } finally { - if (mounted) setLoadingMedicos(false) - } - })() - - return () => { mounted = false } - }, [medicoFiltro, paramsSync]) - - // 3) Carregar horários disponíveis para um médico (próximos 7 dias) e agrupar por dia - async function loadAgenda(doctorId: string): Promise<{ iso: string; label: string } | null> { - if (!doctorId) return null - if (agendaLoading[doctorId]) return null - setAgendaLoading((s) => ({ ...s, [doctorId]: true })) - try { - // janela de 7 dias - const start = new Date(); start.setHours(0,0,0,0) - const end = new Date(); end.setDate(end.getDate() + 7); end.setHours(23,59,59,999) - const res = await getAvailableSlots({ - doctor_id: doctorId, - start_date: start.toISOString(), - end_date: end.toISOString(), - appointment_type: tipoConsulta === 'local' ? 'presencial' : 'telemedicina', - }) - - // construir colunas: hoje, amanhã, +2 dias (4 colunas visíveis) - const days: DayAgenda[] = [] - for (let i = 0; i < 4; i++) { - const d = new Date(start); d.setDate(start.getDate() + i) - const dateKey = d.toISOString().split('T')[0] - const label = i === 0 ? 'HOJE' : i === 1 ? 'AMANHÃ' : shortWeek[d.getDay()] - days.push({ label, data: fmtDay(d), dateKey, horarios: [] }) - } - - const onlyAvail = (res?.slots || []).filter((s: any) => s.available) - const nowMs = Date.now() - for (const s of onlyAvail) { - const dt = new Date(s.datetime) - const dtMs = dt.getTime() - // Filtrar: só mostrar horários que são posteriores ao horário atual - if (dtMs < nowMs) continue - const key = dt.toISOString().split('T')[0] - const bucket = days.find(d => d.dateKey === key) - if (!bucket) continue - const label = dt.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) - bucket.horarios.push({ iso: s.datetime, label }) - } - - // ordenar horários em cada dia - for (const d of days) { - d.horarios.sort((a, b) => new Date(a.iso).getTime() - new Date(b.iso).getTime()) - } - - // compute nearest slot (earliest available in the returned window, but after now) - let nearest: { iso: string; label: string } | null = null - const allSlots = days.flatMap(d => d.horarios || []) - const futureSorted = allSlots - .map(s => ({ ...s, ms: new Date(s.iso).getTime() })) - .filter(s => s.ms >= nowMs) - .sort((a,b) => a.ms - b.ms) - if (futureSorted.length) { - const s = futureSorted[0] - nearest = { iso: s.iso, label: s.label } - } - - setAgendaByDoctor((prev) => ({ ...prev, [doctorId]: days })) - setNearestSlotByDoctor((prev) => ({ ...prev, [doctorId]: nearest })) - return nearest - } catch (e: any) { - showToast('error', e?.message || 'Falha ao buscar horários') - return null - } finally { - setAgendaLoading((s) => ({ ...s, [doctorId]: false })) - } - } - - // 4) Agendar ao clicar em um horário (performs the actual create call) - async function agendar(doctorId: string, iso: string) { - if (!patientId) { - showToast('error', 'Paciente não identificado. Faça login novamente.') - return - } - try { - await criarAgendamento({ - patient_id: String(patientId), - doctor_id: String(doctorId), - scheduled_at: String(iso), - duration_minutes: 30, - appointment_type: (tipoConsulta === 'local' ? 'presencial' : 'telemedicina'), - }) - showToast('success', 'Consulta agendada com sucesso!') - // remover horário da lista local - setAgendaByDoctor((prev) => { - const days = prev[doctorId] - if (!days) return prev - const updated = days.map(d => ({ ...d, horarios: d.horarios.filter(h => h.iso !== iso) })) - return { ...prev, [doctorId]: updated } - }) - } catch (e: any) { - showToast('error', e?.message || 'Falha ao agendar') - } - } - - // Open confirmation dialog for a selected slot instead of immediately booking - 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) - } - - // Called when the user confirms the booking in the dialog - async function confirmAndBook() { - if (!pendingAppointment) return - const { doctorId, iso } = pendingAppointment - if (!patientId) { - showToast('error', 'Paciente não identificado. Faça login novamente.') - return - } - // Debug: indicate the handler was invoked - console.debug('[ResultadosClient] confirmAndBook invoked', { doctorId, iso, patientId, confirmDuration, confirmInsurance }) - 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), - doctor_id: String(doctorId), - scheduled_at: String(iso), - duration_minutes: Number(confirmDuration) || 30, - appointment_type: (tipoConsulta === 'local' ? 'presencial' : 'telemedicina'), - chief_complaint: confirmChiefComplaint || null, - patient_notes: confirmPatientNotes || null, - insurance_provider: confirmInsurance || null, - }) - showToast('success', 'Consulta agendada com sucesso!') - // remover horário da lista local - setAgendaByDoctor((prev) => { - const days = prev[doctorId] - if (!days) return prev - const updated = days.map(d => ({ ...d, horarios: d.horarios.filter(h => h.iso !== iso) })) - return { ...prev, [doctorId]: updated } - }) - setConfirmOpen(false) - setPendingAppointment(null) - // If the user came from the paciente area, keep them here and show a success modal - const origin = params?.get('origin') - if (origin === 'paciente') { - try { - const when = new Date(iso).toLocaleString('pt-BR', { dateStyle: 'long', timeStyle: 'short' }) - setBookedWhenLabel(when) - } catch { - setBookedWhenLabel(iso) - } - setBookingSuccessOpen(true) - } else { - // Navigate to agenda after a short delay so user sees the toast - setTimeout(() => router.push('/agenda'), 500) - } - } catch (e: any) { - showToast('error', e?.message || 'Falha ao agendar') - } finally { - setConfirmLoading(false) - } - } - - // Fetch slots for an arbitrary date using the same logic as CalendarRegistrationForm - async function fetchSlotsForDate(doctorId: string, dateOnly: string) { - if (!doctorId || !dateOnly) return [] - setMoreTimesLoading(true) - setMoreTimesException(null) - try { - // Check for blocking exceptions (listarExcecoes can filter by date) - const exceptions = await listarExcecoes({ doctorId: String(doctorId), date: String(dateOnly) }).catch(() => []) - if (exceptions && exceptions.length) { - const blocking = (exceptions || []).find((e: any) => e && e.kind === 'bloqueio') - if (blocking) { - const reason = blocking.reason ? ` Motivo: ${blocking.reason}` : '' - setMoreTimesException(`Não é possível agendar nesta data.${reason}`) - setMoreTimesSlots([]) - return [] - } - } - - // Build local start/end for the day - let start: Date - let end: Date - try { - const parts = String(dateOnly).split('-').map(Number) - if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) { - const [y, m, d] = parts - start = new Date(y, m - 1, d, 0, 0, 0, 0) - end = new Date(y, m - 1, d, 23, 59, 59, 999) - } else { - start = new Date(dateOnly) - start.setHours(0,0,0,0) - end = new Date(dateOnly) - end.setHours(23,59,59,999) - } - } catch (err) { - start = new Date(dateOnly) - start.setHours(0,0,0,0) - end = new Date(dateOnly) - end.setHours(23,59,59,999) - } - - const av = await getAvailableSlots({ - doctor_id: String(doctorId), - start_date: start.toISOString(), - end_date: end.toISOString(), - appointment_type: tipoConsulta === 'local' ? 'presencial' : 'telemedicina', - }) - - // Try to restrict to public availability windows and synthesize missing slots - try { - const disponibilidades = await listarDisponibilidades({ doctorId: String(doctorId) }).catch(() => []) - const weekdayNumber = start.getDay() - const weekdayNames: Record = { - 0: ['0','sun','sunday','domingo'], - 1: ['1','mon','monday','segunda','segunda-feira'], - 2: ['2','tue','tuesday','terca','terça','terça-feira'], - 3: ['3','wed','wednesday','quarta','quarta-feira'], - 4: ['4','thu','thursday','quinta','quinta-feira'], - 5: ['5','fri','friday','sexta','sexta-feira'], - 6: ['6','sat','saturday','sabado','sábado'] - } - const allowed = new Set((weekdayNames[weekdayNumber] || []).map(s => String(s).toLowerCase())) - const matched = (disponibilidades || []).filter((d: any) => { - try { - const raw = String(d.weekday ?? d.weekday_name ?? d.day ?? d.day_of_week ?? '').toLowerCase() - if (!raw) return false - if (allowed.has(raw)) return true - if (typeof d.weekday === 'number' && d.weekday === weekdayNumber) return true - if (typeof d.day_of_week === 'number' && d.day_of_week === weekdayNumber) return true - return false - } catch (e) { return false } - }) - - if (matched && matched.length) { - const windows = matched.map((d: any) => { - const parseTime = (t?: string) => { - if (!t) return { hh: 0, mm: 0, ss: 0 } - const parts = String(t).split(':').map(Number) - return { hh: parts[0] || 0, mm: parts[1] || 0, ss: parts[2] || 0 } - } - const s = parseTime(d.start_time) - const e2 = parseTime(d.end_time) - const winStart = new Date(start.getFullYear(), start.getMonth(), start.getDate(), s.hh, s.mm, s.ss || 0, 0) - const winEnd = new Date(start.getFullYear(), start.getMonth(), start.getDate(), e2.hh, e2.mm, e2.ss || 0, 999) - const slotMinutes = (() => { const n = Number(d.slot_minutes ?? d.slot_minutes_minutes ?? NaN); return Number.isFinite(n) ? n : undefined })() - return { winStart, winEnd, slotMinutes } - }) - - // compute step based on backend slot diffs - let stepMinutes = 30 - try { - const times = (av.slots || []).map((s: any) => new Date(s.datetime).getTime()).sort((a:number,b:number)=>a-b) - const diffs: number[] = [] - for (let i = 1; i < times.length; i++) { - const d = Math.round((times[i] - times[i-1]) / 60000) - if (d > 0) diffs.push(d) - } - if (diffs.length) stepMinutes = Math.min(...diffs) - } catch(e) {} - - const generatedSet = new Set() - windows.forEach((w:any) => { - try { - const perWindowStep = Number(w.slotMinutes) || stepMinutes - const startMs = w.winStart.getTime() - const endMs = w.winEnd.getTime() - const lastStartMs = endMs - perWindowStep * 60000 - const backendSlotsInWindow = (av.slots || []).filter((s:any) => { - try { - const sd = new Date(s.datetime) - const sm = sd.getHours() * 60 + sd.getMinutes() - const wmStart = w.winStart.getHours() * 60 + w.winStart.getMinutes() - const wmEnd = w.winEnd.getHours() * 60 + w.winEnd.getMinutes() - return sm >= wmStart && sm <= wmEnd - } catch(e) { return false } - }).map((s:any) => new Date(s.datetime).getTime()).sort((a:number,b:number)=>a-b) - - if (!backendSlotsInWindow.length) { - let cursorMs = startMs - while (cursorMs <= lastStartMs) { - generatedSet.add(new Date(cursorMs).toISOString()) - cursorMs += perWindowStep * 60000 - } - } else { - const lastBackendMs = backendSlotsInWindow.at(-1) - let cursorMs = (lastBackendMs ?? 0) + perWindowStep * 60000 - while (cursorMs <= lastStartMs) { - generatedSet.add(new Date(cursorMs).toISOString()) - cursorMs += perWindowStep * 60000 - } - } - } catch(e) {} - }) - - const mergedMap = new Map() - const findWindowSlotMinutes = (isoDt: string) => { - try { - const sd = new Date(isoDt) - const sm = sd.getHours() * 60 + sd.getMinutes() - const w = windows.find((win:any) => { - const ws = win.winStart - const we = win.winEnd - const winStartMinutes = ws.getHours() * 60 + ws.getMinutes() - const winEndMinutes = we.getHours() * 60 + we.getMinutes() - return sm >= winStartMinutes && sm <= winEndMinutes - }) - return w && w.slotMinutes ? Number(w.slotMinutes) : null - } catch(e) { return null } - } - - const existingInWindow: any[] = (av.slots || []).filter((s:any) => { - try { - const sd = new Date(s.datetime) - const slotMinutes = sd.getHours() * 60 + sd.getMinutes() - return windows.some((w:any) => { - const ws = w.winStart - const we = w.winEnd - const winStartMinutes = ws.getHours() * 60 + ws.getMinutes() - const winEndMinutes = we.getHours() * 60 + we.getMinutes() - return slotMinutes >= winStartMinutes && slotMinutes <= winEndMinutes - }) - } catch(e) { return false } - }) - - for (const s of (existingInWindow || [])) { - const sm = findWindowSlotMinutes(s.datetime) - mergedMap.set(s.datetime, sm ? { ...s, slot_minutes: sm } : { ...s }) - } - Array.from(generatedSet).forEach((dt) => { - if (!mergedMap.has(dt)) { - const sm = findWindowSlotMinutes(dt) || stepMinutes - mergedMap.set(dt, { datetime: dt, available: true, slot_minutes: sm }) - } - }) - - const merged = Array.from(mergedMap.values()).sort((a:any,b:any) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime()) - const nowMs = Date.now() - // Filtrar: só mostrar horários que são posteriores ao horário atual - const futureOnly = merged.filter((s: any) => new Date(s.datetime).getTime() >= nowMs) - const formatted = (futureOnly || []).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) })) - setMoreTimesSlots(formatted) - return formatted - } else { - const nowMs = Date.now() - // Filtrar: só mostrar horários que são posteriores ao horário atual - const slots = (av.slots || []).filter((s:any) => new Date(s.datetime).getTime() >= nowMs).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) })) - setMoreTimesSlots(slots) - return slots - } - } catch (e) { - console.warn('[ResultadosClient] erro ao filtrar por disponibilidades', e) - const nowMs = Date.now() - // Filtrar: só mostrar horários que são posteriores ao horário atual - const slots = (av.slots || []).filter((s:any) => new Date(s.datetime).getTime() >= nowMs).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) })) - setMoreTimesSlots(slots) - return slots - } - } catch (e) { - console.warn('[ResultadosClient] falha ao carregar horários para data', e) - setMoreTimesSlots([]) - setMoreTimesException('Falha ao buscar horários para a data selecionada') - return [] - } finally { - setMoreTimesLoading(false) - } - } - - // Filtro visual (convenio/bairro são cosméticos; quando sem dado, mantemos tudo) - const profissionais = useMemo(() => { - let filtered = (medicos || []).filter((m: any) => { - if (convenio !== 'Todos' && m.convenios && !m.convenios.includes(convenio)) return false - if (bairro !== 'Todos' && m.neighborhood && String(m.neighborhood).toLowerCase() !== String(bairro).toLowerCase()) return false - return true - }) - - // Se um médico específico foi selecionado no dashboard, filtrar apenas por ele - if (medicoFiltro) { - filtered = filtered.filter((m: any) => { - // Comparar nome completo com flexibilidade - const nomeMedico = String(m.full_name || m.name || '').toLowerCase() - const filtro = String(medicoFiltro).toLowerCase() - return nomeMedico.includes(filtro) || filtro.includes(nomeMedico.split(' ')[0]) // comparar por primeiro nome também - }) - } - - return filtered - }, [medicos, convenio, bairro, medicoFiltro]) - - // 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 ( -
-
- {/* Toast */} - {toast && ( -
- {toast.msg} -
- )} - - {/* Confirmation dialog shown when a user selects a slot */} - { if (!open) { setConfirmOpen(false); setPendingAppointment(null); } }}> - - - Confirmar agendamento - -
- {pendingAppointment ? ( - (() => { - const doc = medicos.find(m => String(m.id) === String(pendingAppointment.doctorId)) - const doctorName = doc ? (doc.full_name || (doc as any).name || 'Profissional') : 'Profissional' - const when = (() => { - try { return new Date(pendingAppointment.iso).toLocaleString('pt-BR', { dateStyle: 'long', timeStyle: 'short' }) } catch { return pendingAppointment.iso } - })() - return ( -
-

Profissional: {doctorName}

-

Data / Hora: {when}

-

Paciente: Você

-
- ) - })() - ) : ( -

Carregando informações...

- )} -
-
- - -
-
-
- - {/* Booking success modal shown when origin=paciente */} - setBookingSuccessOpen(open)}> - - - Consulta agendada - -
-

Sua consulta foi agendada com sucesso{bookedWhenLabel ? ` para ${bookedWhenLabel}` : ''}.

-
-
- -
-
-
- - {/* Hero de filtros (mantido) */} -
-
-
-

Resultados da procura

-

Qual especialização você deseja?

-
- -
-
- {especialidadesHero.map(item => ( - - ))} -
-
- - {/* Barra de filtros secundários (agora fluída, sem sticky) */} -
-
- {/* Segmented control: tipo da consulta */} -
-
- setTipoConsulta('teleconsulta')} - className="flex-1 rounded-none first:rounded-l-full px-4 py-2.5 text-sm font-medium transition data-[state=on]:bg-primary data-[state=on]:text-primary-foreground hover:bg-primary/10" - > - - Teleconsulta - - setTipoConsulta('local')} - className="flex-1 rounded-none last:rounded-r-full px-4 py-2.5 text-sm font-medium transition data-[state=on]:bg-primary data-[state=on]:text-primary-foreground hover:bg-primary/10" - > - - Consulta no local - -
-
- - {/* divider visual */} -
- - {/* Convênio */} -
- -
- - {/* Busca por nome + Mais filtros/Limpar */} -
-
- ) => setSearchQuery(e.target.value)} - className="w-full sm:min-w-[220px] rounded-full" - /> - {searchQuery ? ( - - ) : ( - - )} -
-
- - {/* Bairro */} -
- -
- - {/* Voltar */} -
- -
-
-
- - {/* Lista de profissionais */} -
- {loadingMedicos && ( - - Buscando profissionais... - - )} - - {!loadingMedicos && paginatedProfissionais.map((medico) => { - const id = String(medico.id) - const agenda = agendaByDoctor[id] - const isLoadingAgenda = !!agendaLoading[id] - const atendeLocal = true // dados ausentes → manter visual - const atendeTele = true - const nome = medico.full_name || 'Profissional' - const esp = (medico as any).specialty || medico.especialidade || '—' - const crm = [medico.crm, (medico as any).crm_uf].filter(Boolean).join(' / ') - const convenios = '—' - const endereco = [medico.street, medico.number].filter(Boolean).join(', ') || medico.street || '—' - const cidade = [medico.city, medico.state].filter(Boolean).join(' • ') - const precoLocal = '—' - const precoTeleconsulta = '—' - - return ( - -
- - - - - -
-
-

{nome}

- {esp} -
-
- - - {/* sem avaliação → travar layout */} - {'4.9'} • {'23'} avaliações - - {crm || '—'} - {convenios} -
-
- -
- - {tipoConsulta === 'local' && atendeLocal && ( -
- - - {endereco} - -
- {cidade || '—'} - {precoLocal} -
-
- )} - - {tipoConsulta === 'teleconsulta' && atendeTele && ( -
- - - Teleconsulta - - {precoTeleconsulta} -
- )} - -
- - - Idiomas: Português, Inglês - - - - Acolhimento em cada consulta - - - - Pagamento seguro - - - - Especialista recomendado - -
- - {/* Quick action: nearest available slot */} - {nearestSlotByDoctor[id] && ( -
- Próximo horário: - -
- )} - -
- - - -
- - {/* Horários compactos removidos conforme solicitação do design (colunas HOJE/AMANHÃ/etc.). */} -
- ) - })} - - {!loadingMedicos && !profissionais.length && ( - - Nenhum profissional encontrado. Ajuste os filtros para ver outras opções. - - )} - - {/* Pagination controls */} - {!loadingMedicos && profissionais.length > 0 && ( -
-
- Itens por página: - - Mostrando {startItem} a {endItem} de {profissionais.length} -
- -
- - - Página {currentPage} de {totalPages} - - -
-
- )} -
- - {/* Dialog de perfil completo (mantido e adaptado) */} - !open && setMedicoSelecionado(null)}> - - {medicoSelecionado && ( - <> - - - {medicoSelecionado.full_name || 'Profissional'} - -

- {((medicoSelecionado as any).specialty || medicoSelecionado.especialidade || '—')} - { ' • ' } - {[medicoSelecionado.crm, (medicoSelecionado as any).crm_uf].filter(Boolean).join(' / ') || '—'} -

-
- -
-
- - - 4.9 (23 avaliações) - - Planos de saúde: — -
- - - - - Experiência - - - Planos de saúde - - - Consultórios - - - Serviços - - - Opiniões (0) - - loadAgenda(String(medicoSelecionado.id))}> - Agenda - - - - -

Informações fornecidas pelo profissional.

-
- - - - - - -
-

Atendimento por teleconsulta ou endereço informado no card.

-
-
- - -
- Consulta - -
-
- - -
-

Nenhuma opinião disponível.

-
-
- - -

- Escolha o melhor horário disponível para sua consulta. -

-
-
- {(agendaByDoctor[String(medicoSelecionado.id)] || []).map((col, idx) => ( -
-

{col.label}

-

{col.data}

-
- {col.horarios.length ? ( - col.horarios.map(h => ( - - )) - ) : ( - - Sem horários - - )} -
-
- ))} - {!(agendaByDoctor[String(medicoSelecionado.id)] || []).length && ( -
Carregando horários...
- )} -
-
-
-
-
- - )} -
-
- - {/* Dialog: Mostrar mais horários */} - { if (!open) { setMoreTimesForDoctor(null); setMoreTimesSlots([]); setMoreTimesException(null); } }}> - - - Mais horários - - -
- setMoreTimesDate(e.target.value)} /> - -
- -
- {moreTimesLoading ? ( -
Carregando horários...
- ) : moreTimesException ? ( -
{moreTimesException}
- ) : (moreTimesSlots.length ? ( -
- {moreTimesSlots.map(s => ( - - ))} -
- ) : ( -
Sem horários para a data selecionada.
- ))} -
-
-
-
-
- ) -} +"use client" + +import React, { useEffect, useMemo, useState } from 'react' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { useSearchParams, useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Toggle } from '@/components/ui/toggle' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Badge } from '@/components/ui/badge' +import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + Building2, + Filter, + Globe, + MapPin, + Star, + ChevronRight, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { + buscarMedicos, + getAvailableSlots, + criarAgendamento, + criarAgendamentoDireto, + listarAgendamentos, + getUserInfo, + buscarPacientes, + listarDisponibilidades, + listarExcecoes, + type Medico, +} from '@/lib/api' + +// ...existing code (tipagens locais de UI)... +type TipoConsulta = 'teleconsulta' | 'local' + +// Utilidades de formatação/agenda +const shortWeek = ['DOM.', 'SEG.', 'TER.', 'QUA.', 'QUI.', 'SEX.', 'SÁB.'] +const monthPt = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez'] +const fmtDay = (d: Date) => `${d.getDate()} ${monthPt[d.getMonth()]}` + +type DayAgenda = { label: string; data: string; dateKey: string; horarios: Array<{ iso: string; label: string }> } + +const especialidadesHero = ['Psicólogo', 'Médico clínico geral', 'Pediatra', 'Dentista', 'Ginecologista', 'Veja mais'] + +export default function ResultadosClient() { + const params = useSearchParams() + const router = useRouter() + + // Filtros/controles da UI - initialize with defaults to avoid hydration mismatch + const [tipoConsulta, setTipoConsulta] = useState('teleconsulta') + const [especialidadeHero, setEspecialidadeHero] = useState('Psicólogo') + const [convenio, setConvenio] = useState('Todos') + const [bairro, setBairro] = useState('Todos') + // Busca por nome do médico + const [searchQuery, setSearchQuery] = useState('') + // Filtro de médico específico vindo da URL (quando clicado no dashboard) + const [medicoFiltro, setMedicoFiltro] = useState(null) + + // Track if URL params have been synced to avoid race condition + const [paramsSync, setParamsSync] = useState(false) + + // Estado dinâmico + const [patientId, setPatientId] = useState(null) + const [medicos, setMedicos] = useState([]) + const [loadingMedicos, setLoadingMedicos] = useState(false) + + // agenda por médico e loading por médico + const [agendaByDoctor, setAgendaByDoctor] = useState>({}) + const [agendaLoading, setAgendaLoading] = useState>({}) + const [agendasExpandida, setAgendasExpandida] = useState>({}) + const [nearestSlotByDoctor, setNearestSlotByDoctor] = useState>({}) + + // "Mostrar mais horários" modal state + const [moreTimesForDoctor, setMoreTimesForDoctor] = useState(null) + const [moreTimesDate, setMoreTimesDate] = useState(() => new Date().toISOString().slice(0,10)) + const [moreTimesLoading, setMoreTimesLoading] = useState(false) + const [moreTimesSlots, setMoreTimesSlots] = useState>([]) + const [moreTimesException, setMoreTimesException] = useState(null) + + // Seleção para o Dialog de perfil completo + const [medicoSelecionado, setMedicoSelecionado] = useState(null) + const [abaDetalhe, setAbaDetalhe] = useState('experiencia') + + // Confirmation dialog for booking: hold pending selection until user confirms + const [confirmOpen, setConfirmOpen] = useState(false) + const [pendingAppointment, setPendingAppointment] = useState<{ doctorId: string; iso: string } | null>(null) + const [confirmLoading, setConfirmLoading] = useState(false) + // Fields editable in the confirmation dialog to be sent to the create endpoint + const [confirmDuration, setConfirmDuration] = useState(30) + const [confirmInsurance, setConfirmInsurance] = useState('') + const [confirmChiefComplaint, setConfirmChiefComplaint] = useState('') + const [confirmPatientNotes, setConfirmPatientNotes] = useState('') + + // Toast simples + const [toast, setToast] = useState<{ type: 'success' | 'error', msg: string } | null>(null) + const showToast = (type: 'success' | 'error', msg: string) => { + setToast({ type, msg }) + setTimeout(() => setToast(null), 3000) + } + // booking success modal (used when origin=paciente) + const [bookingSuccessOpen, setBookingSuccessOpen] = useState(false) + const [bookedWhenLabel, setBookedWhenLabel] = useState(null) + + // 1) Sincronize URL params with state after client mount (prevent hydration mismatch) + useEffect(() => { + if (!params) return + const tipoParam = params.get('tipo') + if (tipoParam === 'presencial') setTipoConsulta('local') + + const especialidadeParam = params.get('especialidade') + if (especialidadeParam) setEspecialidadeHero(especialidadeParam) + + // Ler filtro de médico específico da URL + const medicoParam = params.get('medico') + if (medicoParam) setMedicoFiltro(medicoParam) + + // Mark params as synced + setParamsSync(true) + }, [params]) + + // 2) Fetch patient ID from auth + useEffect(() => { + let mounted = true + ;(async () => { + try { + const info = await getUserInfo().catch(() => null) + const uid = info?.user?.id ?? null + const email = info?.user?.email ?? null + if (!email) return + const results = await buscarPacientes(email).catch(() => []) + // preferir linha com user_id igual ao auth id + const row = (results || []).find((p: any) => String(p.user_id) === String(uid)) || results?.[0] + if (row && mounted) setPatientId(String(row.id)) + } catch { + // silencioso + } + })() + return () => { mounted = false } + }, []) + + // 3) Initial doctors fetch on mount (one-time initialization) + useEffect(() => { + let mounted = true + ;(async () => { + try { + setLoadingMedicos(true) + console.log('[ResultadosClient] Initial doctors fetch starting') + const list = await buscarMedicos('').catch((err) => { + console.error('[ResultadosClient] Initial fetch error:', err) + return [] + }) + if (!mounted) return + console.log('[ResultadosClient] Initial fetch completed, got:', list?.length || 0, 'doctors') + setMedicos(Array.isArray(list) ? list : []) + } finally { + if (mounted) setLoadingMedicos(false) + } + })() + return () => { mounted = false } + }, []) + + // 4) Re-fetch doctors when especialidade changes (after initial sync) + // SKIP this if medicoFiltro está definido (médico específico selecionado) + useEffect(() => { + // Skip if this is the initial render or if user is searching by name or if a specific doctor is selected + if (!paramsSync || medicoFiltro || (searchQuery && String(searchQuery).trim().length > 1)) return + + let mounted = true + ;(async () => { + try { + setLoadingMedicos(true) + setMedicos([]) + setAgendaByDoctor({}) + setAgendasExpandida({}) + // termo de busca: usar a especialidade escolhida + const termo = (especialidadeHero && especialidadeHero !== 'Veja mais') ? especialidadeHero : '' + console.log('[ResultadosClient] Fetching doctors with term:', termo) + const list = await buscarMedicos(termo).catch((err) => { + console.error('[ResultadosClient] buscarMedicos error:', err) + return [] + }) + if (!mounted) return + console.log('[ResultadosClient] Doctors fetched:', list?.length || 0) + setMedicos(Array.isArray(list) ? list : []) + } catch (e: any) { + showToast('error', e?.message || 'Falha ao buscar profissionais') + } finally { + if (mounted) setLoadingMedicos(false) + } + })() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [especialidadeHero, paramsSync, medicoFiltro]) + + // 5) Debounced search by doctor name + // SKIP this if medicoFiltro está definido + useEffect(() => { + if (medicoFiltro) return // Skip se médico específico foi selecionado + + 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, medicoFiltro]) + + // 5b) Quando um médico específico é selecionado, fazer uma busca por ele (PRIORIDADE MÁXIMA) + useEffect(() => { + if (!medicoFiltro || !paramsSync) return + + let mounted = true + ;(async () => { + try { + setLoadingMedicos(true) + // Resetar agenda e expandidas quando mudar o médico + setAgendaByDoctor({}) + setAgendasExpandida({}) + console.log('[ResultadosClient] Buscando médico específico:', medicoFiltro) + // Tentar buscar pelo nome do médico + const list = await buscarMedicos(medicoFiltro).catch(() => []) + if (!mounted) return + console.log('[ResultadosClient] Médicos encontrados:', list?.length || 0) + setMedicos(Array.isArray(list) ? list : []) + } catch (e: any) { + console.warn('[ResultadosClient] Erro ao buscar médico:', e) + showToast('error', e?.message || 'Falha ao buscar profissional') + } finally { + if (mounted) setLoadingMedicos(false) + } + })() + + return () => { mounted = false } + }, [medicoFiltro, paramsSync]) + + // 3) Carregar horários disponíveis para um médico (próximos 7 dias) e agrupar por dia + async function loadAgenda(doctorId: string): Promise<{ iso: string; label: string } | null> { + if (!doctorId) return null + if (agendaLoading[doctorId]) return null + setAgendaLoading((s) => ({ ...s, [doctorId]: true })) + try { + // janela de 7 dias + const start = new Date(); start.setHours(0,0,0,0) + const end = new Date(); end.setDate(end.getDate() + 7); end.setHours(23,59,59,999) + const res = await getAvailableSlots({ + doctor_id: doctorId, + start_date: start.toISOString(), + end_date: end.toISOString(), + appointment_type: tipoConsulta === 'local' ? 'presencial' : 'telemedicina', + }) + + // construir colunas: hoje, amanhã, +2 dias (4 colunas visíveis) + const days: DayAgenda[] = [] + for (let i = 0; i < 4; i++) { + const d = new Date(start); d.setDate(start.getDate() + i) + const dateKey = d.toISOString().split('T')[0] + const label = i === 0 ? 'HOJE' : i === 1 ? 'AMANHÃ' : shortWeek[d.getDay()] + days.push({ label, data: fmtDay(d), dateKey, horarios: [] }) + } + + const onlyAvail = (res?.slots || []).filter((s: any) => s.available) + const nowMs = Date.now() + for (const s of onlyAvail) { + const dt = new Date(s.datetime) + const dtMs = dt.getTime() + // Filtrar: só mostrar horários que são posteriores ao horário atual + if (dtMs < nowMs) continue + const key = dt.toISOString().split('T')[0] + const bucket = days.find(d => d.dateKey === key) + if (!bucket) continue + const label = dt.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) + bucket.horarios.push({ iso: s.datetime, label }) + } + + // ordenar horários em cada dia + for (const d of days) { + d.horarios.sort((a, b) => new Date(a.iso).getTime() - new Date(b.iso).getTime()) + } + + // compute nearest slot (earliest available in the returned window, but after now) + let nearest: { iso: string; label: string } | null = null + const allSlots = days.flatMap(d => d.horarios || []) + const futureSorted = allSlots + .map(s => ({ ...s, ms: new Date(s.iso).getTime() })) + .filter(s => s.ms >= nowMs) + .sort((a,b) => a.ms - b.ms) + if (futureSorted.length) { + const s = futureSorted[0] + nearest = { iso: s.iso, label: s.label } + } + + setAgendaByDoctor((prev) => ({ ...prev, [doctorId]: days })) + setNearestSlotByDoctor((prev) => ({ ...prev, [doctorId]: nearest })) + return nearest + } catch (e: any) { + showToast('error', e?.message || 'Falha ao buscar horários') + return null + } finally { + setAgendaLoading((s) => ({ ...s, [doctorId]: false })) + } + } + + // 4) Agendar ao clicar em um horário (performs the actual create call) + async function agendar(doctorId: string, iso: string) { + if (!patientId) { + showToast('error', 'Paciente não identificado. Faça login novamente.') + return + } + try { + await criarAgendamento({ + patient_id: String(patientId), + doctor_id: String(doctorId), + scheduled_at: String(iso), + duration_minutes: 30, + appointment_type: (tipoConsulta === 'local' ? 'presencial' : 'telemedicina'), + }) + showToast('success', 'Consulta agendada com sucesso!') + // remover horário da lista local + setAgendaByDoctor((prev) => { + const days = prev[doctorId] + if (!days) return prev + const updated = days.map(d => ({ ...d, horarios: d.horarios.filter(h => h.iso !== iso) })) + return { ...prev, [doctorId]: updated } + }) + } catch (e: any) { + showToast('error', e?.message || 'Falha ao agendar') + } + } + + // Open confirmation dialog for a selected slot instead of immediately booking + 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) + } + + // Called when the user confirms the booking in the dialog + async function confirmAndBook() { + if (!pendingAppointment) return + const { doctorId, iso } = pendingAppointment + if (!patientId) { + showToast('error', 'Paciente não identificado. Faça login novamente.') + return + } + // Debug: indicate the handler was invoked + console.debug('[ResultadosClient] confirmAndBook invoked', { doctorId, iso, patientId, confirmDuration, confirmInsurance }) + 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), + doctor_id: String(doctorId), + scheduled_at: String(iso), + duration_minutes: Number(confirmDuration) || 30, + appointment_type: (tipoConsulta === 'local' ? 'presencial' : 'telemedicina'), + chief_complaint: confirmChiefComplaint || null, + patient_notes: confirmPatientNotes || null, + insurance_provider: confirmInsurance || null, + }) + showToast('success', 'Consulta agendada com sucesso!') + // remover horário da lista local + setAgendaByDoctor((prev) => { + const days = prev[doctorId] + if (!days) return prev + const updated = days.map(d => ({ ...d, horarios: d.horarios.filter(h => h.iso !== iso) })) + return { ...prev, [doctorId]: updated } + }) + setConfirmOpen(false) + setPendingAppointment(null) + // If the user came from the paciente area, keep them here and show a success modal + const origin = params?.get('origin') + if (origin === 'paciente') { + try { + const when = new Date(iso).toLocaleString('pt-BR', { dateStyle: 'long', timeStyle: 'short' }) + setBookedWhenLabel(when) + } catch { + setBookedWhenLabel(iso) + } + setBookingSuccessOpen(true) + } else { + // Navigate to agenda after a short delay so user sees the toast + setTimeout(() => router.push('/agenda'), 500) + } + } catch (e: any) { + showToast('error', e?.message || 'Falha ao agendar') + } finally { + setConfirmLoading(false) + } + } + + // Fetch slots for an arbitrary date using the same logic as CalendarRegistrationForm + async function fetchSlotsForDate(doctorId: string, dateOnly: string) { + if (!doctorId || !dateOnly) return [] + setMoreTimesLoading(true) + setMoreTimesException(null) + try { + // Check for blocking exceptions (listarExcecoes can filter by date) + const exceptions = await listarExcecoes({ doctorId: String(doctorId), date: String(dateOnly) }).catch(() => []) + if (exceptions && exceptions.length) { + const blocking = (exceptions || []).find((e: any) => e && e.kind === 'bloqueio') + if (blocking) { + const reason = blocking.reason ? ` Motivo: ${blocking.reason}` : '' + setMoreTimesException(`Não é possível agendar nesta data.${reason}`) + setMoreTimesSlots([]) + return [] + } + } + + // Build local start/end for the day + let start: Date + let end: Date + try { + const parts = String(dateOnly).split('-').map(Number) + if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) { + const [y, m, d] = parts + start = new Date(y, m - 1, d, 0, 0, 0, 0) + end = new Date(y, m - 1, d, 23, 59, 59, 999) + } else { + start = new Date(dateOnly) + start.setHours(0,0,0,0) + end = new Date(dateOnly) + end.setHours(23,59,59,999) + } + } catch (err) { + start = new Date(dateOnly) + start.setHours(0,0,0,0) + end = new Date(dateOnly) + end.setHours(23,59,59,999) + } + + const av = await getAvailableSlots({ + doctor_id: String(doctorId), + start_date: start.toISOString(), + end_date: end.toISOString(), + appointment_type: tipoConsulta === 'local' ? 'presencial' : 'telemedicina', + }) + + // Try to restrict to public availability windows and synthesize missing slots + try { + const disponibilidades = await listarDisponibilidades({ doctorId: String(doctorId) }).catch(() => []) + const weekdayNumber = start.getDay() + const weekdayNames: Record = { + 0: ['0','sun','sunday','domingo'], + 1: ['1','mon','monday','segunda','segunda-feira'], + 2: ['2','tue','tuesday','terca','terça','terça-feira'], + 3: ['3','wed','wednesday','quarta','quarta-feira'], + 4: ['4','thu','thursday','quinta','quinta-feira'], + 5: ['5','fri','friday','sexta','sexta-feira'], + 6: ['6','sat','saturday','sabado','sábado'] + } + const allowed = new Set((weekdayNames[weekdayNumber] || []).map(s => String(s).toLowerCase())) + const matched = (disponibilidades || []).filter((d: any) => { + try { + const raw = String(d.weekday ?? d.weekday_name ?? d.day ?? d.day_of_week ?? '').toLowerCase() + if (!raw) return false + if (allowed.has(raw)) return true + if (typeof d.weekday === 'number' && d.weekday === weekdayNumber) return true + if (typeof d.day_of_week === 'number' && d.day_of_week === weekdayNumber) return true + return false + } catch (e) { return false } + }) + + if (matched && matched.length) { + const windows = matched.map((d: any) => { + const parseTime = (t?: string) => { + if (!t) return { hh: 0, mm: 0, ss: 0 } + const parts = String(t).split(':').map(Number) + return { hh: parts[0] || 0, mm: parts[1] || 0, ss: parts[2] || 0 } + } + const s = parseTime(d.start_time) + const e2 = parseTime(d.end_time) + const winStart = new Date(start.getFullYear(), start.getMonth(), start.getDate(), s.hh, s.mm, s.ss || 0, 0) + const winEnd = new Date(start.getFullYear(), start.getMonth(), start.getDate(), e2.hh, e2.mm, e2.ss || 0, 999) + const slotMinutes = (() => { const n = Number(d.slot_minutes ?? d.slot_minutes_minutes ?? NaN); return Number.isFinite(n) ? n : undefined })() + return { winStart, winEnd, slotMinutes } + }) + + // compute step based on backend slot diffs + let stepMinutes = 30 + try { + const times = (av.slots || []).map((s: any) => new Date(s.datetime).getTime()).sort((a:number,b:number)=>a-b) + const diffs: number[] = [] + for (let i = 1; i < times.length; i++) { + const d = Math.round((times[i] - times[i-1]) / 60000) + if (d > 0) diffs.push(d) + } + if (diffs.length) stepMinutes = Math.min(...diffs) + } catch(e) {} + + const generatedSet = new Set() + windows.forEach((w:any) => { + try { + const perWindowStep = Number(w.slotMinutes) || stepMinutes + const startMs = w.winStart.getTime() + const endMs = w.winEnd.getTime() + const lastStartMs = endMs - perWindowStep * 60000 + const backendSlotsInWindow = (av.slots || []).filter((s:any) => { + try { + const sd = new Date(s.datetime) + const sm = sd.getHours() * 60 + sd.getMinutes() + const wmStart = w.winStart.getHours() * 60 + w.winStart.getMinutes() + const wmEnd = w.winEnd.getHours() * 60 + w.winEnd.getMinutes() + return sm >= wmStart && sm <= wmEnd + } catch(e) { return false } + }).map((s:any) => new Date(s.datetime).getTime()).sort((a:number,b:number)=>a-b) + + if (!backendSlotsInWindow.length) { + let cursorMs = startMs + while (cursorMs <= lastStartMs) { + generatedSet.add(new Date(cursorMs).toISOString()) + cursorMs += perWindowStep * 60000 + } + } else { + const lastBackendMs = backendSlotsInWindow.at(-1) + let cursorMs = (lastBackendMs ?? 0) + perWindowStep * 60000 + while (cursorMs <= lastStartMs) { + generatedSet.add(new Date(cursorMs).toISOString()) + cursorMs += perWindowStep * 60000 + } + } + } catch(e) {} + }) + + const mergedMap = new Map() + const findWindowSlotMinutes = (isoDt: string) => { + try { + const sd = new Date(isoDt) + const sm = sd.getHours() * 60 + sd.getMinutes() + const w = windows.find((win:any) => { + const ws = win.winStart + const we = win.winEnd + const winStartMinutes = ws.getHours() * 60 + ws.getMinutes() + const winEndMinutes = we.getHours() * 60 + we.getMinutes() + return sm >= winStartMinutes && sm <= winEndMinutes + }) + return w && w.slotMinutes ? Number(w.slotMinutes) : null + } catch(e) { return null } + } + + const existingInWindow: any[] = (av.slots || []).filter((s:any) => { + try { + const sd = new Date(s.datetime) + const slotMinutes = sd.getHours() * 60 + sd.getMinutes() + return windows.some((w:any) => { + const ws = w.winStart + const we = w.winEnd + const winStartMinutes = ws.getHours() * 60 + ws.getMinutes() + const winEndMinutes = we.getHours() * 60 + we.getMinutes() + return slotMinutes >= winStartMinutes && slotMinutes <= winEndMinutes + }) + } catch(e) { return false } + }) + + for (const s of (existingInWindow || [])) { + const sm = findWindowSlotMinutes(s.datetime) + mergedMap.set(s.datetime, sm ? { ...s, slot_minutes: sm } : { ...s }) + } + Array.from(generatedSet).forEach((dt) => { + if (!mergedMap.has(dt)) { + const sm = findWindowSlotMinutes(dt) || stepMinutes + mergedMap.set(dt, { datetime: dt, available: true, slot_minutes: sm }) + } + }) + + const merged = Array.from(mergedMap.values()).sort((a:any,b:any) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime()) + const nowMs = Date.now() + // Filtrar: só mostrar horários que são posteriores ao horário atual + const futureOnly = merged.filter((s: any) => new Date(s.datetime).getTime() >= nowMs) + const formatted = (futureOnly || []).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) })) + setMoreTimesSlots(formatted) + return formatted + } else { + const nowMs = Date.now() + // Filtrar: só mostrar horários que são posteriores ao horário atual + const slots = (av.slots || []).filter((s:any) => new Date(s.datetime).getTime() >= nowMs).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) })) + setMoreTimesSlots(slots) + return slots + } + } catch (e) { + console.warn('[ResultadosClient] erro ao filtrar por disponibilidades', e) + const nowMs = Date.now() + // Filtrar: só mostrar horários que são posteriores ao horário atual + const slots = (av.slots || []).filter((s:any) => new Date(s.datetime).getTime() >= nowMs).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) })) + setMoreTimesSlots(slots) + return slots + } + } catch (e) { + console.warn('[ResultadosClient] falha ao carregar horários para data', e) + setMoreTimesSlots([]) + setMoreTimesException('Falha ao buscar horários para a data selecionada') + return [] + } finally { + setMoreTimesLoading(false) + } + } + + // Filtro visual (convenio/bairro são cosméticos; quando sem dado, mantemos tudo) + const profissionais = useMemo(() => { + let filtered = (medicos || []).filter((m: any) => { + if (convenio !== 'Todos' && m.convenios && !m.convenios.includes(convenio)) return false + if (bairro !== 'Todos' && m.neighborhood && String(m.neighborhood).toLowerCase() !== String(bairro).toLowerCase()) return false + return true + }) + + // Se um médico específico foi selecionado no dashboard, filtrar apenas por ele + if (medicoFiltro) { + filtered = filtered.filter((m: any) => { + // Comparar nome completo com flexibilidade + const nomeMedico = String(m.full_name || m.name || '').toLowerCase() + const filtro = String(medicoFiltro).toLowerCase() + return nomeMedico.includes(filtro) || filtro.includes(nomeMedico.split(' ')[0]) // comparar por primeiro nome também + }) + } + + return filtered + }, [medicos, convenio, bairro, medicoFiltro]) + + // 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) + + // Memoized map para calcular próximos 3 horários para cada médico + const proximosHorariosPorMedico = useMemo(() => { + const result: Record> = {} + for (const id in agendaByDoctor) { + const slots = agendaByDoctor[id]?.flatMap(d => d.horarios) || [] + result[id] = slots.slice(0, 3) + } + return result + }, [agendaByDoctor]) + + // Render + return ( +
+
+ {/* Toast */} + {toast && ( +
+ {toast.msg} +
+ )} + + {/* Confirmation dialog shown when a user selects a slot */} + { if (!open) { setConfirmOpen(false); setPendingAppointment(null); } }}> + + + Confirmar agendamento + +
+ {pendingAppointment ? ( + (() => { + const doc = medicos.find(m => String(m.id) === String(pendingAppointment.doctorId)) + const doctorName = doc ? (doc.full_name || (doc as any).name || 'Profissional') : 'Profissional' + const when = (() => { + try { return new Date(pendingAppointment.iso).toLocaleString('pt-BR', { dateStyle: 'long', timeStyle: 'short' }) } catch { return pendingAppointment.iso } + })() + return ( +
+

Profissional: {doctorName}

+

Data / Hora: {when}

+

Paciente: Você

+
+ ) + })() + ) : ( +

Carregando informações...

+ )} +
+
+ + +
+
+
+ + {/* Booking success modal shown when origin=paciente */} + setBookingSuccessOpen(open)}> + + + Consulta agendada + +
+

Sua consulta foi agendada com sucesso{bookedWhenLabel ? ` para ${bookedWhenLabel}` : ''}.

+
+
+ +
+
+
{/* Hero section com barra de busca */} +
+
+
+

Encontre o profissional ideal

+

Busque por nome, especialidade ou disponibilidade

+
+ + {/* Barra de busca principal */} +
+ ) => setSearchQuery(e.target.value)} + className="flex-1 h-11 rounded-full bg-primary-foreground/15 border border-primary-foreground/30 text-primary-foreground placeholder:text-primary-foreground/60 focus:bg-primary-foreground/20" + /> {searchQuery && ( + + )} +
+
+
+ + {/* Barra de filtros secundários (agora fluída, sem sticky) */} +
+
+ {/* Segmented control: tipo da consulta */} +
+
+ setTipoConsulta('teleconsulta')} + className="flex-1 rounded-none first:rounded-l-full px-4 py-2.5 text-sm font-medium transition data-[state=on]:bg-primary data-[state=on]:text-primary-foreground hover:bg-primary/10" + > + + Teleconsulta + + setTipoConsulta('local')} + className="flex-1 rounded-none last:rounded-r-full px-4 py-2.5 text-sm font-medium transition data-[state=on]:bg-primary data-[state=on]:text-primary-foreground hover:bg-primary/10" + > + + Consulta no local + +
+
+ + {/* divider visual */} +
+ + {/* Convênio */} +
+ +
+ + {/* Bairro */} +
+ +
+ + {/* Mais filtros / Voltar */} +
+ +
+ + {/* Voltar */} +
+ +
+
+
+ + {/* Lista de profissionais */} +
+ {loadingMedicos && ( + + Buscando profissionais... + + )} {!loadingMedicos && paginatedProfissionais.map((medico) => { + const id = String(medico.id) + const agenda = agendaByDoctor[id] + const isLoadingAgenda = !!agendaLoading[id] + const atendeLocal = true + const atendeTele = true + const nome = medico.full_name || 'Profissional' + const esp = (medico as any).specialty || medico.especialidade || '—' + const crm = [medico.crm, (medico as any).crm_uf].filter(Boolean).join(' ') + const endereco = [medico.street, medico.number].filter(Boolean).join(', ') || medico.street || '—' + const cidade = medico.city || '—' + const precoTipoConsulta = tipoConsulta === 'local' ? 'R$ —' : 'R$ —' + + // Usar os próximos 3 horários já memoizados + const proximos3Horarios = proximosHorariosPorMedico[id] || [] + + return ( + + {/* Header com Avatar, Nome, Especialidade e Botão Ver Perfil */} +
+ + + {nome.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase()} + + + +
+
+
+

{nome}

+

{esp}

+
+ +
+ + {/* Rating e Info */} +
+
+ + 4.9 + • 23 avaliações +
+
+ + {/* CRM */} +

CRM: {crm || '—'}

+
+
+ + {/* Endereço */} + {tipoConsulta === 'local' && ( +
+ +
+

{endereco}

+

{cidade}

+
+
+ )} + + {/* Tipo de Consulta */} +
+ {tipoConsulta === 'teleconsulta' ? ( + <> + + Teleconsulta + + ) : ( + <> + + Consulta presencial + + )} + {precoTipoConsulta} +
+ + {/* Próximos horários */} + {!isLoadingAgenda && ( +
+

Próximos horários disponíveis:

+ {proximos3Horarios.length > 0 ? ( +
+ {proximos3Horarios.map(slot => ( + + ))} +
+ ) : ( +

Carregando horários...

+ )} +
+ )} + + {/* Ações */} +
+ + +
+
+ ) + })} + + {!loadingMedicos && !profissionais.length && ( + + Nenhum profissional encontrado. Ajuste os filtros para ver outras opções. + + )} + + {/* Pagination controls */} + {!loadingMedicos && profissionais.length > 0 && ( +
+
+ Itens por página: + + Mostrando {startItem} a {endItem} de {profissionais.length} +
+ +
+ + + Página {currentPage} de {totalPages} + + +
+
+ )} +
+ + {/* Dialog de perfil completo (mantido e adaptado) */} + !open && setMedicoSelecionado(null)}> + + {medicoSelecionado && ( + <> + + + {medicoSelecionado.full_name || 'Profissional'} + +

+ {((medicoSelecionado as any).specialty || medicoSelecionado.especialidade || '—')} + { ' • ' } + {[medicoSelecionado.crm, (medicoSelecionado as any).crm_uf].filter(Boolean).join(' / ') || '—'} +

+
+ +
+
+ + + 4.9 (23 avaliações) + + Planos de saúde: — +
+ + + + + Experiência + + + Planos de saúde + + + Consultórios + + + Serviços + + + Opiniões (0) + + loadAgenda(String(medicoSelecionado.id))}> + Agenda + + + + +

Informações fornecidas pelo profissional.

+
+ + + + + + +
+

Atendimento por teleconsulta ou endereço informado no card.

+
+
+ + +
+ Consulta + +
+
+ + +
+

Nenhuma opinião disponível.

+
+
+ + +

+ Escolha o melhor horário disponível para sua consulta. +

+
+
+ {(agendaByDoctor[String(medicoSelecionado.id)] || []).map((col, idx) => ( +
+

{col.label}

+

{col.data}

+
+ {col.horarios.length ? ( + col.horarios.map(h => ( + + )) + ) : ( + + Sem horários + + )} +
+
+ ))} + {!(agendaByDoctor[String(medicoSelecionado.id)] || []).length && ( +
Carregando horários...
+ )} +
+
+
+
+
+ + )} +
+
+ + {/* Dialog: Mostrar mais horários */} + { if (!open) { setMoreTimesForDoctor(null); setMoreTimesSlots([]); setMoreTimesException(null); } }}> + + + Mais horários + + +
+ setMoreTimesDate(e.target.value)} /> + +
+ +
+ {moreTimesLoading ? ( +
Carregando horários...
+ ) : moreTimesException ? ( +
{moreTimesException}
+ ) : (moreTimesSlots.length ? ( +
+ {moreTimesSlots.map(s => ( + + ))} +
+ ) : ( +
Sem horários para a data selecionada.
+ ))} +
+
+
+
+
+ ) +}