2031 lines
96 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import type { ReactNode } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { User, LogOut, Calendar, FileText, MessageCircle, UserCog, Home, Clock, FolderOpen, ChevronLeft, ChevronRight, MapPin, Stethoscope } from 'lucide-react'
import { SimpleThemeToggle } from '@/components/ui/simple-theme-toggle'
import { UploadAvatar } from '@/components/ui/upload-avatar'
import { useAvatarUrl } from '@/hooks/useAvatarUrl'
import Link from 'next/link'
import ProtectedRoute from '@/components/shared/ProtectedRoute'
import { useAuth } from '@/hooks/useAuth'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { buscarPacientes, buscarPacientePorUserId, getUserInfo, listarAgendamentos, buscarMedicosPorIds, buscarMedicos, atualizarPaciente, buscarPacientePorId, getDoctorById, atualizarAgendamento, deletarAgendamento, addDeletedAppointmentId, listarTodosMedicos } from '@/lib/api'
import { CalendarRegistrationForm } from '@/components/features/forms/calendar-registration-form'
import { buscarRelatorioPorId, listarRelatoriosPorMedico } from '@/lib/reports'
import { ENV_CONFIG } from '@/lib/env-config'
import { listarRelatoriosPorPaciente } from '@/lib/reports'
// reports are rendered statically for now
// Simulação de internacionalização básica
const strings = {
dashboard: 'Dashboard',
consultas: 'Consultas',
exames: 'Exames & Laudos',
mensagens: 'Mensagens',
perfil: 'Perfil',
sair: 'Sair',
proximaConsulta: 'Próxima Consulta',
ultimosExames: 'Últimos Exames',
mensagensNaoLidas: 'Mensagens Não Lidas',
agendar: 'Agendar',
cancelar: 'Cancelar',
detalhes: 'Detalhes',
adicionarCalendario: 'Adicionar ao calendário',
visualizarLaudo: 'Visualizar Laudo',
download: 'Download',
compartilhar: 'Compartilhar',
inbox: 'Caixa de Entrada',
enviarMensagem: 'Enviar Mensagem',
salvar: 'Salvar',
editarPerfil: 'Editar Perfil',
consentimentos: 'Consentimentos',
notificacoes: 'Preferências de Notificação',
vazio: 'Nenhum dado encontrado.',
erro: 'Ocorreu um erro. Tente novamente.',
carregando: 'Carregando...',
sucesso: 'Salvo com sucesso!',
erroSalvar: 'Erro ao salvar.',
}
export default function PacientePage() {
const { logout, user } = useAuth()
const [tab, setTab] = useState<'dashboard'|'consultas'|'exames'|'perfil'>('dashboard')
// Simulação de loaders, empty states e erro
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [toast, setToast] = useState<{type: 'success'|'error', msg: string}|null>(null)
const handleLogout = async () => {
setLoading(true)
setError('')
try {
await logout()
} catch {
setError(strings.erro)
} finally {
setLoading(false)
}
}
// Estado para edição do perfil
const [isEditingProfile, setIsEditingProfile] = useState(false)
const [profileData, setProfileData] = useState<any>({
nome: '',
email: user?.email || '',
telefone: '',
endereco: '',
cidade: '',
cep: '',
biografia: '',
id: undefined,
foto_url: undefined,
})
const [patientId, setPatientId] = useState<string | null>(null)
// Hook para carregar automaticamente o avatar do paciente
const { avatarUrl: retrievedAvatarUrl } = useAvatarUrl(patientId)
// Load authoritative patient row for the logged-in user (prefer user_id lookup)
useEffect(() => {
let mounted = true
const uid = user?.id ?? null
const uemail = user?.email ?? null
if (!uid && !uemail) return
async function loadProfile() {
try {
setLoading(true)
setError('')
// 1) exact lookup by user_id on patients table
let paciente: any = null
if (uid) paciente = await buscarPacientePorUserId(uid)
// 2) fallback: search patients by email and prefer a row that has user_id equal to auth id
if (!paciente && uemail) {
try {
const results = await buscarPacientes(uemail)
if (results && results.length) {
paciente = results.find((r: any) => String(r.user_id) === String(uid)) || results[0]
}
} catch (e) {
console.warn('[PacientePage] buscarPacientes falhou', e)
}
}
// 3) fallback: use getUserInfo() (auth profile) if available
if (!paciente) {
try {
const info = await getUserInfo().catch(() => null)
const p = info?.profile ?? null
if (p) {
// map auth profile to our local shape (best-effort)
paciente = {
full_name: p.full_name ?? undefined,
email: p.email ?? undefined,
phone_mobile: p.phone ?? undefined,
}
}
} catch (e) {
// ignore
}
}
if (paciente && mounted) {
try { if ((paciente as any).id) setPatientId(String((paciente as any).id)) } catch {}
const getFirst = (obj: any, keys: string[]) => {
if (!obj) return undefined
for (const k of keys) {
const v = obj[k]
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v)
}
return undefined
}
const nome = getFirst(paciente, ['full_name','fullName','name','nome','social_name']) || ''
const telefone = getFirst(paciente, ['phone_mobile','phone','telefone','mobile']) || ''
const rua = getFirst(paciente, ['street','logradouro','endereco','address'])
const numero = getFirst(paciente, ['number','numero'])
const bairro = getFirst(paciente, ['neighborhood','bairro'])
const endereco = rua ? (numero ? `${rua}, ${numero}` : rua) + (bairro ? ` - ${bairro}` : '') : ''
const cidade = getFirst(paciente, ['city','cidade','localidade']) || ''
const cep = getFirst(paciente, ['cep','postal_code','zip']) || ''
const biografia = getFirst(paciente, ['biography','bio','notes']) || ''
const emailFromRow = getFirst(paciente, ['email']) || uemail || ''
if (process.env.NODE_ENV !== 'production') console.debug('[PacientePage] paciente row', paciente)
setProfileData({ nome, email: emailFromRow, telefone, endereco, cidade, cep, biografia })
}
} catch (err) {
console.warn('[PacientePage] erro ao carregar paciente', err)
} finally {
if (mounted) setLoading(false)
}
}
loadProfile()
return () => { mounted = false }
}, [user?.id, user?.email])
// Load authoritative patient row for the logged-in user (prefer user_id lookup)
useEffect(() => {
let mounted = true
const uid = user?.id ?? null
const uemail = user?.email ?? null
if (!uid && !uemail) return
async function loadProfile() {
try {
setLoading(true)
setError('')
let paciente: any = null
if (uid) paciente = await buscarPacientePorUserId(uid)
if (!paciente && uemail) {
try {
const res = await buscarPacientes(uemail)
if (res && res.length) paciente = res.find((r:any) => String((r as any).user_id) === String(uid)) || res[0]
} catch (e) {
console.warn('[PacientePage] busca por email falhou', e)
}
}
if (paciente && mounted) {
try { if ((paciente as any).id) setPatientId(String((paciente as any).id)) } catch {}
const getFirst = (obj: any, keys: string[]) => {
if (!obj) return undefined
for (const k of keys) {
const v = obj[k]
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v)
}
return undefined
}
const nome = getFirst(paciente, ['full_name','fullName','name','nome','social_name']) || profileData.nome
const telefone = getFirst(paciente, ['phone_mobile','phone','telefone','mobile']) || profileData.telefone
const rua = getFirst(paciente, ['street','logradouro','endereco','address'])
const numero = getFirst(paciente, ['number','numero'])
const bairro = getFirst(paciente, ['neighborhood','bairro'])
const endereco = rua ? (numero ? `${rua}, ${numero}` : rua) + (bairro ? ` - ${bairro}` : '') : profileData.endereco
const cidade = getFirst(paciente, ['city','cidade','localidade']) || profileData.cidade
const cep = getFirst(paciente, ['cep','postal_code','zip']) || profileData.cep
const biografia = getFirst(paciente, ['biography','bio','notes']) || profileData.biografia || ''
const emailFromRow = getFirst(paciente, ['email']) || user?.email || profileData.email
if (process.env.NODE_ENV !== 'production') console.debug('[PacientePage] paciente row', paciente)
setProfileData((prev: any) => ({ ...prev, nome, email: emailFromRow, telefone, endereco, cidade, cep, biografia }))
}
} catch (err) {
console.warn('[PacientePage] erro ao carregar paciente', err)
} finally {
if (mounted) setLoading(false)
}
}
loadProfile()
return () => { mounted = false }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, user?.email])
// Sincroniza a URL do avatar recuperada com o profileData
useEffect(() => {
if (retrievedAvatarUrl) {
setProfileData((prev: any) => ({
...prev,
foto_url: retrievedAvatarUrl
}))
}
}, [retrievedAvatarUrl])
const handleProfileChange = (field: string, value: string) => {
setProfileData((prev: any) => ({ ...prev, [field]: value }))
}
const handleSaveProfile = async () => {
if (!patientId) {
setToast({ type: 'error', msg: 'Paciente não identificado. Não foi possível salvar.' })
setIsEditingProfile(false)
return
}
setLoading(true)
try {
const payload: any = {}
if (profileData.email) payload.email = profileData.email
if (profileData.telefone) payload.phone_mobile = profileData.telefone
if (profileData.endereco) payload.street = profileData.endereco
if (profileData.cidade) payload.city = profileData.cidade
if (profileData.cep) payload.cep = profileData.cep
if (profileData.biografia) payload.notes = profileData.biografia
await atualizarPaciente(String(patientId), payload)
// refresh patient row
const refreshed = await buscarPacientePorId(String(patientId)).catch(() => null)
if (refreshed) {
const getFirst = (obj: any, keys: string[]) => {
if (!obj) return undefined
for (const k of keys) {
const v = obj[k]
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v)
}
return undefined
}
const nome = getFirst(refreshed, ['full_name','fullName','name','nome','social_name']) || profileData.nome
const telefone = getFirst(refreshed, ['phone_mobile','phone','telefone','mobile']) || profileData.telefone
const rua = getFirst(refreshed, ['street','logradouro','endereco','address'])
const numero = getFirst(refreshed, ['number','numero'])
const bairro = getFirst(refreshed, ['neighborhood','bairro'])
const endereco = rua ? (numero ? `${rua}, ${numero}` : rua) + (bairro ? ` - ${bairro}` : '') : profileData.endereco
const cidade = getFirst(refreshed, ['city','cidade','localidade']) || profileData.cidade
const cep = getFirst(refreshed, ['cep','postal_code','zip']) || profileData.cep
const biografia = getFirst(refreshed, ['biography','bio','notes']) || profileData.biografia || ''
const emailFromRow = getFirst(refreshed, ['email']) || profileData.email
const foto = getFirst(refreshed, ['foto_url','avatar_url','fotoUrl']) || profileData.foto_url
setProfileData((prev:any) => ({ ...prev, nome, email: emailFromRow, telefone, endereco, cidade, cep, biografia, foto_url: foto }))
}
setIsEditingProfile(false)
setToast({ type: 'success', msg: strings.sucesso })
} catch (err: any) {
console.warn('[PacientePage] erro ao atualizar paciente', err)
setToast({ type: 'error', msg: err?.message || strings.erroSalvar })
} finally {
setLoading(false)
}
}
const handleCancelEdit = () => {
setIsEditingProfile(false)
}
function DashboardCards() {
const router = useRouter()
const [nextAppt, setNextAppt] = useState<string | null>(null)
const [examsCount, setExamsCount] = useState<number | null>(null)
const [loading, setLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [medicos, setMedicos] = useState<any[]>([])
const [searchLoading, setSearchLoading] = useState(false)
const [especialidades, setEspecialidades] = useState<string[]>([])
const [especialidadesLoading, setEspecialidadesLoading] = useState(true)
useEffect(() => {
let mounted = true
async function load() {
if (!patientId) {
setNextAppt(null)
setExamsCount(null)
return
}
setLoading(true)
try {
// Load appointments for this patient (upcoming)
const q = `patient_id=eq.${encodeURIComponent(String(patientId))}&order=scheduled_at.asc&limit=200`
const ags = await listarAgendamentos(q).catch(() => [])
if (!mounted) return
const now = Date.now()
// find the first appointment with scheduled_at >= now
const upcoming = (ags || []).map((a: any) => ({ ...a, _sched: a.scheduled_at ? new Date(a.scheduled_at).getTime() : null }))
.filter((a: any) => a._sched && a._sched >= now)
.sort((x: any, y: any) => Number(x._sched) - Number(y._sched))
if (upcoming && upcoming.length) {
setNextAppt(new Date(upcoming[0]._sched).toLocaleDateString('pt-BR'))
} else {
setNextAppt(null)
}
// Load reports/laudos and compute count matching the Laudos session rules
const reports = await listarRelatoriosPorPaciente(String(patientId)).catch(() => [])
if (!mounted) return
let count = 0
try {
if (!Array.isArray(reports) || reports.length === 0) {
count = 0
} else {
// Use the same robust doctor-resolution strategy as ExamesLaudos so
// the card matches the list: try buscarMedicosPorIds, then per-id
// getDoctorById and finally a REST fallback by user_id.
const ids = Array.from(new Set((reports as any[]).map((r:any) => r.doctor_id || r.created_by || r.doctor).filter(Boolean).map(String)))
if (ids.length === 0) {
// fallback: count reports that have any direct doctor reference
count = (reports as any[]).filter((r:any) => !!(r && (r.doctor_id || r.created_by || r.doctor || r.user_id))).length
} else {
const docs = await buscarMedicosPorIds(ids).catch(() => [])
const map: Record<string, any> = {}
for (const d of docs || []) {
if (!d) continue
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = map[String(d.user_id)] || d } catch {}
}
// Try per-id fallback using getDoctorById for any unresolved ids
const unresolved = ids.filter(i => !map[i])
if (unresolved.length) {
for (const u of unresolved) {
try {
const d = await getDoctorById(String(u)).catch(() => null)
if (d) {
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
}
} catch (e) {
// ignore per-id failure
}
}
}
// REST fallback: try lookup by user_id for still unresolved ids
const stillUnresolved = ids.filter(i => !map[i])
if (stillUnresolved.length) {
for (const u of stillUnresolved) {
try {
const token = (typeof window !== 'undefined') ? (localStorage.getItem('auth_token') || localStorage.getItem('token') || sessionStorage.getItem('auth_token') || sessionStorage.getItem('token')) : null
const headers: Record<string,string> = { apikey: ENV_CONFIG.SUPABASE_ANON_KEY, Accept: 'application/json' }
if (token) headers.Authorization = `Bearer ${token}`
const url = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/doctors?user_id=eq.${encodeURIComponent(String(u))}&limit=1`
const res = await fetch(url, { method: 'GET', headers })
if (!res || res.status >= 400) continue
const rows = await res.json().catch(() => [])
if (rows && Array.isArray(rows) && rows.length) {
const d = rows[0]
if (d) {
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
}
}
} catch (e) {
// ignore network errors
}
}
}
// Count only reports whose referenced doctor record has user_id
count = (reports as any[]).filter((r:any) => {
const maybeId = String(r.doctor_id || r.created_by || r.doctor || '')
const doc = map[maybeId]
return !!(doc && (doc.user_id || (doc as any).user_id))
}).length
}
}
} catch (e) {
count = Array.isArray(reports) ? reports.length : 0
}
if (!mounted) return
setExamsCount(count)
} catch (e) {
console.warn('[DashboardCards] erro ao carregar dados', e)
if (!mounted) return
setNextAppt(null)
setExamsCount(null)
} finally {
if (mounted) setLoading(false)
}
}
load()
return () => { mounted = false }
}, [])
// Carregar especialidades únicas dos médicos ao montar
useEffect(() => {
let mounted = true
setEspecialidadesLoading(true)
;(async () => {
try {
console.log('[DashboardCards] Carregando especialidades...')
const todos = await listarTodosMedicos().catch((err) => {
console.error('[DashboardCards] Erro ao buscar médicos:', err)
return []
})
console.log('[DashboardCards] Médicos carregados:', todos?.length || 0, todos)
if (!mounted) return
// Mapeamento de correções para especialidades com encoding errado
const specialtyFixes: Record<string, string> = {
'Cl\u00EDnica Geral': 'Clínica Geral',
'Cl\u00E3nica Geral': 'Clínica Geral',
'Cl?nica Geral': 'Clínica Geral',
'Cl©nica Geral': 'Clínica Geral',
'Cl\uFFFDnica Geral': 'Clínica Geral',
};
let specs: string[] = []
if (Array.isArray(todos) && todos.length > 0) {
// Extrai TODAS as especialidades únicas do campo specialty
specs = Array.from(new Set(
todos
.map((m: any) => {
let spec = m.specialty || m.speciality || ''
// Aplica correções conhecidas
for (const [wrong, correct] of Object.entries(specialtyFixes)) {
spec = String(spec).replace(new RegExp(wrong, 'g'), correct)
}
// Normaliza caracteres UTF-8 e limpa
try {
const normalized = String(spec || '').normalize('NFC').trim()
return normalized
} catch (e) {
return String(spec || '').trim()
}
})
.filter((s: string) => s && s.length > 0)
))
}
console.log('[DashboardCards] Especialidades encontradas:', specs)
// Ordenação alfabética usando localeCompare para suportar acentuação (português)
setEspecialidades(specs.length > 0 ? specs.sort((a, b) => a.localeCompare(b, 'pt', { sensitivity: 'base' })) : [])
} catch (e) {
console.error('[DashboardCards] erro ao carregar especialidades', e)
if (mounted) setEspecialidades([])
} finally {
if (mounted) setEspecialidadesLoading(false)
}
})()
return () => { mounted = false }
}, [])
// Debounced search por médico
useEffect(() => {
let mounted = true
const term = String(searchQuery || '').trim()
const handle = setTimeout(async () => {
if (!mounted) return
if (!term || term.length < 2) {
setMedicos([])
return
}
try {
setSearchLoading(true)
const results = await buscarMedicos(term).catch(() => [])
if (!mounted) return
setMedicos(Array.isArray(results) ? results : [])
} catch (e) {
if (mounted) setMedicos([])
} finally {
if (mounted) setSearchLoading(false)
}
}, 300)
return () => { mounted = false; clearTimeout(handle) }
}, [searchQuery])
const handleSearchMedico = (medico: any) => {
const qs = new URLSearchParams()
qs.set('tipo', 'teleconsulta')
if (medico?.full_name) qs.set('medico', medico.full_name)
if (medico?.specialty) qs.set('especialidade', medico.specialty || medico.especialidade || '')
qs.set('origin', 'paciente')
router.push(`/paciente/resultados?${qs.toString()}`)
setSearchQuery('')
setMedicos([])
}
const handleEspecialidadeClick = (especialidade: string) => {
const qs = new URLSearchParams()
qs.set('tipo', 'teleconsulta')
qs.set('especialidade', especialidade)
qs.set('origin', 'paciente')
router.push(`/paciente/resultados?${qs.toString()}`)
}
return (
<div className="space-y-4 sm:space-y-6">
{/* Hero Section com Busca */}
<section className="rounded-2xl sm:rounded-3xl bg-linear-to-br from-primary to-primary/90 p-4 sm:p-6 md:p-8 text-primary-foreground shadow-lg">
<div className="max-w-4xl mx-auto space-y-4 sm:space-y-6">
<div className="text-center space-y-2 sm:space-y-3">
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold">Encontre especialistas e clínicas</h2>
<p className="text-sm sm:text-base md:text-lg opacity-90">Busque por médico, especialidade ou localização</p>
</div>
{/* Search Bar */}
<div className="relative">
<Input
placeholder="Buscar médico, especialidade..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 sm:px-5 md:px-6 py-3 sm:py-3.5 md:py-4 rounded-xl bg-white text-foreground placeholder:text-muted-foreground text-sm sm:text-base border-0 shadow-md"
/>
{searchQuery && medicos.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-2 bg-card rounded-xl border border-border shadow-lg z-50 max-h-64 overflow-y-auto">
{medicos.map((medico) => (
<button
key={medico.id}
onClick={() => handleSearchMedico(medico)}
className="w-full text-left px-4 py-3 sm:py-4 hover:bg-primary/10 border-b border-border/50 last:border-0 transition-colors text-foreground text-sm sm:text-base"
>
<div className="font-semibold">{medico.full_name || 'Médico'}</div>
<div className="text-xs sm:text-sm text-muted-foreground">{medico.specialty || medico.especialidade || ''}</div>
</button>
))}
</div>
)}
</div>
{/* Especialidades */}
<div className="space-y-3 sm:space-y-4">
<p className="text-sm sm:text-base font-semibold opacity-90">Especialidades populares</p>
{especialidadesLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 gap-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-12 w-full bg-white/20 rounded-full animate-pulse" />
))}
</div>
) : especialidades && especialidades.length > 0 ? (
// Grid responsivo com botões arredondados e tamanho uniforme
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-2 sm:gap-3">
{especialidades.map((esp) => (
<button
key={esp}
onClick={() => handleEspecialidadeClick(esp)}
className="w-full min-h-[44px] sm:min-h-[48px] flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white font-medium text-sm transition-colors border border-white/20 px-3 py-2 text-center break-words"
>
<span className="leading-tight">{esp}</span>
</button>
))}
</div>
) : (
<p className="text-sm opacity-75">Nenhuma especialidade disponível</p>
)}
</div>
</div>
</section>
{/* Cards com Informações */}
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:gap-4 md:grid-cols-2">
<Card className="group rounded-2xl border border-border/60 bg-card p-4 sm:p-5 md:p-5 shadow-sm transition hover:shadow-md">
<div className="flex h-32 sm:h-36 md:h-40 w-full flex-col items-center justify-center gap-2 sm:gap-3">
<div className="flex h-10 w-10 sm:h-11 sm:w-11 md:h-12 md:w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Calendar className="h-5 w-5 sm:h-5 sm:w-5 md:h-6 md:w-6" aria-hidden />
</div>
<span className="text-base sm:text-lg md:text-lg font-semibold text-muted-foreground tracking-wide">
{strings.proximaConsulta}
</span>
<span className="text-base sm:text-lg md:text-xl font-semibold text-foreground" aria-live="polite">
{loading ? strings.carregando : (nextAppt ?? '-')}
</span>
</div>
</Card>
<Card className="group rounded-2xl border border-border/60 bg-card p-4 sm:p-5 md:p-5 shadow-sm transition hover:shadow-md">
<div className="flex h-32 sm:h-36 md:h-40 w-full flex-col items-center justify-center gap-2 sm:gap-3">
<div className="flex h-10 w-10 sm:h-11 sm:w-11 md:h-12 md:w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<FileText className="h-5 w-5 sm:h-5 sm:w-5 md:h-6 md:w-6" aria-hidden />
</div>
<span className="text-base sm:text-lg md:text-lg font-semibold text-muted-foreground tracking-wide">
{strings.ultimosExames}
</span>
<span className="text-base sm:text-lg md:text-xl font-semibold text-foreground" aria-live="polite">
{loading ? strings.carregando : (examsCount !== null ? String(examsCount) : '-')}
</span>
</div>
</Card>
</div>
</div>
)
}
const [currentDate, setCurrentDate] = useState(new Date())
// helper: produce a local YYYY-MM-DD key (uses local timezone, not toISOString UTC)
const localDateKey = (d: Date) => {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
const consultasFicticias = [
{
id: 1,
medico: "Dr. Carlos Andrade",
especialidade: "Cardiologia",
local: "Clínica Coração Feliz",
data: localDateKey(new Date()),
hora: "09:00",
status: "Confirmada"
},
{
id: 2,
medico: "Dra. Fernanda Lima",
especialidade: "Dermatologia",
local: "Clínica Pele Viva",
data: localDateKey(new Date()),
hora: "14:30",
status: "Pendente"
},
{
id: 3,
medico: "Dr. João Silva",
especialidade: "Ortopedia",
local: "Hospital Ortopédico",
data: (() => { let d = new Date(); d.setDate(d.getDate()+1); return localDateKey(d) })(),
hora: "11:00",
status: "Cancelada"
},
];
function formatDatePt(date: Date) {
return date.toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
}
function navigateDate(direction: 'prev' | 'next') {
const newDate = new Date(currentDate);
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
setCurrentDate(newDate);
}
function goToToday() {
setCurrentDate(new Date());
}
const todayStr = localDateKey(currentDate)
const consultasDoDia = consultasFicticias.filter(c => c.data === todayStr);
function Consultas() {
const router = useRouter()
const [tipoConsulta, setTipoConsulta] = useState<'teleconsulta' | 'presencial'>('teleconsulta')
const [especialidade, setEspecialidade] = useState('cardiologia')
const [localizacao, setLocalizacao] = useState('')
const hoverPrimaryClass = "hover-primary-blue focus-visible:ring-2 focus-visible:ring-blue-500/60 active:scale-[0.97]"
const activeToggleClass = "w-full transition duration-200 focus-visible:ring-2 focus-visible:ring-blue-500/60 active:scale-[0.97] bg-blue-500 text-white hover:bg-blue-500 hover:text-white"
const inactiveToggleClass = "w-full transition duration-200 bg-slate-50 text-blue-500 border border-blue-500/30 hover:bg-blue-50 hover:text-blue-500 dark:bg-white/5 dark:text-white dark:hover:bg-blue-500/20 dark:border-white/20"
const hoverPrimaryIconClass = "rounded-xl bg-white text-slate-900 border border-black/10 shadow-[0_2px_8px_rgba(0,0,0,0.03)] hover-primary-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:bg-slate-800 dark:text-slate-100 dark:border-white/10 dark:shadow-none"
const today = new Date(); today.setHours(0, 0, 0, 0);
const selectedDate = new Date(currentDate); selectedDate.setHours(0, 0, 0, 0);
const isSelectedDateToday = selectedDate.getTime() === today.getTime()
// Appointments state (loaded when component mounts)
const [appointments, setAppointments] = useState<any[] | null>(null)
const [doctorsMap, setDoctorsMap] = useState<Record<string, any>>({}) // Store doctor info by ID
const [loadingAppointments, setLoadingAppointments] = useState(false)
const [appointmentsError, setAppointmentsError] = useState<string | null>(null)
// expanded appointment id for inline details (kept for possible fallback)
const [expandedId, setExpandedId] = useState<number | null>(null)
// selected appointment for modal details
const [selectedAppointment, setSelectedAppointment] = useState<any | null>(null)
useEffect(() => {
let mounted = true
if (!patientId) {
setAppointmentsError('Paciente não identificado. Faça login novamente.')
return
}
async function loadAppointments() {
try {
setLoadingAppointments(true)
setAppointmentsError(null)
setAppointments(null)
// Try `eq.` first, then fallback to `in.(id)` which some views expect
const baseEncoded = encodeURIComponent(String(patientId))
const queriesToTry = [
`patient_id=eq.${baseEncoded}&order=scheduled_at.asc&limit=200`,
`patient_id=in.(${baseEncoded})&order=scheduled_at.asc&limit=200`,
];
let rows: any[] = []
for (const q of queriesToTry) {
try {
// Debug: also fetch raw response to inspect headers/response body in the browser
try {
const token = (typeof window !== 'undefined') ? (localStorage.getItem('auth_token') || localStorage.getItem('token') || sessionStorage.getItem('auth_token') || sessionStorage.getItem('token')) : null
const headers: Record<string,string> = {
apikey: ENV_CONFIG.SUPABASE_ANON_KEY,
Accept: 'application/json',
}
if (token) headers.Authorization = `Bearer ${token}`
const rawUrl = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/appointments?${q}`
console.debug('[Consultas][debug] GET', rawUrl, 'Headers(masked):', { ...headers, Authorization: headers.Authorization ? `${String(headers.Authorization).slice(0,6)}...${String(headers.Authorization).slice(-6)}` : undefined })
const rawRes = await fetch(rawUrl, { method: 'GET', headers })
const rawText = await rawRes.clone().text().catch(() => '')
console.debug('[Consultas][debug] raw response', { url: rawUrl, status: rawRes.status, bodyPreview: (typeof rawText === 'string' && rawText.length > 0) ? rawText.slice(0, 200) : rawText })
} catch (dbgErr) {
console.debug('[Consultas][debug] não foi possível capturar raw response', dbgErr)
}
const r = await listarAgendamentos(q)
if (r && Array.isArray(r) && r.length) {
rows = r
break
}
// if r is empty array, continue to next query format
} catch (e) {
// keep trying next format
console.debug('[Consultas] tentativa listarAgendamentos falhou para query', q, e)
}
}
if (!mounted) return
if (!rows || rows.length === 0) {
// no appointments found for this patient using either filter
setAppointments([])
return
}
const doctorIds = Array.from(new Set(rows.map((r: any) => r.doctor_id).filter(Boolean)))
const doctorsMap: Record<string, any> = {}
if (doctorIds.length) {
try {
const docs = await buscarMedicosPorIds(doctorIds).catch(() => [])
for (const d of docs || []) doctorsMap[d.id] = d
} catch (e) {
// ignore
}
}
const mapped = (rows || []).map((a: any) => {
const sched = a.scheduled_at ? new Date(a.scheduled_at) : null
const doc = a.doctor_id ? doctorsMap[String(a.doctor_id)] : null
return {
id: a.id,
medico: doc?.full_name || a.doctor_id || '---',
especialidade: doc?.specialty || '',
local: a.location || a.place || '',
data: sched ? localDateKey(sched) : '',
hora: sched ? sched.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '',
status: a.status ? String(a.status) : 'Pendente',
}
}).filter((consulta: any) => {
// Filter out cancelled appointments (those with cancelled_at set OR status='cancelled')
const raw = rows.find((r: any) => String(r.id) === String(consulta.id));
if (!raw) return false;
// Check cancelled_at field
const cancelled = raw.cancelled_at;
if (cancelled && cancelled !== '' && cancelled !== 'null') return false;
// Check status field
if (raw.status && String(raw.status).toLowerCase() === 'cancelled') return false;
return true;
})
setDoctorsMap(doctorsMap)
setAppointments(mapped)
} catch (err: any) {
console.warn('[Consultas] falha ao carregar agendamentos', err)
if (!mounted) return
setAppointmentsError(err?.message ?? 'Falha ao carregar agendamentos.')
setAppointments([])
} finally {
if (mounted) setLoadingAppointments(false)
}
}
loadAppointments()
return () => { mounted = false }
}, [])
// Monta a URL de resultados com os filtros atuais
const buildResultadosHref = () => {
const qs = new URLSearchParams()
qs.set('tipo', tipoConsulta) // 'teleconsulta' | 'presencial'
if (especialidade) qs.set('especialidade', especialidade)
if (localizacao) qs.set('local', localizacao)
// indicate navigation origin so destination can alter UX (e.g., show modal instead of redirect)
qs.set('origin', 'paciente')
return `/paciente/resultados?${qs.toString()}`
}
// derived lists for the page (computed after appointments state is declared)
const _dialogSource = (appointments !== null ? appointments : consultasFicticias)
const _todaysAppointments = (_dialogSource || []).filter((c: any) => c.data === todayStr)
// helper: present a localized label for appointment status
const statusLabel = (s: any) => {
const raw = (s === null || s === undefined) ? '' : String(s)
const key = raw.toLowerCase()
const map: Record<string,string> = {
'requested': 'Solicitado',
'request': 'Solicitado',
'confirmed': 'Confirmado',
'confirmada': 'Confirmada',
'confirmado': 'Confirmado',
'completed': 'Concluído',
'concluído': 'Concluído',
'cancelled': 'Cancelado',
'cancelada': 'Cancelada',
'cancelado': 'Cancelado',
'pending': 'Pendente',
'pendente': 'Pendente',
'checked_in': 'Registrado',
'in_progress': 'Em andamento',
'no_show': 'Não compareceu'
}
return map[key] || raw
}
// map an appointment (row) to the CalendarRegistrationForm's formData shape
const mapAppointmentToFormData = (appointment: any) => {
// Use the raw appointment with all fields: doctor_id, scheduled_at, appointment_type, etc.
const schedIso = appointment.scheduled_at || (appointment.data && appointment.hora ? `${appointment.data}T${appointment.hora}` : null) || null
const baseDate = schedIso ? new Date(schedIso) : new Date()
const appointmentDate = schedIso ? baseDate.toISOString().split('T')[0] : ''
const startTime = schedIso ? baseDate.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : (appointment.hora || '')
const duration = appointment.duration_minutes ?? appointment.duration ?? 30
// Get doctor name from doctorsMap if available
const docName = appointment.medico || (appointment.doctor_id ? doctorsMap[String(appointment.doctor_id)]?.full_name : null) || appointment.doctor_name || appointment.professional_name || '---'
return {
id: appointment.id,
patientName: docName,
patientId: null,
doctorId: appointment.doctor_id ?? null,
professionalName: docName,
appointmentDate,
startTime,
endTime: '',
status: appointment.status || undefined,
appointmentType: appointment.appointment_type || appointment.type || (appointment.local ? 'presencial' : 'teleconsulta'),
duration_minutes: duration,
notes: appointment.notes || '',
}
}
return (
<div className="space-y-6">
{/* Consultas Agendadas Section */}
<section className="bg-card shadow-md rounded-lg border border-border p-4 sm:p-5 md:p-6">
<div className="space-y-4 sm:space-y-5 md:space-y-6">
<header>
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-foreground mb-1 sm:mb-2">Suas Consultas Agendadas</h2>
<p className="text-xs sm:text-sm md:text-base text-muted-foreground">Gerencie suas consultas confirmadas, pendentes ou canceladas.</p>
</header>
{/* Date Navigation */}
<div className="flex flex-col gap-2 sm:gap-3 rounded-2xl border border-primary/20 bg-linear-to-r from-primary/5 to-primary/10 p-3 sm:p-4 md:p-6 shadow-sm">
<div className="flex items-center gap-1.5 sm:gap-2 flex-wrap">
<Button
type="button"
variant="outline"
size="icon"
onClick={(e: any) => { e.stopPropagation(); e.preventDefault(); navigateDate('prev') }}
aria-label="Dia anterior"
className={`shadow-sm hover:bg-primary! hover:text-white! hover:border-primary! transition-all p-1.5 sm:p-2`}
>
<ChevronLeft className="h-4 w-4 sm:h-4 sm:w-4" />
</Button>
<span className="text-xs sm:text-sm md:text-base font-semibold text-foreground flex-1 sm:flex-none line-clamp-1">{formatDatePt(currentDate)}</span>
<Button
type="button"
variant="outline"
size="icon"
onClick={(e: any) => { e.stopPropagation(); e.preventDefault(); navigateDate('next') }}
aria-label="Próximo dia"
className={`shadow-sm hover:bg-primary! hover:text-white! hover:border-primary! transition-all p-1.5 sm:p-2`}
>
<ChevronRight className="h-4 w-4 sm:h-4 sm:w-4" />
</Button>
{isSelectedDateToday && (
<Button
type="button"
variant="outline"
size="sm"
onClick={goToToday}
disabled
className="border border-border/50 text-foreground focus-visible:ring-2 focus-visible:ring-primary/40 active:scale-[0.97] hover:bg-primary/5 hover:text-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-foreground text-xs px-2 py-1 h-auto"
>
Hoje
</Button>
)}
</div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground bg-background/50 px-3 py-1.5 rounded-lg w-fit">
<span className="text-primary font-semibold">{_todaysAppointments.length}</span> consulta{_todaysAppointments.length !== 1 ? 's' : ''} agendada{_todaysAppointments.length !== 1 ? 's' : ''}
</div>
</div>
{/* Appointments List */}
<div className="flex flex-col gap-6">
{loadingAppointments ? (
<div className="text-center py-10 text-muted-foreground">Carregando consultas...</div>
) : appointmentsError ? (
<div className="text-center py-10 text-red-600">{appointmentsError}</div>
) : (
(() => {
const todays = _todaysAppointments
if (!todays || todays.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 sm:py-16 px-3 sm:px-4">
<div className="rounded-full bg-primary/10 p-3 sm:p-4 mb-3 sm:mb-4">
<Calendar className="h-8 w-8 sm:h-10 sm:w-10 text-primary" />
</div>
<p className="text-lg sm:text-xl font-bold text-foreground mb-1 sm:mb-2">Nenhuma consulta agendada para este dia</p>
<p className="text-sm sm:text-base text-muted-foreground text-center max-w-sm">Use a busca acima para marcar uma nova consulta ou navegue entre os dias.</p>
</div>
)
}
return todays.map((consulta: any) => (
<div
key={consulta.id}
className="rounded-2xl border border-primary/15 bg-card shadow-md hover:shadow-xl transition-all duration-300 p-4 sm:p-5 md:p-6 hover:border-primary/30 hover:bg-card/95"
>
<div className="grid gap-4 sm:gap-5 md:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-[1.5fr_0.8fr_1fr_1.2fr] items-start">
{/* Doctor Info */}
<div className="flex items-start gap-3 sm:gap-4 min-w-0">
<span
className="mt-1 sm:mt-2 h-3 w-3 sm:h-4 sm:w-4 shrink-0 rounded-full shadow-sm"
style={{ backgroundColor: (consulta.status === 'Confirmada' || consulta.status === 'confirmed') ? '#10b981' : '#ef4444' }}
aria-hidden
/>
<div className="space-y-2 sm:space-y-3 min-w-0">
<div className="font-bold flex items-center gap-1.5 sm:gap-2.5 text-foreground text-sm sm:text-base md:text-lg leading-tight">
<Stethoscope className="h-4 w-4 sm:h-5 sm:w-5 text-primary shrink-0" />
<span className="truncate">{consulta.medico}</span>
</div>
<p className="text-xs sm:text-sm text-muted-foreground wrap-break-word leading-relaxed">
<span className="font-medium text-foreground/70">{consulta.especialidade}</span>
<span className="mx-1 sm:mx-1.5"></span>
<span>{consulta.local}</span>
</p>
</div>
</div>
{/* Time */}
<div className="flex items-center justify-start gap-2 sm:gap-2.5 text-foreground">
<Clock className="h-4 w-4 sm:h-5 sm:w-5 text-primary shrink-0" />
<span className="font-bold text-sm sm:text-base md:text-lg">{consulta.hora}</span>
</div>
{/* Status Badge */}
<div className="flex items-center justify-start">
<span className={`px-2 sm:px-3 md:px-4 py-1.5 sm:py-2 md:py-2.5 rounded-full text-xs font-bold text-white shadow-md transition-all ${
consulta.status === 'Confirmada' || consulta.status === 'confirmed'
? 'bg-linear-to-r from-green-500 to-green-600 shadow-green-500/20'
: 'bg-linear-to-r from-red-500 to-red-600 shadow-red-500/20'
}`}>
{statusLabel(consulta.status)}
</span>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row items-stretch gap-2">
<Button
type="button"
size="sm"
className="border border-primary/30 text-primary bg-primary/5 hover:bg-primary! hover:text-white! hover:border-primary! transition-all duration-200 focus-visible:ring-2 focus-visible:ring-primary/40 active:scale-95 text-xs sm:text-xs font-semibold flex-1"
onClick={() => setSelectedAppointment(consulta)}
>
Detalhes
</Button>
{/* Reagendar removed by request */}
{consulta.status !== 'Cancelada' && consulta.status !== 'cancelled' && (
<Button
type="button"
size="sm"
className="border border-destructive/30 text-destructive bg-destructive/5 hover:bg-destructive! hover:text-white! hover:border-destructive! transition-all duration-200 focus-visible:ring-2 focus-visible:ring-destructive/40 active:scale-95 text-xs sm:text-xs font-semibold flex-1"
onClick={async () => {
try {
const ok = typeof window !== 'undefined' ? window.confirm('Deseja realmente cancelar esta consulta?') : true
if (!ok) return
// Prefer PATCH to mark appointment as cancelled (safer under RLS)
try {
await atualizarAgendamento(consulta.id, {
cancelled_at: new Date().toISOString(),
status: 'cancelled',
cancellation_reason: 'Cancelado pelo paciente'
})
} catch (patchErr) {
// Fallback: try hard delete if server allows it
try {
await deletarAgendamento(consulta.id)
} catch (delErr) {
// Re-throw original patch error if both fail
throw patchErr || delErr
}
}
// remove from local list so UI updates immediately
setAppointments((prev) => {
if (!prev) return prev
return prev.filter((a: any) => String(a.id) !== String(consulta.id))
})
// if modal open for this appointment, close it
if (selectedAppointment && String(selectedAppointment.id) === String(consulta.id)) setSelectedAppointment(null)
// Optionally persist to deleted cache to help client-side filtering
try { addDeletedAppointmentId(consulta.id) } catch(e) {}
setToast({ type: 'success', msg: 'Consulta cancelada.' })
} catch (err: any) {
console.error('[Consultas] falha ao cancelar agendamento', err)
try { setToast({ type: 'error', msg: err?.message || 'Falha ao cancelar a consulta.' }) } catch (e) {}
}
}}
>
Cancelar
</Button>
)}
</div>
{/* Inline detalhes removed: modal will show details instead */}
</div>
</div>
))
})()
)}
</div>
</div>
</section>
<Dialog open={!!selectedAppointment} onOpenChange={open => !open && setSelectedAppointment(null)}>
<DialogContent className="w-full sm:mx-auto sm:my-8 max-w-3xl md:max-w-4xl lg:max-w-5xl max-h-[90vh] overflow-hidden sm:p-6 p-4">
<DialogHeader>
<DialogTitle>Detalhes da Consulta</DialogTitle>
<DialogDescription className="sr-only">Detalhes da consulta</DialogDescription>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-3 max-h-[70vh] overflow-y-auto text-sm text-foreground">
{selectedAppointment ? (
<>
<div className="space-y-3">
<div><span className="font-medium">Profissional:</span> {selectedAppointment.medico || '-'}</div>
<div><span className="font-medium">Especialidade:</span> {selectedAppointment.especialidade || '-'}</div>
</div>
<div className="space-y-3">
<div><span className="font-medium">Data:</span> {(function(d:any,h:any){ try{ const dt = new Date(String(d) + 'T' + String(h||'00:00')); return formatDatePt(dt) }catch(e){ return String(d||'-') } })(selectedAppointment.data, selectedAppointment.hora)}</div>
<div><span className="font-medium">Hora:</span> {selectedAppointment.hora || '-'}</div>
<div><span className="font-medium">Status:</span> {statusLabel(selectedAppointment.status) || '-'}</div>
</div>
</>
) : (
<div>Carregando...</div>
)}
</div>
</DialogHeader>
<DialogFooter className="flex flex-col sm:flex-row gap-2 sm:justify-end sm:items-center mt-4">
<div className="flex w-full sm:w-auto justify-between sm:justify-end gap-2">
<Button variant="outline" onClick={() => setSelectedAppointment(null)} className="transition duration-200 hover:bg-primary/10 hover:text-primary min-w-[110px]">
Fechar
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reagendar feature removed */}
</div>
)
}
// Selected report state
const [selectedReport, setSelectedReport] = useState<any | null>(null)
function ExamesLaudos() {
const router = useRouter()
const [reports, setReports] = useState<any[] | null>(null)
const [loadingReports, setLoadingReports] = useState(false)
const [reportsError, setReportsError] = useState<string | null>(null)
const [reportDoctorName, setReportDoctorName] = useState<string | null>(null)
const [doctorsMap, setDoctorsMap] = useState<Record<string, any>>({})
const [resolvingDoctors, setResolvingDoctors] = useState(false)
const [reportsPage, setReportsPage] = useState<number>(1)
const [reportsPerPage, setReportsPerPage] = useState<number>(5)
const [searchTerm, setSearchTerm] = useState<string>('')
const [remoteMatch, setRemoteMatch] = useState<any | null>(null)
const [searchingRemote, setSearchingRemote] = useState<boolean>(false)
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest' | 'custom'>('newest')
const [filterDate, setFilterDate] = useState<string>('')
// derived filtered list based on search term and date filters
const filteredReports = useMemo(() => {
if (!reports || !Array.isArray(reports)) return []
const qRaw = String(searchTerm || '').trim()
const q = qRaw.toLowerCase()
// If we have a remote-match result for this query, prefer it. remoteMatch
// may be a single report (for id-like queries) or an array (for doctor-name search).
const hexOnlyRaw = String(qRaw).replace(/[^0-9a-fA-F]/g, '')
// defensive: compute length via explicit number conversion to avoid any
// accidental transpilation/patch artifacts that could turn a comparison
// into an unexpected call. This avoids runtime "8 is not a function".
const hexLenRaw = (typeof hexOnlyRaw === 'string') ? hexOnlyRaw.length : (Number(hexOnlyRaw) || 0)
const looksLikeId = hexLenRaw >= 8
if (remoteMatch) {
if (Array.isArray(remoteMatch)) return remoteMatch
return [remoteMatch]
}
// Start with all reports or filtered by search
let filtered = !q ? reports : reports.filter((r: any) => {
try {
const id = r.id ? String(r.id).toLowerCase() : ''
const title = String(reportTitle(r) || '').toLowerCase()
const exam = String(r.exam || r.exame || r.report_type || r.especialidade || '').toLowerCase()
const date = String(r.report_date || r.created_at || r.data || '').toLowerCase()
const notes = String(r.content || r.body || r.conteudo || r.notes || r.observacoes || '').toLowerCase()
const cid = String(r.cid || r.cid_code || r.cidCode || r.cie || '').toLowerCase()
const diagnosis = String(r.diagnosis || r.diagnostico || r.diagnosis_text || r.diagnostico_text || '').toLowerCase()
const conclusion = String(r.conclusion || r.conclusao || r.conclusion_text || r.conclusao_text || '').toLowerCase()
const orderNumber = String(r.order_number || r.orderNumber || r.numero_pedido || '').toLowerCase()
// patient fields
const patientName = String(
r?.paciente?.full_name || r?.paciente?.nome || r?.patient?.full_name || r?.patient?.nome || r?.patient_name || r?.patient_full_name || ''
).toLowerCase()
// requester/executor fields
const requestedBy = String(r.requested_by_name || r.requested_by || r.requester_name || r.requester || '').toLowerCase()
const executor = String(r.executante || r.executante_name || r.executor || r.executor_name || '').toLowerCase()
// try to resolve doctor name from map when available
const maybeId = r?.doctor_id || r?.created_by || r?.doctor || null
const doctorName = maybeId ? String(doctorsMap[String(maybeId)]?.full_name || doctorsMap[String(maybeId)]?.name || '').toLowerCase() : ''
// build search corpus
const corpus = [id, title, exam, date, notes, cid, diagnosis, conclusion, orderNumber, patientName, requestedBy, executor, doctorName].join(' ')
return corpus.includes(q)
} catch (e) {
return false
}
})
// Apply date filter if specified
if (filterDate) {
const filterDateObj = new Date(filterDate)
filterDateObj.setHours(0, 0, 0, 0)
filtered = filtered.filter((r: any) => {
const reportDateObj = new Date(r.report_date || r.created_at || Date.now())
reportDateObj.setHours(0, 0, 0, 0)
return reportDateObj.getTime() === filterDateObj.getTime()
})
}
// Apply sorting
const sorted = [...filtered]
if (sortOrder === 'newest') {
sorted.sort((a: any, b: any) => {
const dateA = new Date(a.report_date || a.created_at || 0).getTime()
const dateB = new Date(b.report_date || b.created_at || 0).getTime()
return dateB - dateA // Newest first
})
} else if (sortOrder === 'oldest') {
sorted.sort((a: any, b: any) => {
const dateA = new Date(a.report_date || a.created_at || 0).getTime()
const dateB = new Date(b.report_date || b.created_at || 0).getTime()
return dateA - dateB // Oldest first
})
}
return sorted
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reports, searchTerm, doctorsMap, remoteMatch, sortOrder, filterDate])
// When the search term looks like an id, attempt a direct fetch using the reports API
useEffect(() => {
let mounted = true
const q = String(searchTerm || '').trim()
if (!q) {
setRemoteMatch(null)
setSearchingRemote(false)
return
}
// heuristic: id-like strings contain many hex characters (UUID-like) —
// avoid calling RegExp.test/match to sidestep any env/type issues here.
const hexOnly = String(q).replace(/[^0-9a-fA-F]/g, '')
// defensive length computation as above
const hexLen = (typeof hexOnly === 'string') ? hexOnly.length : (Number(hexOnly) || 0)
const looksLikeId = hexLen >= 8
// If it looks like an id, try the single-report lookup. Otherwise, if it's a
// textual query, try searching doctors by full_name and then fetch reports
// authored/requested by those doctors.
;(async () => {
try {
setSearchingRemote(true)
setRemoteMatch(null)
if (looksLikeId && q) { // Adicionada verificação para q não ser vazio
const r = await buscarRelatorioPorId(q).catch(() => null)
if (!mounted) return
if (r) setRemoteMatch(r)
return
}
// textual search: try to find doctors whose full_name matches the query
// and then fetch reports for those doctors. Only run for reasonably
// long queries to avoid excessive network calls.
if (q.length >= 2) {
const docs = await buscarMedicos(q).catch(() => [])
if (!mounted) return
if (docs && Array.isArray(docs) && docs.length) {
// fetch reports for matching doctors in parallel. Some report rows
// reference the doctor's account `user_id` in `requested_by` while
// others reference the doctor's record `id`. Try both per doctor.
const promises = docs.map(async (d: any) => {
try {
const byId = await listarRelatoriosPorMedico(String(d.id)).catch(() => [])
if (Array.isArray(byId) && byId.length) return byId
// fallback: if the doctor record has a user_id, try that too
if (d && (d.user_id || d.userId)) {
const byUser = await listarRelatoriosPorMedico(String(d.user_id || d.userId)).catch(() => [])
if (Array.isArray(byUser) && byUser.length) return byUser
}
return []
} catch (e) {
return []
}
})
const arrays = await Promise.all(promises)
if (!mounted) return
const combined = ([] as any[]).concat(...arrays)
// dedupe by report id
const seen = new Set<string>()
const unique: any[] = []
for (const rr of combined) {
try {
const rid = String(rr.id)
if (!seen.has(rid)) {
seen.add(rid)
unique.push(rr)
}
} catch (e) {
// skip malformed item
}
}
if (unique.length) setRemoteMatch(unique)
else setRemoteMatch(null)
return
}
}
// nothing useful found
if (mounted) setRemoteMatch(null)
} catch (e) {
if (mounted) setRemoteMatch(null)
} finally {
if (mounted) setSearchingRemote(false)
}
})()
return () => { mounted = false }
}, [searchTerm])
// Helper to derive a human-friendly title for a report/laudo
const reportTitle = (rep: any, preferDoctorName?: string | null) => {
if (!rep) return 'Laudo'
// prefer a resolved doctor name when we have a map
try {
const maybeId = rep?.doctor_id ?? rep?.created_by ?? rep?.doctor ?? null
if (maybeId) {
const doc = doctorsMap[String(maybeId)]
if (doc) {
const name = doc.full_name || doc.name || doc.fullName || doc.doctor_name || null
if (name) return String(name)
}
}
} catch (e) {
// ignore
}
// Try common fields that may contain the doctor's/author name first
const tryKeys = [
'doctor_name', 'doctor_full_name', 'doctorFullName', 'doctorName',
'requested_by_name', 'requested_by', 'requester_name', 'requester',
'created_by_name', 'created_by', 'executante', 'executante_name',
'title', 'name', 'report_name', 'report_title'
]
for (const k of tryKeys) {
const v = rep[k]
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v)
}
if (preferDoctorName) return preferDoctorName
return 'Laudo'
}
// When reports are loaded, try to resolve doctor records for display
useEffect(() => {
let mounted = true
if (!reports || !Array.isArray(reports) || reports.length === 0) return
;(async () => {
try {
setResolvingDoctors(true)
const ids = Array.from(new Set(reports.map((r: any) => r.doctor_id || r.created_by || r.doctor).filter(Boolean).map(String)))
if (ids.length === 0) return
const docs = await buscarMedicosPorIds(ids).catch(() => [])
if (!mounted) return
const map: Record<string, any> = {}
// index returned docs by both their id and user_id (some reports store user_id)
for (const d of docs || []) {
if (!d) continue
try {
if (d.id !== undefined && d.id !== null) map[String(d.id)] = d
} catch {}
try {
if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d
} catch {}
}
// attempt per-id fallback for any unresolved ids (try getDoctorById)
const unresolved = ids.filter(i => !map[i])
if (unresolved.length) {
for (const u of unresolved) {
try {
const d = await getDoctorById(String(u)).catch(() => null)
if (d) {
if (d.id !== undefined && d.id !== null) map[String(d.id)] = d
if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d
}
} catch (e) {
// ignore per-id failure
}
}
}
// final fallback: try lookup by user_id (direct REST using baseHeaders)
const stillUnresolved = ids.filter(i => !map[i])
if (stillUnresolved.length) {
for (const u of stillUnresolved) {
try {
const token = (typeof window !== 'undefined') ? (localStorage.getItem('auth_token') || localStorage.getItem('token') || sessionStorage.getItem('auth_token') || sessionStorage.getItem('token')) : null
const headers: Record<string,string> = { apikey: ENV_CONFIG.SUPABASE_ANON_KEY, Accept: 'application/json' }
if (token) headers.Authorization = `Bearer ${token}`
const url = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/doctors?user_id=eq.${encodeURIComponent(String(u))}&limit=1`
const res = await fetch(url, { method: 'GET', headers })
if (!res || res.status >= 400) continue
const rows = await res.json().catch(() => [])
if (rows && Array.isArray(rows) && rows.length) {
const d = rows[0]
if (d) {
if (d.id !== undefined && d.id !== null) map[String(d.id)] = d
if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d
}
}
} catch (e) {
// ignore network errors
}
}
}
setDoctorsMap(map)
// After resolving doctor records, filter out reports whose doctor
// record doesn't have a user_id (doctor_userid). If a report's
// referenced doctor lacks user_id, we hide that laudo.
try {
const filtered = (reports || []).filter((r: any) => {
const maybeId = String(r?.doctor_id || r?.created_by || r?.doctor || '')
const doc = map[maybeId]
return !!(doc && (doc.user_id || (doc as any).user_id))
})
// Only update when different to avoid extra cycles
if (Array.isArray(filtered) && filtered.length !== (reports || []).length) {
setReports(filtered)
}
} catch (e) {
// ignore filtering errors
}
setResolvingDoctors(false)
} catch (e) {
// ignore resolution errors
setResolvingDoctors(false)
}
})()
return () => { mounted = false }
}, [reports])
useEffect(() => {
let mounted = true
if (!patientId) return
setLoadingReports(true)
setReportsError(null)
;(async () => {
try {
const res = await listarRelatoriosPorPaciente(String(patientId)).catch(() => [])
if (!mounted) return
// If no reports, set empty and return
if (!Array.isArray(res) || res.length === 0) {
setReports([])
return
}
// Resolve referenced doctor ids and only keep reports whose
// referenced doctor record has a truthy user_id (i.e., created by a doctor)
try {
setResolvingDoctors(true)
const ids = Array.from(new Set((res as any[]).map((r:any) => r.doctor_id || r.created_by || r.doctor).filter(Boolean).map(String)))
const map: Record<string, any> = {}
if (ids.length) {
const docs = await buscarMedicosPorIds(ids).catch(() => [])
for (const d of docs || []) {
if (!d) continue
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = map[String(d.user_id)] || d } catch {}
}
// per-id fallback
const unresolved = ids.filter(i => !map[i])
if (unresolved.length) {
for (const u of unresolved) {
try {
const d = await getDoctorById(String(u)).catch(() => null)
if (d) {
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
}
} catch (e) {
// ignore
}
}
}
// REST fallback by user_id
const stillUnresolved = ids.filter(i => !map[i])
if (stillUnresolved.length) {
for (const u of stillUnresolved) {
try {
const token = (typeof window !== 'undefined') ? (localStorage.getItem('auth_token') || localStorage.getItem('token') || sessionStorage.getItem('auth_token') || sessionStorage.getItem('token')) : null
const headers: Record<string,string> = { apikey: ENV_CONFIG.SUPABASE_ANON_KEY, Accept: 'application/json' }
if (token) headers.Authorization = `Bearer ${token}`
const url = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/doctors?user_id=eq.${encodeURIComponent(String(u))}&limit=1`
const r = await fetch(url, { method: 'GET', headers })
if (!r || r.status >= 400) continue
const rows = await r.json().catch(() => [])
if (rows && Array.isArray(rows) && rows.length) {
const d = rows[0]
if (d) {
try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
}
}
} catch (e) {
// ignore
}
}
}
}
// Now filter reports to only those whose referenced doctor has user_id
const filtered = (res || []).filter((r: any) => {
const maybeId = String(r?.doctor_id || r?.created_by || r?.doctor || '')
const doc = map[maybeId]
return !!(doc && (doc.user_id || (doc as any).user_id))
})
// Update doctorsMap and reports
setDoctorsMap(map)
setReports(filtered)
setResolvingDoctors(false)
return
} catch (e) {
// If resolution fails, fall back to setting raw results
console.warn('[ExamesLaudos] falha ao resolver médicos para filtragem', e)
setReports(Array.isArray(res) ? res : [])
setResolvingDoctors(false)
return
}
} catch (err) {
console.warn('[ExamesLaudos] erro ao carregar laudos', err)
if (!mounted) return
setReportsError('Falha ao carregar laudos.')
} finally {
if (mounted) setLoadingReports(false)
}
})()
return () => { mounted = false }
}, [])
// When a report is selected, try to fetch doctor name if we have an id
useEffect(() => {
let mounted = true
if (!selectedReport) {
setReportDoctorName(null)
return
}
const maybeDoctorId = selectedReport.doctor_id || selectedReport.created_by || null
if (!maybeDoctorId) {
setReportDoctorName(null)
return
}
(async () => {
try {
const docs = await buscarMedicosPorIds([String(maybeDoctorId)]).catch(() => [])
if (!mounted) return
if (docs && docs.length) {
const doc0: any = docs[0]
setReportDoctorName(doc0.full_name || doc0.name || doc0.fullName || null)
return
}
// fallback: try single-id lookup
try {
const d = await getDoctorById(String(maybeDoctorId)).catch(() => null)
if (d && mounted) {
setReportDoctorName(d.full_name || d.name || d.fullName || null)
return
}
} catch (e) {
// ignore
}
// final fallback: query doctors by user_id
try {
const token = (typeof window !== 'undefined') ? (localStorage.getItem('auth_token') || localStorage.getItem('token') || sessionStorage.getItem('auth_token') || sessionStorage.getItem('token')) : null
const headers: Record<string,string> = { apikey: ENV_CONFIG.SUPABASE_ANON_KEY, Accept: 'application/json' }
if (token) headers.Authorization = `Bearer ${token}`
const url = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/doctors?user_id=eq.${encodeURIComponent(String(maybeDoctorId))}&limit=1`
const res = await fetch(url, { method: 'GET', headers })
if (res && res.status < 400) {
const rows = await res.json().catch(() => [])
if (rows && Array.isArray(rows) && rows.length) {
const d = rows[0]
if (d && mounted) setReportDoctorName(d.full_name || d.name || d.fullName || null)
}
}
} catch (e) {
// ignore
}
} catch (e) {
// ignore
}
})()
return () => { mounted = false }
}, [])
// reset pagination when reports change
useEffect(() => {
setReportsPage(1)
}, [reports])
return (<>
<section className="bg-card shadow-md rounded-lg border border-border p-3 sm:p-4 md:p-6">
<h2 className="text-xl sm:text-2xl md:text-2xl font-bold mb-4 sm:mb-5 md:mb-6">Laudos</h2>
<div className="space-y-3">
{/* Search box: allow searching by id, doctor, exam, date or text */}
<div className="mb-3 sm:mb-4 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<Input placeholder="Pesquisar laudo, médico, exame, data ou id" value={searchTerm} onChange={e => { setSearchTerm(e.target.value); setReportsPage(1) }} className="text-xs sm:text-sm" />
{searchTerm && (
<Button variant="ghost" onClick={() => { setSearchTerm(''); setReportsPage(1) }} className="text-xs sm:text-sm w-full sm:w-auto">Limpar</Button>
)}
</div>
{/* Date filter and sort controls */}
<div className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-center flex-wrap">
{/* Sort buttons */}
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant={sortOrder === 'newest' ? 'default' : 'outline'}
onClick={() => { setSortOrder('newest'); setReportsPage(1) }}
className="text-xs sm:text-sm"
>
Mais Recente
</Button>
<Button
size="sm"
variant={sortOrder === 'oldest' ? 'default' : 'outline'}
onClick={() => { setSortOrder('oldest'); setReportsPage(1) }}
className="text-xs sm:text-sm"
>
Mais Antigo
</Button>
</div>
{/* Date picker */}
<div className="flex gap-2 items-center">
<input
type="date"
value={filterDate}
onChange={(e) => { setFilterDate(e.target.value); setReportsPage(1) }}
className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 border border-border rounded bg-background"
/>
{filterDate && (
<Button
size="sm"
variant="ghost"
onClick={() => { setFilterDate(''); setReportsPage(1) }}
className="text-xs sm:text-sm"
>
</Button>
)}
</div>
</div>
{loadingReports ? (
<div className="text-center py-6 sm:py-8 text-xs sm:text-sm text-muted-foreground">{strings.carregando}</div>
) : reportsError ? (
<div className="text-center py-6 sm:py-8 text-xs sm:text-sm text-red-600">{reportsError}</div>
) : (!reports || reports.length === 0) ? (
<div className="text-center py-6 sm:py-8 text-xs sm:text-sm text-muted-foreground">Nenhum laudo encontrado para este paciente.</div>
) : (filteredReports.length === 0) ? (
searchingRemote ? (
<div className="text-center py-6 sm:py-8 text-xs sm:text-sm text-muted-foreground">Buscando laudo...</div>
) : (
<div className="text-center py-6 sm:py-8 text-xs sm:text-sm text-muted-foreground">Nenhum laudo corresponde à pesquisa.</div>
)
) : (
(() => {
const total = Array.isArray(filteredReports) ? filteredReports.length : 0
// enforce a maximum of 5 laudos per page
const perPage = Math.max(1, Math.min(reportsPerPage || 5, 5))
const totalPages = Math.max(1, Math.ceil(total / perPage))
// keep page inside bounds
const page = Math.min(Math.max(1, reportsPage), totalPages)
const start = (page - 1) * perPage
const end = start + perPage
const pageItems = (filteredReports || []).slice(start, end)
return (
<>
{pageItems.map((r) => (
<div key={r.id || JSON.stringify(r)} className="flex flex-col md:flex-row md:items-center md:justify-between bg-muted rounded p-3 sm:p-4 md:p-5 gap-3 md:gap-0">
<div className="min-w-0">
{(() => {
const maybeId = r?.doctor_id || r?.created_by || r?.doctor || null
if (resolvingDoctors && maybeId && !doctorsMap[String(maybeId)]) {
return <div className="font-medium text-xs sm:text-base md:text-lg text-muted-foreground">{strings.carregando}</div>
}
return <div className="font-medium text-foreground text-sm sm:text-lg md:text-lg truncate">{reportTitle(r)}</div>
})()}
<div className="text-xs sm:text-sm md:text-base text-muted-foreground mt-1">Data: {new Date(r.report_date || r.created_at || Date.now()).toLocaleDateString('pt-BR')}</div>
</div>
<div className="flex gap-2 w-full md:w-auto flex-col sm:flex-row">
<Button variant="outline" className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm w-full md:w-auto" onClick={async () => { router.push(`/laudos/${r.id}`); }}>{strings.visualizarLaudo}</Button>
</div>
</div>
))}
{/* Pagination controls */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-2 mt-4">
<div className="text-xs sm:text-sm text-muted-foreground">Mostrando {Math.min(start+1, total)}{Math.min(end, total)} de {total}</div>
<div className="flex items-center justify-between sm:justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => setReportsPage(p => Math.max(1, p-1))} disabled={page <= 1} className="px-2 sm:px-3 text-xs sm:text-sm">Anterior</Button>
<div className="text-xs sm:text-sm text-muted-foreground">{page} / {totalPages}</div>
<Button size="sm" variant="outline" onClick={() => setReportsPage(p => Math.min(totalPages, p+1))} disabled={page >= totalPages} className="px-2 sm:px-3 text-xs sm:text-sm">Próxima</Button>
</div>
</div>
</>
)
})()
)}
</div>
</section>
{/* Modal removed - now using dedicated page /app/laudos/[id] */}
</>
)
}
function Perfil() {
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 sm:gap-5 md:gap-6 px-3 sm:px-4 md:px-8 py-6 sm:py-8 md:py-10">
{/* Header com Título e Botão */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div className="min-w-0">
<h2 className="text-2xl sm:text-2xl md:text-3xl font-bold">Meu Perfil</h2>
<p className="text-xs sm:text-sm md:text-base text-muted-foreground mt-1">Bem-vindo à sua área exclusiva.</p>
</div>
{!isEditingProfile ? (
<Button
className="bg-blue-600 hover:bg-blue-700 w-full sm:w-auto whitespace-nowrap text-xs sm:text-sm"
onClick={() => setIsEditingProfile(true)}
>
Editar Perfil
</Button>
) : (
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
<Button
className="bg-green-600 hover:bg-green-700 text-xs sm:text-sm"
onClick={handleSaveProfile}
>
Salvar
</Button>
<Button
variant="outline"
className="text-xs sm:text-sm"
onClick={handleCancelEdit}
>
Cancelar
</Button>
</div>
)}
</div>
{/* Grid de 3 colunas (2 + 1) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-5 md:gap-6">
{/* Coluna Esquerda - Informações Pessoais */}
<div className="lg:col-span-2 space-y-4 sm:space-y-5 md:space-y-6">
{/* Informações Pessoais */}
<div className="border border-border rounded-lg p-3 sm:p-4 md:p-6">
<h3 className="text-base sm:text-lg md:text-lg font-semibold mb-3 sm:mb-4">Informações Pessoais</h3>
<div className="space-y-3 sm:space-y-4">
{/* Nome Completo */}
<div>
<Label className="text-xs sm:text-sm md:text-sm font-medium text-muted-foreground">
Nome Completo
</Label>
<div className="mt-2 p-2 sm:p-3 bg-muted rounded text-xs sm:text-sm md:text-base text-foreground font-medium">
{profileData.nome || "Não preenchido"}
</div>
<p className="text-xs text-muted-foreground mt-1">
Este campo não pode ser alterado
</p>
</div>
{/* Email */}
<div>
<Label className="text-xs sm:text-sm md:text-sm font-medium text-muted-foreground">
Email
</Label>
<div className="mt-2 p-2 sm:p-3 bg-muted rounded text-xs sm:text-sm md:text-base text-foreground">
{profileData.email || "Não preenchido"}
</div>
<p className="text-xs text-muted-foreground mt-1">
Este campo não pode ser alterado
</p>
</div>
{/* Telefone */}
<div>
<Label className="text-xs sm:text-sm md:text-sm font-medium text-muted-foreground">
Telefone
</Label>
{isEditingProfile ? (
<Input
value={profileData.telefone || ""}
onChange={(e) => handleProfileChange('telefone', e.target.value)}
className="mt-2 text-xs sm:text-sm"
placeholder="(00) 00000-0000"
maxLength={15}
/>
) : (
<div className="mt-2 p-2 sm:p-3 bg-muted rounded text-xs sm:text-sm md:text-base text-foreground">
{profileData.telefone || "Não preenchido"}
</div>
)}
</div>
</div>
</div>
{/* Endereço e Contato */}
<div className="border border-border rounded-lg p-3 sm:p-4 md:p-6">
<h3 className="text-base sm:text-lg md:text-lg font-semibold mb-3 sm:mb-4">Endereço e Contato</h3>
<div className="space-y-3 sm:space-y-4">
{/* Logradouro */}
<div>
<Label className="text-xs sm:text-sm md:text-sm font-medium text-muted-foreground">
Logradouro
</Label>
{isEditingProfile ? (
<Input
value={profileData.endereco || ""}
onChange={(e) => handleProfileChange('endereco', e.target.value)}
className="mt-2 text-xs sm:text-sm"
placeholder="Rua, avenida, etc."
/>
) : (
<div className="mt-2 p-2 sm:p-3 bg-muted rounded text-xs sm:text-sm md:text-base text-foreground">
{profileData.endereco || "Não preenchido"}
</div>
)}
</div>
{/* Cidade */}
<div>
<Label className="text-xs sm:text-sm md:text-sm font-medium text-muted-foreground">
Cidade
</Label>
{isEditingProfile ? (
<Input
value={profileData.cidade || ""}
onChange={(e) => handleProfileChange('cidade', e.target.value)}
className="mt-2 text-xs sm:text-sm"
placeholder="São Paulo"
/>
) : (
<div className="mt-2 p-2 sm:p-3 bg-muted rounded text-xs sm:text-sm md:text-base text-foreground">
{profileData.cidade || "Não preenchido"}
</div>
)}
</div>
{/* CEP */}
<div>
<Label className="text-xs sm:text-sm md:text-sm font-medium text-muted-foreground">
CEP
</Label>
{isEditingProfile ? (
<Input
value={profileData.cep || ""}
onChange={(e) => handleProfileChange('cep', e.target.value)}
className="mt-2 text-xs sm:text-sm"
placeholder="00000-000"
/>
) : (
<div className="mt-2 p-2 sm:p-3 bg-muted rounded text-xs sm:text-sm md:text-base text-foreground">
{profileData.cep || "Não preenchido"}
</div>
)}
</div>
</div>
</div>
</div>
{/* Coluna Direita - Foto do Perfil */}
<div>
<div className="border border-border rounded-lg p-3 sm:p-4 md:p-6">
<h3 className="text-base sm:text-lg md:text-lg font-semibold mb-3 sm:mb-4">Foto do Perfil</h3>
<div className="flex flex-col items-center gap-3 sm:gap-4">
<Avatar className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28">
<AvatarImage src={profileData.foto_url} alt={profileData.nome || 'Avatar'} />
<AvatarFallback className="bg-primary text-primary-foreground text-lg sm:text-xl md:text-2xl font-bold">
{profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'PC'}
</AvatarFallback>
</Avatar>
<div className="text-center space-y-2">
<p className="text-xs sm:text-sm md:text-base text-muted-foreground">
{profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'PC'}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
// Renderização principal
return (
<ProtectedRoute requiredUserType={["paciente"]}>
<div className="container mx-auto px-2 sm:px-4 py-6 sm:py-8">
{/* Header com informações do paciente */}
<header className="sticky top-0 z-40 bg-card shadow-md rounded-lg border border-border p-2 sm:p-3 md:p-4 mb-4 sm:mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
{/* Logo MEDIConnect */}
<div className="flex items-center gap-2 mr-2 sm:mr-3 shrink-0">
<div className="w-8 h-8 sm:w-9 sm:h-9 bg-primary rounded-lg flex items-center justify-center">
<Stethoscope className="w-4 h-4 sm:w-5 sm:h-5 text-primary-foreground" />
</div>
<span className="text-base sm:text-sm font-semibold text-foreground hidden sm:inline">
MEDIConnect
</span>
</div>
<div className="h-6 w-px bg-border hidden sm:block"></div>
<Avatar className="h-10 w-10 sm:h-10 sm:w-10 shrink-0">
<AvatarImage src={profileData.foto_url} alt={profileData.nome || 'Avatar'} />
<AvatarFallback className="bg-primary text-white font-bold text-xs sm:text-sm">{profileData.nome?.charAt(0) || 'P'}</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-xs text-muted-foreground truncate">Conta do paciente</span>
<span className="font-bold text-xs sm:text-sm leading-tight truncate">{profileData.nome || 'Paciente'}</span>
<span className="text-xs text-muted-foreground truncate">{profileData.email || 'Email não disponível'}</span>
</div>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto shrink-0">
<SimpleThemeToggle />
<Button asChild variant="outline" className="hover:bg-blue-500 hover:text-white transition-colors flex-1 sm:flex-none text-xs sm:text-sm py-1.5 sm:py-2 h-8 sm:h-9 px-2 sm:px-3">
<Link href="/">
<Home className="h-3 w-3 sm:h-4 sm:w-4" /> <span className="hidden sm:inline ml-1">Início</span>
</Link>
</Button>
<Button
onClick={handleLogout}
variant="outline"
aria-label={strings.sair}
disabled={loading}
className="text-destructive border-destructive hover:bg-destructive/20 hover:text-destructive transition-colors text-xs sm:text-sm py-1.5 sm:py-2 h-8 sm:h-9 px-2 sm:px-3"
>
<LogOut className="h-3 w-3 sm:h-4 sm:w-4" /> <span className="hidden sm:inline ml-1">{strings.sair}</span>
</Button>
</div>
</header>
{/* Layout com sidebar e conteúdo */}
<div className="grid grid-cols-1 md:grid-cols-[200px_1fr] lg:grid-cols-[220px_1fr] gap-4 sm:gap-5 md:gap-6">
{/* Sidebar vertical - sticky */}
<aside className="sticky top-24 h-fit md:top-24 z-40">
<nav aria-label="Navegação do dashboard" className="relative isolate bg-card shadow-lg rounded-lg border border-border p-1.5 sm:p-2 md:p-3 z-50">
<div className="grid grid-cols-2 md:grid-cols-1 gap-1 sm:gap-1.5">
<Button
variant={tab==='dashboard'?'default':'ghost'}
aria-current={tab==='dashboard'}
onClick={()=>setTab('dashboard')}
className={`flex flex-col md:flex-row items-center justify-center md:justify-start gap-0.5 md:gap-2 transition-colors hover:bg-primary! hover:text-white! cursor-pointer text-xs px-1.5 sm:px-3 py-1.5 sm:py-2 h-auto`}
title="Dashboard"
>
<Calendar className="h-4 w-4 sm:h-4 sm:w-4 md:h-4 md:w-4 flex-shrink-0" /><span className="text-xs sm:text-sm">{strings.dashboard}</span>
</Button>
<Button
variant={tab==='consultas'?'default':'ghost'}
aria-current={tab==='consultas'}
onClick={()=>setTab('consultas')}
className={`flex flex-col md:flex-row items-center justify-center md:justify-start gap-0.5 md:gap-2 transition-colors hover:bg-primary! hover:text-white! cursor-pointer text-xs px-1.5 sm:px-3 py-1.5 sm:py-2 h-auto`}
title="Consultas"
>
<Calendar className="h-4 w-4 sm:h-4 sm:w-4 md:h-4 md:w-4 flex-shrink-0" /><span className="text-xs sm:text-sm">{strings.consultas}</span>
</Button>
<Button
variant={tab==='exames'?'default':'ghost'}
aria-current={tab==='exames'}
onClick={()=>setTab('exames')}
className={`flex flex-col md:flex-row items-center justify-center md:justify-start gap-0.5 md:gap-2 transition-colors hover:bg-primary! hover:text-white! cursor-pointer text-xs px-1.5 sm:px-3 py-1.5 sm:py-2 h-auto`}
title="Exames"
>
<FileText className="h-4 w-4 sm:h-4 sm:w-4 md:h-4 md:w-4 flex-shrink-0" /><span className="text-xs sm:text-sm">{strings.exames}</span>
</Button>
<Button
variant={tab==='perfil'?'default':'ghost'}
aria-current={tab==='perfil'}
onClick={()=>setTab('perfil')}
className={`flex flex-col md:flex-row items-center justify-center md:justify-start gap-0.5 md:gap-2 transition-colors hover:bg-primary! hover:text-white! cursor-pointer text-xs px-1.5 sm:px-3 py-1.5 sm:py-2 h-auto`}
title="Perfil"
>
<UserCog className="h-4 w-4 sm:h-4 sm:w-4 md:h-4 md:w-4 flex-shrink-0" /><span className="text-xs sm:text-sm">{strings.perfil}</span>
</Button>
</div>
</nav>
</aside>
{/* Conteúdo principal */}
<main className="flex-1 w-full">
{/* Toasts de feedback */}
{toast && (
<div className={`fixed top-24 right-2 sm:right-4 z-50 px-3 sm:px-4 py-2 rounded shadow-lg text-xs sm:text-sm ${toast.type==='success'?'bg-green-600 text-white':'bg-red-600 text-white'}`} role="alert">{toast.msg}</div>
)}
{/* Loader global */}
{loading && <div className="flex-1 flex items-center justify-center"><span className="text-xs sm:text-sm">{strings.carregando}</span></div>}
{error && <div className="flex-1 flex items-center justify-center text-red-600"><span className="text-xs sm:text-sm">{error}</span></div>}
{/* Conteúdo principal */}
{!loading && !error && (
<>
{tab==='dashboard' && <DashboardCards />}
{tab==='consultas' && <Consultas />}
{tab==='exames' && <ExamesLaudos />}
{tab==='perfil' && <Perfil />}
</>
)}
</main>
</div>
</div>
</ProtectedRoute>
)
}