- {/* rótulo e número com mesma fonte e mesmo tamanho (harmônico) */}
-
- {strings.proximaConsulta}
-
-
- {loading ? strings.carregando : (nextAppt ?? '-')}
-
-
- Itens por página:
-
- Mostrando {startItem} a {endItem} de {profissionais.length}
-
-
-
-
-
- Página {currentPage} de {totalPages}
-
-
-
-
- )}
-
-
- {/* Dialog de perfil completo (mantido e adaptado) */}
-
-
- {/* Dialog: Mostrar mais horários */}
-
-
-
- )
-}
+"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 */}
+
+
+ {/* Booking success modal shown when origin=paciente */}
+ {/* Hero section com barra de busca */}
+
+