Merge pull request 'backup/fix-patient-page' (#59) from backup/fix-patient-page into develop

Reviewed-on: #59
This commit is contained in:
M-Gabrielly 2025-10-23 17:48:58 +00:00
commit 770eab9afe
5 changed files with 625 additions and 245 deletions

View File

@ -19,14 +19,48 @@ import { listarMedicos, excluirMedico, buscarMedicos, buscarMedicoPorId, buscarP
import { listAssignmentsForUser } from '@/lib/assignment'; import { listAssignmentsForUser } from '@/lib/assignment';
function normalizeMedico(m: any): Medico { function normalizeMedico(m: any): Medico {
const normalizeSex = (v: any) => {
if (v === null || typeof v === 'undefined') return null;
const s = String(v || '').trim().toLowerCase();
if (!s) return null;
const male = new Set(['m','masc','male','masculino','homem','h','1','mas']);
const female = new Set(['f','fem','female','feminino','mulher','mul','2','fem']);
const other = new Set(['o','outro','other','3','nb','nonbinary','nao binario','não binário']);
if (male.has(s)) return 'masculino';
if (female.has(s)) return 'feminino';
if (other.has(s)) return 'outro';
if (['masculino','feminino','outro'].includes(s)) return s;
return null;
};
const formatBirth = (v: any) => {
if (!v && typeof v !== 'string') return null;
const s = String(v || '').trim();
if (!s) return null;
const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (iso) {
const [, y, mth, d] = iso;
return `${d.padStart(2,'0')}/${mth.padStart(2,'0')}/${y}`;
}
const ddmmyyyy = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (ddmmyyyy) return s;
const parsed = new Date(s);
if (!isNaN(parsed.getTime())) {
const d = String(parsed.getDate()).padStart(2,'0');
const mth = String(parsed.getMonth() + 1).padStart(2,'0');
const y = String(parsed.getFullYear());
return `${d}/${mth}/${y}`;
}
return null;
};
return { return {
id: String(m.id ?? m.uuid ?? ""), id: String(m.id ?? m.uuid ?? ""),
full_name: m.full_name ?? m.nome ?? "", // 👈 Correção: usar full_name como padrão full_name: m.full_name ?? m.nome ?? "", // 👈 Correção: usar full_name como padrão
nome_social: m.nome_social ?? m.social_name ?? null, nome_social: m.nome_social ?? m.social_name ?? null,
cpf: m.cpf ?? "", cpf: m.cpf ?? "",
rg: m.rg ?? m.document_number ?? null, rg: m.rg ?? m.document_number ?? null,
sexo: m.sexo ?? m.sex ?? null, sexo: normalizeSex(m.sexo ?? m.sex ?? m.sexualidade ?? null),
data_nascimento: m.data_nascimento ?? m.birth_date ?? null, data_nascimento: formatBirth(m.data_nascimento ?? m.birth_date ?? m.birthDate ?? null),
telefone: m.telefone ?? m.phone_mobile ?? "", telefone: m.telefone ?? m.phone_mobile ?? "",
celular: m.celular ?? m.phone2 ?? null, celular: m.celular ?? m.phone2 ?? null,
contato_emergencia: m.contato_emergencia ?? null, contato_emergencia: m.contato_emergencia ?? null,

View File

@ -1,7 +1,7 @@
'use client' 'use client'
// import { useAuth } from '@/hooks/useAuth' // removido duplicado
import { useState } from 'react' import type { ReactNode } from 'react'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
@ -12,10 +12,13 @@ import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { User, LogOut, Calendar, FileText, MessageCircle, UserCog, Home, Clock, FolderOpen, ChevronLeft, ChevronRight, MapPin, Stethoscope } from 'lucide-react' import { User, LogOut, Calendar, FileText, MessageCircle, UserCog, Home, Clock, FolderOpen, ChevronLeft, ChevronRight, MapPin, Stethoscope } from 'lucide-react'
import { SimpleThemeToggle } from '@/components/simple-theme-toggle' import { SimpleThemeToggle } from '@/components/simple-theme-toggle'
import { UploadAvatar } from '@/components/ui/upload-avatar'
import Link from 'next/link' import Link from 'next/link'
import ProtectedRoute from '@/components/ProtectedRoute' import ProtectedRoute from '@/components/ProtectedRoute'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { buscarPacientes, buscarPacientePorUserId, getUserInfo, listarMensagensPorPaciente } from '@/lib/api'
import { useReports } from '@/hooks/useReports'
// Simulação de internacionalização básica // Simulação de internacionalização básica
const strings = { const strings = {
dashboard: 'Dashboard', dashboard: 'Dashboard',
@ -57,8 +60,6 @@ export default function PacientePage() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [toast, setToast] = useState<{type: 'success'|'error', msg: string}|null>(null) const [toast, setToast] = useState<{type: 'success'|'error', msg: string}|null>(null)
// Acessibilidade: foco visível e ordem de tabulação garantidos por padrão nos botões e inputs
const handleLogout = async () => { const handleLogout = async () => {
setLoading(true) setLoading(true)
setError('') setError('')
@ -73,18 +74,167 @@ export default function PacientePage() {
// Estado para edição do perfil // Estado para edição do perfil
const [isEditingProfile, setIsEditingProfile] = useState(false) const [isEditingProfile, setIsEditingProfile] = useState(false)
const [profileData, setProfileData] = useState({ const [profileData, setProfileData] = useState<any>({
nome: "Maria Silva Santos", nome: '',
email: user?.email || "paciente@example.com", email: user?.email || '',
telefone: "(11) 99999-9999", telefone: '',
endereco: "Rua das Flores, 123", endereco: '',
cidade: "São Paulo", cidade: '',
cep: "01234-567", cep: '',
biografia: "Paciente desde 2020. Histórico de consultas e exames regulares.", biografia: '',
id: undefined,
foto_url: undefined,
}) })
const [patientId, setPatientId] = useState<string | null>(null)
// 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 }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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])
const handleProfileChange = (field: string, value: string) => { const handleProfileChange = (field: string, value: string) => {
setProfileData(prev => ({ ...prev, [field]: value })) setProfileData((prev: any) => ({ ...prev, [field]: value }))
} }
const handleSaveProfile = () => { const handleSaveProfile = () => {
setIsEditingProfile(false) setIsEditingProfile(false)
@ -399,136 +549,58 @@ export default function PacientePage() {
) )
} }
// Exames e laudos fictícios // Reports (laudos) hook
const examesFicticios = [ const { reports, loadReportsByPatient, loading: reportsLoading } = useReports()
{ const [selectedReport, setSelectedReport] = useState<any | null>(null)
id: 1,
nome: "Hemograma Completo",
data: "2025-09-20",
status: "Disponível",
prontuario: "Paciente apresenta hemograma dentro dos padrões de normalidade. Sem alterações significativas.",
},
{
id: 2,
nome: "Raio-X de Tórax",
data: "2025-08-10",
status: "Disponível",
prontuario: "Exame radiológico sem evidências de lesões pulmonares. Estruturas cardíacas normais.",
},
{
id: 3,
nome: "Eletrocardiograma",
data: "2025-07-05",
status: "Disponível",
prontuario: "Ritmo sinusal, sem arritmias. Exame dentro da normalidade.",
},
];
const laudosFicticios = [
{
id: 1,
nome: "Laudo Hemograma Completo",
data: "2025-09-21",
status: "Assinado",
laudo: "Hemoglobina, hematócrito, leucócitos e plaquetas dentro dos valores de referência. Sem anemias ou infecções detectadas.",
},
{
id: 2,
nome: "Laudo Raio-X de Tórax",
data: "2025-08-11",
status: "Assinado",
laudo: "Radiografia sem alterações. Parênquima pulmonar preservado. Ausência de derrame pleural.",
},
{
id: 3,
nome: "Laudo Eletrocardiograma",
data: "2025-07-06",
status: "Assinado",
laudo: "ECG normal. Não há sinais de isquemia ou sobrecarga.",
},
];
const [exameSelecionado, setExameSelecionado] = useState<null | typeof examesFicticios[0]>(null)
const [laudoSelecionado, setLaudoSelecionado] = useState<null | typeof laudosFicticios[0]>(null)
function ExamesLaudos() { function ExamesLaudos() {
useEffect(() => {
if (!patientId) return
// load laudos for this patient
loadReportsByPatient(patientId).catch(() => {})
}, [patientId])
return ( return (
<section className="bg-card shadow-md rounded-lg border border-border p-6"> <section className="bg-card shadow-md rounded-lg border border-border p-6">
<h2 className="text-2xl font-bold mb-6">Exames</h2>
<div className="mb-8">
<h3 className="text-lg font-semibold mb-2">Meus Exames</h3>
<div className="space-y-3">
{examesFicticios.map(exame => (
<div key={exame.id} className="flex flex-col md:flex-row md:items-center md:justify-between bg-muted rounded p-4">
<div>
<div className="font-medium text-foreground">{exame.nome}</div>
<div className="text-sm text-muted-foreground">Data: {new Date(exame.data).toLocaleDateString('pt-BR')}</div>
</div>
<div className="flex gap-2 mt-2 md:mt-0">
<Button variant="outline" onClick={() => setExameSelecionado(exame)}>Ver Prontuário</Button>
<Button variant="secondary">Download</Button>
</div>
</div>
))}
</div>
</div>
<h2 className="text-2xl font-bold mb-6">Laudos</h2> <h2 className="text-2xl font-bold mb-6">Laudos</h2>
<div>
<h3 className="text-lg font-semibold mb-2">Meus Laudos</h3> {reportsLoading ? (
<div className="text-center py-8 text-muted-foreground">Carregando laudos...</div>
) : (!reports || reports.length === 0) ? (
<div className="text-center py-8 text-muted-foreground">Nenhum laudo salvo.</div>
) : (
<div className="space-y-3"> <div className="space-y-3">
{laudosFicticios.map(laudo => ( {reports.map((r: any) => (
<div key={laudo.id} className="flex flex-col md:flex-row md:items-center md:justify-between bg-muted rounded p-4"> <div key={r.id || r.order_number || JSON.stringify(r)} className="flex flex-col md:flex-row md:items-center md:justify-between bg-muted rounded p-4">
<div> <div>
<div className="font-medium text-foreground">{laudo.nome}</div> <div className="font-medium text-foreground">{r.title || r.report_type || r.exame || r.name || 'Laudo'}</div>
<div className="text-sm text-muted-foreground">Data: {new Date(laudo.data).toLocaleDateString('pt-BR')}</div> <div className="text-sm text-muted-foreground">Data: {new Date(r.report_date || r.data || r.created_at || Date.now()).toLocaleDateString('pt-BR')}</div>
</div> </div>
<div className="flex gap-2 mt-2 md:mt-0"> <div className="flex gap-2 mt-2 md:mt-0">
<Button variant="outline" onClick={() => setLaudoSelecionado(laudo)}>Visualizar</Button> <Button variant="outline" onClick={async () => { setSelectedReport(r); }}>Visualizar</Button>
<Button variant="secondary">Compartilhar</Button> <Button variant="secondary" onClick={async () => { try { await navigator.clipboard.writeText(JSON.stringify(r)); setToast({ type: 'success', msg: 'Laudo copiado (debug).' }) } catch { setToast({ type: 'error', msg: 'Falha ao copiar.' }) } }}>Compartilhar</Button>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div>
{/* Modal Prontuário Exame */}
<Dialog open={!!exameSelecionado} onOpenChange={open => !open && setExameSelecionado(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Prontuário do Exame</DialogTitle>
<DialogDescription>
{exameSelecionado && (
<>
<div className="font-semibold mb-2">{exameSelecionado.nome}</div>
<div className="text-sm text-muted-foreground mb-4">Data: {new Date(exameSelecionado.data).toLocaleDateString('pt-BR')}</div>
<div className="mb-4 whitespace-pre-line">{exameSelecionado.prontuario}</div>
</>
)} )}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setExameSelecionado(null)}>Fechar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal Visualizar Laudo */} <Dialog open={!!selectedReport} onOpenChange={open => !open && setSelectedReport(null)}>
<Dialog open={!!laudoSelecionado} onOpenChange={open => !open && setLaudoSelecionado(null)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Laudo Médico</DialogTitle> <DialogTitle>Laudo Médico</DialogTitle>
<DialogDescription> <DialogDescription>
{laudoSelecionado && ( {selectedReport && (
<> <>
<div className="font-semibold mb-2">{laudoSelecionado.nome}</div> <div className="font-semibold mb-2">{selectedReport.title || selectedReport.report_type || selectedReport.exame || 'Laudo'}</div>
<div className="text-sm text-muted-foreground mb-4">Data: {new Date(laudoSelecionado.data).toLocaleDateString('pt-BR')}</div> <div className="text-sm text-muted-foreground mb-4">Data: {new Date(selectedReport.report_date || selectedReport.data || selectedReport.created_at || Date.now()).toLocaleDateString('pt-BR')}</div>
<div className="mb-4 whitespace-pre-line">{laudoSelecionado.laudo}</div> <div className="mb-4 whitespace-pre-line">{selectedReport.content || selectedReport.laudo || selectedReport.body || JSON.stringify(selectedReport, null, 2)}</div>
</> </>
)} )}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setLaudoSelecionado(null)}>Fechar</Button> <Button variant="outline" onClick={() => setSelectedReport(null)}>Fechar</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -536,54 +608,51 @@ export default function PacientePage() {
) )
} }
// Mensagens fictícias recebidas do médico
const mensagensFicticias = [
{
id: 1,
medico: "Dr. Carlos Andrade",
data: "2025-10-06T15:30:00",
conteudo: "Olá Maria, seu exame de hemograma está normal. Parabéns por manter seus exames em dia!",
lida: false
},
{
id: 2,
medico: "Dra. Fernanda Lima",
data: "2025-09-21T10:15:00",
conteudo: "Maria, seu laudo de Raio-X já está disponível no sistema. Qualquer dúvida, estou à disposição.",
lida: true
},
{
id: 3,
medico: "Dr. João Silva",
data: "2025-08-12T09:00:00",
conteudo: "Bom dia! Lembre-se de agendar seu retorno para acompanhamento da ortopedia.",
lida: true
},
];
function Mensagens() { function Mensagens() {
const [msgs, setMsgs] = useState<any[]>([])
const [loadingMsgs, setLoadingMsgs] = useState(false)
const [msgsError, setMsgsError] = useState<string | null>(null)
useEffect(() => {
let mounted = true
if (!patientId) return
setLoadingMsgs(true)
setMsgsError(null)
listarMensagensPorPaciente(String(patientId))
.then(res => {
if (!mounted) return
setMsgs(Array.isArray(res) ? res : [])
})
.catch(err => {
console.warn('[Mensagens] erro ao carregar mensagens', err)
if (!mounted) return
setMsgsError('Falha ao carregar mensagens.')
})
.finally(() => { if (mounted) setLoadingMsgs(false) })
return () => { mounted = false }
}, [patientId])
return ( return (
<section className="bg-card shadow-md rounded-lg border border-border p-6"> <section className="bg-card shadow-md rounded-lg border border-border p-6">
<h2 className="text-2xl font-bold mb-6">Mensagens Recebidas</h2> <h2 className="text-2xl font-bold mb-6">Mensagens Recebidas</h2>
<div className="space-y-3"> <div className="space-y-3">
{mensagensFicticias.length === 0 ? ( {loadingMsgs ? (
<div className="text-center py-8 text-gray-600 dark:text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">Carregando mensagens...</div>
<MessageCircle className="h-12 w-12 mx-auto mb-4 text-gray-400 dark:text-muted-foreground/50" /> ) : msgsError ? (
<p className="text-lg mb-2">Nenhuma mensagem recebida</p> <div className="text-center py-8 text-red-600">{msgsError}</div>
<p className="text-sm">Você ainda não recebeu mensagens dos seus médicos.</p> ) : (!msgs || msgs.length === 0) ? (
</div> <div className="text-center py-8 text-muted-foreground">Nenhuma mensagem encontrada.</div>
) : ( ) : (
mensagensFicticias.map(msg => ( msgs.map((msg: any) => (
<div key={msg.id} className={`flex flex-col md:flex-row md:items-center md:justify-between bg-muted rounded p-4 border ${!msg.lida ? 'border-primary' : 'border-transparent'}`}> <div key={msg.id || JSON.stringify(msg)} className="bg-muted rounded p-4">
<div>
<div className="font-medium text-foreground flex items-center gap-2"> <div className="font-medium text-foreground flex items-center gap-2">
<User className="h-4 w-4 text-primary" /> <User className="h-4 w-4 text-primary" />
{msg.medico} {msg.sender_name || msg.from || msg.doctor_name || 'Remetente'}
{!msg.lida && <span className="ml-2 px-2 py-0.5 rounded-full text-xs bg-primary text-white">Nova</span>} {!msg.read && <span className="ml-2 px-2 py-0.5 rounded-full text-xs bg-primary text-white">Nova</span>}
</div>
<div className="text-sm text-muted-foreground mb-2">{new Date(msg.data).toLocaleString('pt-BR')}</div>
<div className="text-foreground whitespace-pre-line">{msg.conteudo}</div>
</div> </div>
<div className="text-sm text-muted-foreground mb-2">{new Date(msg.created_at || msg.data || Date.now()).toLocaleString('pt-BR')}</div>
<div className="text-foreground whitespace-pre-line">{msg.body || msg.content || msg.text || JSON.stringify(msg)}</div>
</div> </div>
)) ))
)} )}
@ -593,6 +662,7 @@ export default function PacientePage() {
} }
function Perfil() { function Perfil() {
const hasAddress = Boolean(profileData.endereco || profileData.cidade || profileData.cep || profileData.biografia)
return ( return (
<div className="space-y-6 max-w-2xl mx-auto"> <div className="space-y-6 max-w-2xl mx-auto">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -634,7 +704,8 @@ export default function PacientePage() {
)} )}
</div> </div>
</div> </div>
{/* Endereço e Contato */} {/* Endereço e Contato (render apenas se existir algum dado) */}
{hasAddress && (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-semibold border-b border-border text-foreground pb-2">Endereço</h3> <h3 className="text-lg font-semibold border-b border-border text-foreground pb-2">Endereço</h3>
<div className="space-y-2"> <div className="space-y-2">
@ -670,23 +741,17 @@ export default function PacientePage() {
)} )}
</div> </div>
</div> </div>
)}
</div> </div>
{/* Foto do Perfil */} {/* Foto do Perfil */}
<div className="border-t border-border pt-6"> <div className="border-t border-border pt-6">
<h3 className="text-lg font-semibold mb-4 text-foreground">Foto do Perfil</h3> <h3 className="text-lg font-semibold mb-4 text-foreground">Foto do Perfil</h3>
<div className="flex items-center gap-4"> <UploadAvatar
<Avatar className="h-20 w-20"> userId={profileData.id}
<AvatarFallback className="text-lg"> currentAvatarUrl={profileData.foto_url}
{profileData.nome.split(' ').map(n => n[0]).join('').toUpperCase()} onAvatarChange={(newUrl) => handleProfileChange('foto_url', newUrl)}
</AvatarFallback> userName={profileData.nome}
</Avatar> />
{isEditingProfile && (
<div className="space-y-2">
<Button variant="outline" size="sm">Alterar Foto</Button>
<p className="text-xs text-muted-foreground">Formatos aceitos: JPG, PNG (máx. 2MB)</p>
</div>
)}
</div>
</div> </div>
</div> </div>
) )

View File

@ -202,37 +202,78 @@ export function DoctorRegistrationForm({
""; "";
console.log('🎯 Especialidade encontrada:', especialidade); console.log('🎯 Especialidade encontrada:', especialidade);
const m: any = medico as any;
const normalizeSex = (v: any): string | null => {
if (v === null || typeof v === 'undefined') return null;
const s = String(v).trim().toLowerCase();
if (!s) return null;
const male = new Set(['m','masc','male','masculino','homem','h','1','mas']);
const female = new Set(['f','fem','female','feminino','mulher','mul','2','fem']);
const other = new Set(['o','outro','other','3','nb','nonbinary','nao binario','não binário']);
if (male.has(s)) return 'masculino';
if (female.has(s)) return 'feminino';
if (other.has(s)) return 'outro';
// Already canonical?
if (['masculino','feminino','outro'].includes(s)) return s;
return null;
};
const formatBirth = (v: any) => {
if (!v && typeof v !== 'string') return '';
const s = String(v).trim();
if (!s) return '';
// Accept ISO YYYY-MM-DD or full ISO datetime
const isoMatch = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (isoMatch) {
const [, y, mth, d] = isoMatch;
return `${d.padStart(2,'0')}/${mth.padStart(2,'0')}/${y}`;
}
// If already dd/mm/yyyy, return as-is
const ddmmyyyy = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (ddmmyyyy) return s;
// Try parsing other common formats
const maybe = new Date(s);
if (!isNaN(maybe.getTime())) {
const d = String(maybe.getDate()).padStart(2,'0');
const mm = String(maybe.getMonth() + 1).padStart(2,'0');
const y = String(maybe.getFullYear());
return `${d}/${mm}/${y}`;
}
return '';
};
const formData = { const formData = {
photo: null, photo: null,
full_name: String(medico.full_name || ""), full_name: String(m.full_name || m.nome || ""),
nome_social: String(medico.nome_social || ""), nome_social: String(m.nome_social || m.social_name || ""),
crm: String(medico.crm || ""), crm: String(m.crm || ""),
estado_crm: String(medico.estado_crm || ""), estado_crm: String(m.estado_crm || m.crm_uf || m.crm_state || ""),
rqe: String(medico.rqe || ""), rqe: String(m.rqe || ""),
formacao_academica: Array.isArray(medico.formacao_academica) ? medico.formacao_academica : [], formacao_academica: Array.isArray(m.formacao_academica) ? m.formacao_academica : [],
curriculo: null, curriculo: null,
especialidade: String(especialidade), especialidade: String(especialidade),
cpf: String(medico.cpf || ""), cpf: String(m.cpf || ""),
rg: String(medico.rg || ""), rg: String(m.rg || m.document_number || ""),
sexo: String(medico.sexo || ""), sexo: normalizeSex(m.sexo || m.sex || m.sexualidade || null) ?? "",
data_nascimento: String(medico.data_nascimento || ""), data_nascimento: String(formatBirth(m.data_nascimento || m.birth_date || m.birthDate || "")),
email: String(medico.email || ""), email: String(m.email || ""),
telefone: String(medico.telefone || ""), telefone: String(m.telefone || m.phone_mobile || m.phone || m.mobile || ""),
celular: String(medico.celular || ""), celular: String(m.celular || m.phone2 || ""),
contato_emergencia: String(medico.contato_emergencia || ""), contato_emergencia: String(m.contato_emergencia || ""),
cep: String(medico.cep || ""), cep: String(m.cep || ""),
logradouro: String(medico.street || ""), logradouro: String(m.street || m.logradouro || ""),
numero: String(medico.number || ""), numero: String(m.number || m.numero || ""),
complemento: String(medico.complement || ""), complemento: String(m.complement || m.complemento || ""),
bairro: String(medico.neighborhood || ""), bairro: String(m.neighborhood || m.bairro || ""),
cidade: String(medico.city || ""), cidade: String(m.city || m.cidade || ""),
estado: String(medico.state || ""), estado: String(m.state || m.estado || ""),
observacoes: String(medico.observacoes || ""), observacoes: String(m.observacoes || m.notes || ""),
anexos: [], anexos: [],
tipo_vinculo: String(medico.tipo_vinculo || ""), tipo_vinculo: String(m.tipo_vinculo || ""),
dados_bancarios: medico.dados_bancarios || { banco: "", agencia: "", conta: "", tipo_conta: "" }, dados_bancarios: m.dados_bancarios || { banco: "", agencia: "", conta: "", tipo_conta: "" },
agenda_horario: String(medico.agenda_horario || ""), agenda_horario: String(m.agenda_horario || ""),
valor_consulta: medico.valor_consulta ? String(medico.valor_consulta) : "", valor_consulta: m.valor_consulta ? String(m.valor_consulta) : "",
}; };
console.log("[DoctorForm] Dados do formulário preparados:", formData); console.log("[DoctorForm] Dados do formulário preparados:", formData);
@ -355,9 +396,12 @@ function setField<T extends keyof FormData>(k: T, v: FormData[T]) {
if (!form.cpf.trim()) e.cpf = "CPF é obrigatório"; if (!form.cpf.trim()) e.cpf = "CPF é obrigatório";
if (!form.crm.trim()) e.crm = "CRM é obrigatório"; if (!form.crm.trim()) e.crm = "CRM é obrigatório";
if (!form.especialidade.trim()) e.especialidade = "Especialidade é obrigatória"; if (!form.especialidade.trim()) e.especialidade = "Especialidade é obrigatória";
// During edit, avoid forcing address fields. They are required on create only.
if (mode !== 'edit') {
if (!form.cep.trim()) e.cep = "CEP é obrigatório"; // Verifique se o CEP está preenchido if (!form.cep.trim()) e.cep = "CEP é obrigatório"; // Verifique se o CEP está preenchido
if (!form.bairro.trim()) e.bairro = "Bairro é obrigatório"; // Verifique se o bairro está preenchido if (!form.bairro.trim()) e.bairro = "Bairro é obrigatório"; // Verifique se o bairro está preenchido
if (!form.cidade.trim()) e.cidade = "Cidade é obrigatória"; // Verifique se a cidade está preenchida if (!form.cidade.trim()) e.cidade = "Cidade é obrigatória"; // Verifique se a cidade está preenchida
}
setErrors(e); setErrors(e);
return Object.keys(e).length === 0; return Object.keys(e).length === 0;
@ -426,7 +470,15 @@ function toPayload(): MedicoInput {
async function handleSubmit(ev: React.FormEvent) { async function handleSubmit(ev: React.FormEvent) {
ev.preventDefault(); ev.preventDefault();
if (!validateLocal()) return; console.debug('[DoctorForm] handleSubmit invoked. mode=', mode, 'doctorId=', doctorId);
if (!validateLocal()) {
try {
const { toast } = require('@/hooks/use-toast').useToast();
const msgs = Object.entries(errors).map(([k,v]) => v).filter(Boolean).join('\n') || 'Preencha os campos obrigatórios';
toast({ title: 'Erro de validação', description: msgs, variant: 'destructive' });
} catch {}
return;
}
setSubmitting(true); setSubmitting(true);
setErrors({}); setErrors({});

View File

@ -0,0 +1,132 @@
"use client"
import React, { useState } from 'react'
import { Button } from './button'
import { Input } from './input'
import { Avatar, AvatarFallback, AvatarImage } from './avatar'
import { Upload, Download } from 'lucide-react'
import { uploadFotoPaciente } from '@/lib/api'
interface UploadAvatarProps {
userId: string
currentAvatarUrl?: string
onAvatarChange?: (newUrl: string) => void
userName?: string
className?: string
}
export function UploadAvatar({ userId, currentAvatarUrl, onAvatarChange, userName }: UploadAvatarProps) {
const [isUploading, setIsUploading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
try {
setIsUploading(true)
setError('')
console.debug('[UploadAvatar] Iniciando upload:', {
fileName: file.name,
fileType: file.type,
fileSize: file.size,
userId
})
const result = await uploadFotoPaciente(userId, file)
if (result.foto_url) {
console.debug('[UploadAvatar] Upload concluído:', result)
onAvatarChange?.(result.foto_url)
}
} catch (err) {
console.error('[UploadAvatar] Erro no upload:', err)
setError(err instanceof Error ? err.message : 'Erro ao fazer upload do avatar')
} finally {
setIsUploading(false)
// Limpa o input para permitir selecionar o mesmo arquivo novamente
event.target.value = ''
}
}
const handleDownload = async () => {
if (!currentAvatarUrl) return
try {
const response = await fetch(currentAvatarUrl)
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `avatar-${userId}.${blob.type.split('/')[1] || 'jpg'}`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (err) {
setError('Erro ao baixar o avatar')
}
}
const initials = userName
? userName.split(' ').map(n => n[0]).join('').toUpperCase()
: 'U'
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20">
<AvatarImage src={currentAvatarUrl} alt={userName || 'Avatar'} />
<AvatarFallback className="text-lg">
{initials}
</AvatarFallback>
</Avatar>
<div className="space-y-2">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => document.getElementById('avatar-upload')?.click()}
disabled={isUploading}
>
<Upload className="h-4 w-4 mr-2" />
{isUploading ? 'Enviando...' : 'Upload'}
</Button>
{currentAvatarUrl && (
<Button
variant="outline"
size="sm"
onClick={handleDownload}
>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
)}
</div>
<Input
id="avatar-upload"
type="file"
className="hidden"
accept="image/jpeg,image/png,image/webp"
onChange={handleUpload}
disabled={isUploading}
/>
<p className="text-xs text-muted-foreground">
Formatos aceitos: JPG, PNG, WebP (máx. 2MB)
</p>
{error && (
<p className="text-xs text-destructive">
{error}
</p>
)}
</div>
</div>
</div>
)
}

View File

@ -898,6 +898,25 @@ export async function buscarPacientes(termo: string): Promise<Paciente[]> {
return results.slice(0, 20); // Limita a 20 resultados return results.slice(0, 20); // Limita a 20 resultados
} }
/**
* Busca um paciente pelo user_id associado (campo user_id na tabela patients).
* Retorna o primeiro registro encontrado ou null quando não achar.
*/
export async function buscarPacientePorUserId(userId?: string | null): Promise<Paciente | null> {
if (!userId) return null;
try {
const url = `${REST}/patients?user_id=eq.${encodeURIComponent(String(userId))}&limit=1`;
const headers = baseHeaders();
console.debug('[buscarPacientePorUserId] URL:', url);
const arr = await fetchWithFallback<Paciente[]>(url, headers).catch(() => []);
if (arr && arr.length) return arr[0];
return null;
} catch (err) {
console.warn('[buscarPacientePorUserId] erro ao buscar por user_id', err);
return null;
}
}
export async function buscarPacientePorId(id: string | number): Promise<Paciente> { export async function buscarPacientePorId(id: string | number): Promise<Paciente> {
const idParam = String(id); const idParam = String(id);
const headers = baseHeaders(); const headers = baseHeaders();
@ -931,6 +950,42 @@ export async function buscarPacientePorId(id: string | number): Promise<Paciente
throw new Error('404: Paciente não encontrado'); throw new Error('404: Paciente não encontrado');
} }
// ===== MENSAGENS =====
export type Mensagem = {
id: string;
patient_id?: string;
doctor_id?: string | null;
from?: string | null;
to?: string | null;
sender_name?: string | null;
subject?: string | null;
body?: string | null;
content?: string | null;
read?: boolean | null;
created_at?: string | null;
};
/**
* Lista mensagens (inbox) de um paciente específico.
* Retorna array vazio se não houver mensagens.
*/
export async function listarMensagensPorPaciente(patientId: string): Promise<Mensagem[]> {
if (!patientId) return [];
try {
const qs = new URLSearchParams();
qs.set('patient_id', `eq.${encodeURIComponent(String(patientId))}`);
// Order by created_at descending if available
qs.set('order', 'created_at.desc');
const url = `${REST}/messages?${qs.toString()}`;
const headers = baseHeaders();
const res = await fetch(url, { method: 'GET', headers });
return await parse<Mensagem[]>(res);
} catch (err) {
console.warn('[listarMensagensPorPaciente] erro ao buscar mensagens', err);
return [];
}
}
// ===== RELATÓRIOS ===== // ===== RELATÓRIOS =====
export type Report = { export type Report = {
id: string; id: string;
@ -2086,17 +2141,41 @@ export async function atualizarMedico(id: string | number, input: MedicoInput):
// Criar um payload limpo apenas com campos básicos que sabemos que existem // Criar um payload limpo apenas com campos básicos que sabemos que existem
const cleanPayload = { const cleanPayload = {
// Basic identification / contact
full_name: input.full_name, full_name: input.full_name,
nome_social: (input as any).nome_social || undefined,
crm: input.crm, crm: input.crm,
crm_uf: (input as any).crm_uf || (input as any).crmUf || undefined,
rqe: (input as any).rqe || undefined,
specialty: input.specialty, specialty: input.specialty,
email: input.email, email: input.email,
phone_mobile: input.phone_mobile, phone_mobile: input.phone_mobile,
phone2: (input as any).phone2 ?? (input as any).telefone ?? undefined,
cpf: input.cpf, cpf: input.cpf,
rg: (input as any).rg ?? undefined,
// Address
cep: input.cep, cep: input.cep,
street: input.street, street: input.street,
number: input.number, number: input.number,
complement: (input as any).complement ?? undefined,
neighborhood: (input as any).neighborhood ?? (input as any).bairro ?? undefined,
city: input.city, city: input.city,
state: input.state, state: input.state,
// Personal / professional
birth_date: (input as any).birth_date ?? (input as any).data_nascimento ?? undefined,
sexo: (input as any).sexo ?? undefined,
formacao_academica: (input as any).formacao_academica ?? undefined,
observacoes: (input as any).observacoes ?? undefined,
// Administrative / financial
tipo_vinculo: (input as any).tipo_vinculo ?? undefined,
dados_bancarios: (input as any).dados_bancarios ?? undefined,
valor_consulta: (input as any).valor_consulta ?? undefined,
agenda_horario: (input as any).agenda_horario ?? undefined,
// Flags
active: input.active ?? true active: input.active ?? true
}; };
@ -2597,8 +2676,10 @@ export async function uploadFotoPaciente(_id: string | number, _file: File): Pro
}; };
const ext = extMap[_file.type] || 'jpg'; const ext = extMap[_file.type] || 'jpg';
const objectPath = `avatars/${userId}/avatar.${ext}`; // O bucket deve ser 'avatars' e o caminho do objeto será userId/avatar.ext
const uploadUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/avatars/${encodeURIComponent(userId)}/avatar`; const bucket = 'avatars';
const objectPath = `${userId}/avatar.${ext}`;
const uploadUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/${bucket}/${encodeURIComponent(objectPath)}`;
// Build multipart form data // Build multipart form data
const form = new FormData(); const form = new FormData();
@ -2614,6 +2695,13 @@ export async function uploadFotoPaciente(_id: string | number, _file: File): Pro
const jwt = getAuthToken(); const jwt = getAuthToken();
if (jwt) headers.Authorization = `Bearer ${jwt}`; if (jwt) headers.Authorization = `Bearer ${jwt}`;
console.debug('[uploadFotoPaciente] Iniciando upload:', {
url: uploadUrl,
fileType: _file.type,
fileSize: _file.size,
hasAuth: !!jwt
});
const res = await fetch(uploadUrl, { const res = await fetch(uploadUrl, {
method: 'POST', method: 'POST',
headers, headers,
@ -2623,10 +2711,19 @@ export async function uploadFotoPaciente(_id: string | number, _file: File): Pro
// Supabase storage returns 200/201 with object info or error // Supabase storage returns 200/201 with object info or error
if (!res.ok) { if (!res.ok) {
const raw = await res.text().catch(() => ''); const raw = await res.text().catch(() => '');
console.error('[uploadFotoPaciente] upload falhou', { status: res.status, raw }); console.error('[uploadFotoPaciente] upload falhou', {
status: res.status,
raw,
headers: Object.fromEntries(res.headers.entries()),
url: uploadUrl,
requestHeaders: headers,
objectPath
});
if (res.status === 401) throw new Error('Não autenticado'); if (res.status === 401) throw new Error('Não autenticado');
if (res.status === 403) throw new Error('Sem permissão para fazer upload'); if (res.status === 403) throw new Error('Sem permissão para fazer upload');
throw new Error('Falha no upload da imagem'); if (res.status === 404) throw new Error('Bucket de avatars não encontrado. Verifique se o bucket "avatars" existe no Supabase');
throw new Error(`Falha no upload da imagem (${res.status}): ${raw || 'Sem detalhes do erro'}`);
} }
// Try to parse JSON response // Try to parse JSON response
@ -2635,7 +2732,7 @@ export async function uploadFotoPaciente(_id: string | number, _file: File): Pro
// The API may not return a structured body; return the Key we constructed // The API may not return a structured body; return the Key we constructed
const key = (json && (json.Key || json.key)) ?? objectPath; const key = (json && (json.Key || json.key)) ?? objectPath;
const publicUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/public/${encodeURIComponent('avatars')}/${encodeURIComponent(userId)}/avatar.${ext}`; const publicUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/public/avatars/${encodeURIComponent(userId)}/avatar.${ext}`;
return { foto_url: publicUrl, Key: key }; return { foto_url: publicUrl, Key: key };
} }