Compare commits
6 Commits
653b21e2d2
...
a2ca13607e
| Author | SHA1 | Date | |
|---|---|---|---|
| a2ca13607e | |||
| 770eab9afe | |||
|
|
2c39f404d8 | ||
|
|
6a8a4af756 | ||
| d21ed34715 | |||
|
|
f67ff8df8c |
@ -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={!!selectedReport} onOpenChange={open => !open && setSelectedReport(null)}>
|
||||||
<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={!!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.sender_name || msg.from || msg.doctor_name || 'Remetente'}
|
||||||
{msg.medico}
|
{!msg.read && <span className="ml-2 px-2 py-0.5 rounded-full text-xs bg-primary text-white">Nova</span>}
|
||||||
{!msg.lida && <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,59 +704,54 @@ export default function PacientePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Endereço e Contato */}
|
{/* Endereço e Contato (render apenas se existir algum dado) */}
|
||||||
<div className="space-y-4">
|
{hasAddress && (
|
||||||
<h3 className="text-lg font-semibold border-b border-border text-foreground pb-2">Endereço</h3>
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<h3 className="text-lg font-semibold border-b border-border text-foreground pb-2">Endereço</h3>
|
||||||
<Label htmlFor="endereco">Endereço</Label>
|
<div className="space-y-2">
|
||||||
{isEditingProfile ? (
|
<Label htmlFor="endereco">Endereço</Label>
|
||||||
<Input id="endereco" value={profileData.endereco} onChange={e => handleProfileChange('endereco', e.target.value)} />
|
{isEditingProfile ? (
|
||||||
) : (
|
<Input id="endereco" value={profileData.endereco} onChange={e => handleProfileChange('endereco', e.target.value)} />
|
||||||
<p className="p-2 bg-muted/50 rounded text-foreground">{profileData.endereco}</p>
|
) : (
|
||||||
)}
|
<p className="p-2 bg-muted/50 rounded text-foreground">{profileData.endereco}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cidade">Cidade</Label>
|
||||||
|
{isEditingProfile ? (
|
||||||
|
<Input id="cidade" value={profileData.cidade} onChange={e => handleProfileChange('cidade', e.target.value)} />
|
||||||
|
) : (
|
||||||
|
<p className="p-2 bg-muted/50 rounded text-foreground">{profileData.cidade}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cep">CEP</Label>
|
||||||
|
{isEditingProfile ? (
|
||||||
|
<Input id="cep" value={profileData.cep} onChange={e => handleProfileChange('cep', e.target.value)} />
|
||||||
|
) : (
|
||||||
|
<p className="p-2 bg-muted/50 rounded text-foreground">{profileData.cep}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="biografia">Biografia</Label>
|
||||||
|
{isEditingProfile ? (
|
||||||
|
<Textarea id="biografia" value={profileData.biografia} onChange={e => handleProfileChange('biografia', e.target.value)} rows={4} placeholder="Conte um pouco sobre você..." />
|
||||||
|
) : (
|
||||||
|
<p className="p-2 bg-muted/50 rounded min-h-[100px] text-foreground">{profileData.biografia}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
)}
|
||||||
<Label htmlFor="cidade">Cidade</Label>
|
|
||||||
{isEditingProfile ? (
|
|
||||||
<Input id="cidade" value={profileData.cidade} onChange={e => handleProfileChange('cidade', e.target.value)} />
|
|
||||||
) : (
|
|
||||||
<p className="p-2 bg-muted/50 rounded text-foreground">{profileData.cidade}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="cep">CEP</Label>
|
|
||||||
{isEditingProfile ? (
|
|
||||||
<Input id="cep" value={profileData.cep} onChange={e => handleProfileChange('cep', e.target.value)} />
|
|
||||||
) : (
|
|
||||||
<p className="p-2 bg-muted/50 rounded text-foreground">{profileData.cep}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="biografia">Biografia</Label>
|
|
||||||
{isEditingProfile ? (
|
|
||||||
<Textarea id="biografia" value={profileData.biografia} onChange={e => handleProfileChange('biografia', e.target.value)} rows={4} placeholder="Conte um pouco sobre você..." />
|
|
||||||
) : (
|
|
||||||
<p className="p-2 bg-muted/50 rounded min-h-[100px] text-foreground">{profileData.biografia}</p>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import {
|
|||||||
|
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { ENV_CONFIG } from '@/lib/env-config';
|
||||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||||
import interactionPlugin from "@fullcalendar/interaction";
|
import interactionPlugin from "@fullcalendar/interaction";
|
||||||
@ -82,6 +83,20 @@ const colorsByType = {
|
|||||||
return p?.idade ?? p?.age ?? '';
|
return p?.idade ?? p?.age ?? '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Normaliza número de telefone para E.164 básico (prioriza +55 quando aplicável)
|
||||||
|
const normalizePhoneNumber = (raw?: string) => {
|
||||||
|
if (!raw || typeof raw !== 'string') return '';
|
||||||
|
// Remover tudo que não for dígito
|
||||||
|
const digits = raw.replace(/\D+/g, '');
|
||||||
|
if (!digits) return '';
|
||||||
|
// Já tem código de país (começa com 55)
|
||||||
|
if (digits.startsWith('55') && digits.length >= 11) return '+' + digits;
|
||||||
|
// Se tiver 10 ou 11 dígitos (DDD + número), assume Brasil e prefixa +55
|
||||||
|
if (digits.length === 10 || digits.length === 11) return '+55' + digits;
|
||||||
|
// Se tiver outros formatos pequenos, apenas prefixa +
|
||||||
|
return '+' + digits;
|
||||||
|
};
|
||||||
|
|
||||||
// Helpers para normalizar campos do laudo/relatório
|
// Helpers para normalizar campos do laudo/relatório
|
||||||
const getReportPatientName = (r: any) => r?.paciente?.full_name ?? r?.paciente?.nome ?? r?.patient?.full_name ?? r?.patient?.nome ?? r?.patient_name ?? r?.patient_full_name ?? '';
|
const getReportPatientName = (r: any) => r?.paciente?.full_name ?? r?.paciente?.nome ?? r?.patient?.full_name ?? r?.patient?.nome ?? r?.patient_name ?? r?.patient_full_name ?? '';
|
||||||
const getReportPatientId = (r: any) => r?.paciente?.id ?? r?.patient?.id ?? r?.patient_id ?? r?.patientId ?? r?.patient_id_raw ?? r?.patient_id ?? r?.id ?? '';
|
const getReportPatientId = (r: any) => r?.paciente?.id ?? r?.patient?.id ?? r?.patient_id ?? r?.patientId ?? r?.patient_id_raw ?? r?.patient_id ?? r?.id ?? '';
|
||||||
@ -101,7 +116,7 @@ const colorsByType = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ProfissionalPage = () => {
|
const ProfissionalPage = () => {
|
||||||
const { logout, user } = useAuth();
|
const { logout, user, token } = useAuth();
|
||||||
const [activeSection, setActiveSection] = useState('calendario');
|
const [activeSection, setActiveSection] = useState('calendario');
|
||||||
const [pacienteSelecionado, setPacienteSelecionado] = useState<any>(null);
|
const [pacienteSelecionado, setPacienteSelecionado] = useState<any>(null);
|
||||||
|
|
||||||
@ -374,10 +389,98 @@ const ProfissionalPage = () => {
|
|||||||
const [selectedEvent, setSelectedEvent] = useState<any>(null);
|
const [selectedEvent, setSelectedEvent] = useState<any>(null);
|
||||||
const [currentCalendarDate, setCurrentCalendarDate] = useState(new Date());
|
const [currentCalendarDate, setCurrentCalendarDate] = useState(new Date());
|
||||||
|
|
||||||
const handleSave = (event: React.MouseEvent<HTMLButtonElement>) => {
|
const [commPhoneNumber, setCommPhoneNumber] = useState('');
|
||||||
|
const [commMessage, setCommMessage] = useState('');
|
||||||
|
const [commPatientId, setCommPatientId] = useState<string | null>(null);
|
||||||
|
const [smsSending, setSmsSending] = useState(false);
|
||||||
|
|
||||||
|
const handleSave = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
console.log("Laudo salvo!");
|
setSmsSending(true);
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
try {
|
||||||
|
// Validate required fields
|
||||||
|
if (!commPhoneNumber || !commPhoneNumber.trim()) throw new Error('O campo phone_number é obrigatório');
|
||||||
|
if (!commMessage || !commMessage.trim()) throw new Error('O campo message é obrigatório');
|
||||||
|
|
||||||
|
const payload: any = { phone_number: commPhoneNumber.trim(), message: commMessage.trim() };
|
||||||
|
if (commPatientId) payload.patient_id = commPatientId;
|
||||||
|
|
||||||
|
const headers: Record<string,string> = { 'Content-Type': 'application/json' };
|
||||||
|
// include any default headers from ENV_CONFIG if present (e.g. apikey)
|
||||||
|
if ((ENV_CONFIG as any)?.DEFAULT_HEADERS) Object.assign(headers, (ENV_CONFIG as any).DEFAULT_HEADERS);
|
||||||
|
// include Authorization if we have a token (user session)
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
// Ensure apikey is present (frontend only has ANON key in this project)
|
||||||
|
if (!headers.apikey && (ENV_CONFIG as any)?.SUPABASE_ANON_KEY) {
|
||||||
|
headers.apikey = (ENV_CONFIG as any).SUPABASE_ANON_KEY;
|
||||||
|
}
|
||||||
|
// Ensure Accept header
|
||||||
|
headers['Accept'] = 'application/json';
|
||||||
|
|
||||||
|
// Normalizar número antes de enviar (E.164 básico)
|
||||||
|
const normalized = normalizePhoneNumber(commPhoneNumber);
|
||||||
|
if (!normalized) throw new Error('Número inválido após normalização');
|
||||||
|
payload.phone_number = normalized;
|
||||||
|
|
||||||
|
// Debug: log payload and headers with secrets masked to help diagnose issues
|
||||||
|
try {
|
||||||
|
const masked = { ...headers } as Record<string, any>;
|
||||||
|
if (masked.apikey && typeof masked.apikey === 'string') masked.apikey = `${masked.apikey.slice(0,4)}...${masked.apikey.slice(-4)}`;
|
||||||
|
if (masked.Authorization) masked.Authorization = 'Bearer <<token-present>>';
|
||||||
|
console.debug('[ProfissionalPage] Enviando SMS -> url:', `${(ENV_CONFIG as any).SUPABASE_URL}/functions/v1/send-sms`, 'payload:', payload, 'headers(masked):', masked);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore logging errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${(ENV_CONFIG as any).SUPABASE_URL}/functions/v1/send-sms`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
// If server returned 5xx and we sent a patient_id, try a single retry without patient_id
|
||||||
|
if (res.status >= 500 && payload.patient_id) {
|
||||||
|
try {
|
||||||
|
const fallback = { phone_number: payload.phone_number, message: payload.message };
|
||||||
|
console.debug('[ProfissionalPage] 5xx ao enviar com patient_id — tentando reenviar sem patient_id', { fallback });
|
||||||
|
const retryRes = await fetch(`${(ENV_CONFIG as any).SUPABASE_URL}/functions/v1/send-sms`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(fallback),
|
||||||
|
});
|
||||||
|
const retryBody = await retryRes.json().catch(() => null);
|
||||||
|
if (retryRes.ok) {
|
||||||
|
alert('SMS enviado com sucesso (sem patient_id)');
|
||||||
|
setCommPhoneNumber('');
|
||||||
|
setCommMessage('');
|
||||||
|
setCommPatientId(null);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw new Error(retryBody?.message || retryBody?.error || `Erro ao enviar SMS (retry ${retryRes.status})`);
|
||||||
|
}
|
||||||
|
} catch (retryErr) {
|
||||||
|
console.warn('[ProfissionalPage] Reenvio sem patient_id falhou', retryErr);
|
||||||
|
throw new Error(body?.message || body?.error || `Erro ao enviar SMS (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(body?.message || body?.error || `Erro ao enviar SMS (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// success feedback
|
||||||
|
alert('SMS enviado com sucesso');
|
||||||
|
// clear fields
|
||||||
|
setCommPhoneNumber('');
|
||||||
|
setCommMessage('');
|
||||||
|
setCommPatientId(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(String(err?.message || err || 'Falha ao enviar SMS'));
|
||||||
|
} finally {
|
||||||
|
setSmsSending(false);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -2480,61 +2583,60 @@ const ProfissionalPage = () => {
|
|||||||
<div className="bg-card shadow-md rounded-lg p-6">
|
<div className="bg-card shadow-md rounded-lg p-6">
|
||||||
<h2 className="text-2xl font-bold mb-4 text-foreground">Comunicação com o Paciente</h2>
|
<h2 className="text-2xl font-bold mb-4 text-foreground">Comunicação com o Paciente</h2>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="destinatario">Destinatário</Label>
|
<Label htmlFor="patientSelect">Paciente *</Label>
|
||||||
<Select>
|
<select
|
||||||
<SelectTrigger id="destinatario" className="hover:border-primary focus:border-primary cursor-pointer">
|
id="patientSelect"
|
||||||
<SelectValue placeholder="Selecione o paciente" />
|
className="input"
|
||||||
</SelectTrigger>
|
value={commPatientId ?? ''}
|
||||||
<SelectContent className="bg-popover border">
|
onChange={(e) => {
|
||||||
{pacientes.map((paciente) => (
|
const val = e.target.value || null;
|
||||||
<SelectItem
|
setCommPatientId(val);
|
||||||
key={paciente.cpf}
|
if (!val) {
|
||||||
value={paciente.nome}
|
setCommPhoneNumber('');
|
||||||
className="hover:bg-blue-50 focus:bg-blue-50 cursor-pointer dark:hover:bg-primary dark:hover:text-primary-foreground dark:focus:bg-primary dark:focus:text-primary-foreground"
|
return;
|
||||||
>
|
}
|
||||||
{paciente.nome} - {paciente.cpf}
|
try {
|
||||||
</SelectItem>
|
const found = (pacientes || []).find((p: any) => String(p.id ?? p.uuid ?? p.email ?? '') === String(val));
|
||||||
))}
|
if (found) {
|
||||||
</SelectContent>
|
setCommPhoneNumber(
|
||||||
</Select>
|
found.phone_mobile ?? found.celular ?? found.telefone ?? found.phone ?? found.mobile ?? found.phone_number ?? ''
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setCommPhoneNumber('');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ProfissionalPage] erro ao preencher telefone do paciente selecionado', e);
|
||||||
|
setCommPhoneNumber('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">-- nenhum --</option>
|
||||||
|
{pacientes && pacientes.map((p:any) => (
|
||||||
|
<option key={String(p.id || p.uuid || p.cpf || p.email)} value={String(p.id ?? p.uuid ?? p.email ?? '')}>
|
||||||
|
{p.full_name ?? p.nome ?? p.name ?? p.email ?? String(p.id ?? p.cpf ?? '')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="tipoMensagem">Tipo de mensagem</Label>
|
<Label htmlFor="phoneNumber">Número (phone_number)</Label>
|
||||||
<Select>
|
<Input id="phoneNumber" placeholder="+5511999999999" value={commPhoneNumber} readOnly disabled className="bg-muted/50" />
|
||||||
<SelectTrigger id="tipoMensagem" className="hover:border-primary focus:border-primary cursor-pointer">
|
|
||||||
<SelectValue placeholder="Selecione o tipo" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="bg-popover border">
|
|
||||||
<SelectItem value="lembrete" className="hover:bg-blue-50 focus:bg-blue-50 cursor-pointer dark:hover:bg-primary dark:hover:text-primary-foreground dark:focus:bg-primary dark:focus:text-primary-foreground">Lembrete de Consulta</SelectItem>
|
|
||||||
<SelectItem value="resultado" className="hover:bg-blue-50 focus:bg-blue-50 cursor-pointer dark:hover:bg-primary dark:hover:text-primary-foreground dark:focus:bg-primary dark:focus:text-primary-foreground">Resultado de Exame</SelectItem>
|
|
||||||
<SelectItem value="instrucao" className="hover:bg-blue-50 focus:bg-blue-50 cursor-pointer dark:hover:bg-primary dark:hover:text-primary-foreground dark:focus:bg-primary dark:focus:text-primary-foreground">Instruções Pós-Consulta</SelectItem>
|
|
||||||
<SelectItem value="outro" className="hover:bg-blue-50 focus:bg-blue-50 cursor-pointer dark:hover:bg-primary dark:hover:text-primary-foreground dark:focus:bg-primary dark:focus:text-primary-foreground">Outro</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="space-y-2">
|
||||||
<div>
|
<Label htmlFor="message">Mensagem (message)</Label>
|
||||||
<Label htmlFor="dataEnvio">Data de envio</Label>
|
<textarea id="message" className="w-full p-2 border rounded" rows={5} value={commMessage} onChange={(e) => setCommMessage(e.target.value)} />
|
||||||
<p id="dataEnvio" className="text-sm text-muted-foreground">03/09/2025</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<Label htmlFor="statusEntrega">Status da entrega</Label>
|
<div className="flex justify-end mt-6">
|
||||||
<p id="statusEntrega" className="text-sm text-muted-foreground">Pendente</p>
|
<Button onClick={handleSave} disabled={smsSending}>
|
||||||
|
{smsSending ? 'Enviando...' : 'Enviar SMS'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Resposta do paciente</Label>
|
|
||||||
<div className="border rounded-md p-3 bg-muted/40 space-y-2">
|
|
||||||
<p className="text-sm">"Ok, obrigado pelo lembrete!"</p>
|
|
||||||
<p className="text-xs text-muted-foreground">03/09/2025 14:30</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end mt-6">
|
|
||||||
<Button onClick={handleSave}>Registrar Comunicação</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
132
susconecta/components/ui/upload-avatar.tsx
Normal file
132
susconecta/components/ui/upload-avatar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -2621,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();
|
||||||
@ -2638,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,
|
||||||
@ -2647,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
|
||||||
@ -2659,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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user