Compare commits

..

14 Commits

Author SHA1 Message Date
3c52ec5e3a Merge pull request 'backup/agendamento' (#60) from backup/agendamento into develop
Reviewed-on: #60
2025-10-28 02:57:05 +00:00
João Gustavo
708ec3cd93 Merge branch 'feature/endpoint-user-info-id' into backup/agendamento 2025-10-27 23:56:26 -03:00
7e17a9847b feat(dashboard): add dashboard 2025-10-27 23:54:36 -03:00
João Gustavo
a37dbb4c75 fix-visual-adjustments 2025-10-27 23:51:22 -03:00
1693a415e2 feat(perfil): add profile for admin and user information endpoint by id 2025-10-27 23:21:17 -03:00
João Gustavo
bb6e3b0d25 fix-reports 2025-10-27 23:11:33 -03:00
João Gustavo
79eb63ad96 add-user-endpoint 2025-10-27 22:25:57 -03:00
João Gustavo
b53401ff39 list-reports-and-appoiments 2025-10-27 12:00:18 -03:00
João Gustavo
0c8bc4534a add-appoimnets-in-patient-page 2025-10-25 00:35:40 -03:00
João Gustavo
cc43a2e9a9 add-doctor-availability 2025-10-24 17:21:08 -03:00
João Gustavo
7c9b2b6ca3 Merge branch 'feature/add-api-sms' into backup/agendamento 2025-10-24 16:52:56 -03:00
João Gustavo
2527d28f6b fix-patient-role 2025-10-24 16:51:17 -03:00
a2ca13607e feat(profissional): add endpoint de sms 2025-10-24 16:33:46 -03:00
99bd827c4c feat(agendamento): tornar parte de agendamento do paciente dinâmica de acordo com a API 2025-10-23 19:28:02 -03:00
16 changed files with 3327 additions and 3942 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +1,403 @@
export default function DashboardPage() {
return (
<>
<div className="space-y-6 p-6 bg-background">
<div>
<h1 className="text-2xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground">
Bem-vindo ao painel de controle
</p>
</div>
'use client';
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-card p-6 rounded-lg border">
<h3 className="text-sm font-medium text-muted-foreground">
Total de Pacientes
</h3>
<p className="text-2xl font-bold text-foreground">1,234</p>
</div>
<div className="bg-card p-6 rounded-lg border">
<h3 className="text-sm font-medium text-muted-foreground">
Consultas Hoje
</h3>
<p className="text-2xl font-bold text-foreground">28</p>
</div>
<div className="bg-card p-6 rounded-lg border">
<h3 className="text-sm font-medium text-muted-foreground">
Próximas Consultas
</h3>
<p className="text-2xl font-bold text-foreground">45</p>
</div>
<div className="bg-card p-6 rounded-lg border">
<h3 className="text-sm font-medium text-muted-foreground">
Receita Mensal
</h3>
<p className="text-2xl font-bold text-foreground">R$ 45.230</p>
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
countTotalPatients,
countTotalDoctors,
countAppointmentsToday,
getUpcomingAppointments,
getAppointmentsByDateRange,
getNewUsersLastDays,
getPendingReports,
getDisabledUsers,
getDoctorsAvailabilityToday,
getPatientById,
getDoctorById,
} from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle, Calendar, Users, Stethoscope, Clock, FileText, AlertTriangle, Plus, ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { PatientRegistrationForm } from '@/components/forms/patient-registration-form';
import { DoctorRegistrationForm } from '@/components/forms/doctor-registration-form';
interface DashboardStats {
totalPatients: number;
totalDoctors: number;
appointmentsToday: number;
}
interface UpcomingAppointment {
id: string;
scheduled_at: string;
status: string;
doctor_id: string;
patient_id: string;
doctor?: { full_name?: string };
patient?: { full_name?: string };
}
export default function DashboardPage() {
const router = useRouter();
const [stats, setStats] = useState<DashboardStats>({
totalPatients: 0,
totalDoctors: 0,
appointmentsToday: 0,
});
const [appointments, setAppointments] = useState<UpcomingAppointment[]>([]);
const [appointmentData, setAppointmentData] = useState<any[]>([]);
const [newUsers, setNewUsers] = useState<any[]>([]);
const [pendingReports, setPendingReports] = useState<any[]>([]);
const [disabledUsers, setDisabledUsers] = useState<any[]>([]);
const [doctors, setDoctors] = useState<Map<string, any>>(new Map());
const [patients, setPatients] = useState<Map<string, any>>(new Map());
const [loading, setLoading] = useState(true);
// Estados para os modais de formulário
const [showPatientForm, setShowPatientForm] = useState(false);
const [showDoctorForm, setShowDoctorForm] = useState(false);
const [editingPatientId, setEditingPatientId] = useState<string | null>(null);
const [editingDoctorId, setEditingDoctorId] = useState<string | null>(null);
useEffect(() => {
loadDashboardData();
}, []);
const loadDashboardData = async () => {
try {
setLoading(true);
// 1. Carrega stats
const [patientCount, doctorCount, todayCount] = await Promise.all([
countTotalPatients(),
countTotalDoctors(),
countAppointmentsToday(),
]);
setStats({
totalPatients: patientCount,
totalDoctors: doctorCount,
appointmentsToday: todayCount,
});
// 2. Carrega dados dos widgets em paralelo
const [upcomingAppts, appointmentDataRange, newUsersList, pendingReportsList, disabledUsersList] = await Promise.all([
getUpcomingAppointments(5),
getAppointmentsByDateRange(7),
getNewUsersLastDays(7),
getPendingReports(5),
getDisabledUsers(5),
]);
setAppointments(upcomingAppts);
setAppointmentData(appointmentDataRange);
setNewUsers(newUsersList);
setPendingReports(pendingReportsList);
setDisabledUsers(disabledUsersList);
// 3. Busca detalhes de pacientes e médicos para as próximas consultas
const doctorMap = new Map();
const patientMap = new Map();
for (const appt of upcomingAppts) {
if (appt.doctor_id && !doctorMap.has(appt.doctor_id)) {
const doctor = await getDoctorById(appt.doctor_id);
if (doctor) doctorMap.set(appt.doctor_id, doctor);
}
if (appt.patient_id && !patientMap.has(appt.patient_id)) {
const patient = await getPatientById(appt.patient_id);
if (patient) patientMap.set(appt.patient_id, patient);
}
}
setDoctors(doctorMap);
setPatients(patientMap);
} catch (err) {
console.error('[Dashboard] Erro ao carregar dados:', err);
} finally {
setLoading(false);
}
};
const handlePatientFormSaved = () => {
setShowPatientForm(false);
setEditingPatientId(null);
loadDashboardData();
};
const handleDoctorFormSaved = () => {
setShowDoctorForm(false);
setEditingDoctorId(null);
loadDashboardData();
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusBadge = (status: string) => {
const statusMap: Record<string, { variant: any; label: string }> = {
confirmed: { variant: 'default', label: 'Confirmado' },
completed: { variant: 'secondary', label: 'Concluído' },
cancelled: { variant: 'destructive', label: 'Cancelado' },
requested: { variant: 'outline', label: 'Solicitado' },
};
const s = statusMap[status] || { variant: 'outline', label: status };
return <Badge variant={s.variant as any}>{s.label}</Badge>;
};
if (loading) {
return (
<div className="space-y-6 p-6 bg-background">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-1/4"></div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-32 bg-muted rounded"></div>
))}
</div>
</div>
</div>
</>
);
}
// Se está exibindo formulário de paciente
if (showPatientForm) {
return (
<div className="space-y-6 p-6 bg-background">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => {
setShowPatientForm(false);
setEditingPatientId(null);
}}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-2xl font-bold">{editingPatientId ? "Editar paciente" : "Novo paciente"}</h1>
</div>
<PatientRegistrationForm
inline
mode={editingPatientId ? "edit" : "create"}
patientId={editingPatientId}
onSaved={handlePatientFormSaved}
onClose={() => {
setShowPatientForm(false);
setEditingPatientId(null);
}}
/>
</div>
);
}
// Se está exibindo formulário de médico
if (showDoctorForm) {
return (
<div className="space-y-6 p-6 bg-background">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => {
setShowDoctorForm(false);
setEditingDoctorId(null);
}}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-2xl font-bold">{editingDoctorId ? "Editar Médico" : "Novo Médico"}</h1>
</div>
<DoctorRegistrationForm
inline
mode={editingDoctorId ? "edit" : "create"}
doctorId={editingDoctorId}
onSaved={handleDoctorFormSaved}
onClose={() => {
setShowDoctorForm(false);
setEditingDoctorId(null);
}}
/>
</div>
);
}
return (
<div className="space-y-6 p-6 bg-background">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-2">Bem-vindo ao painel de controle</p>
</div>
{/* 1. CARDS RESUMO */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-card p-6 rounded-lg border border-border hover:shadow-md transition">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-muted-foreground">Total de Pacientes</h3>
<p className="text-3xl font-bold text-foreground mt-2">{stats.totalPatients}</p>
</div>
<Users className="h-8 w-8 text-blue-500 opacity-20" />
</div>
</div>
<div className="bg-card p-6 rounded-lg border border-border hover:shadow-md transition">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-muted-foreground">Total de Médicos</h3>
<p className="text-3xl font-bold text-foreground mt-2">{stats.totalDoctors}</p>
</div>
<Stethoscope className="h-8 w-8 text-green-500 opacity-20" />
</div>
</div>
<div className="bg-card p-6 rounded-lg border border-border hover:shadow-md transition">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-muted-foreground">Consultas Hoje</h3>
<p className="text-3xl font-bold text-foreground mt-2">{stats.appointmentsToday}</p>
</div>
<Calendar className="h-8 w-8 text-purple-500 opacity-20" />
</div>
</div>
<div className="bg-card p-6 rounded-lg border border-border hover:shadow-md transition">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-muted-foreground">Relatórios Pendentes</h3>
<p className="text-3xl font-bold text-foreground mt-2">{pendingReports.length}</p>
</div>
<FileText className="h-8 w-8 text-orange-500 opacity-20" />
</div>
</div>
</div>
{/* 6. AÇÕES RÁPIDAS */}
<div className="bg-card p-6 rounded-lg border border-border">
<h2 className="text-lg font-semibold text-foreground mb-4">Ações Rápidas</h2>
<div className="flex flex-wrap gap-3">
<Button onClick={() => setShowPatientForm(true)} className="gap-2">
<Plus className="h-4 w-4" />
Novo Paciente
</Button>
<Button onClick={() => router.push('/agenda')} variant="outline" className="gap-2">
<Calendar className="h-4 w-4" />
Novo Agendamento
</Button>
<Button onClick={() => setShowDoctorForm(true)} variant="outline" className="gap-2">
<Stethoscope className="h-4 w-4" />
Novo Médico
</Button>
<Button onClick={() => router.push('/dashboard/relatorios')} variant="outline" className="gap-2">
<FileText className="h-4 w-4" />
Ver Relatórios
</Button>
</div>
</div>
{/* 2. PRÓXIMAS CONSULTAS */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 bg-card p-6 rounded-lg border border-border">
<h2 className="text-lg font-semibold text-foreground mb-4">Próximas Consultas (7 dias)</h2>
{appointments.length > 0 ? (
<div className="space-y-3">
{appointments.map(appt => (
<div key={appt.id} className="flex items-center justify-between p-3 bg-muted rounded-lg hover:bg-muted/80 transition">
<div className="flex-1">
<p className="font-medium text-foreground">
{patients.get(appt.patient_id)?.full_name || 'Paciente desconhecido'}
</p>
<p className="text-sm text-muted-foreground">
Médico: {doctors.get(appt.doctor_id)?.full_name || 'Médico desconhecido'}
</p>
<p className="text-xs text-muted-foreground mt-1">{formatDate(appt.scheduled_at)}</p>
</div>
<div className="flex items-center gap-2">
{getStatusBadge(appt.status)}
</div>
</div>
))}
</div>
) : (
<p className="text-muted-foreground">Nenhuma consulta agendada para os próximos 7 dias</p>
)}
</div>
{/* 5. RELATÓRIOS PENDENTES */}
<div className="bg-card p-6 rounded-lg border border-border">
<h2 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<FileText className="h-5 w-5" />
Relatórios Pendentes
</h2>
{pendingReports.length > 0 ? (
<div className="space-y-2">
{pendingReports.map(report => (
<div key={report.id} className="p-3 bg-muted rounded-lg hover:bg-muted/80 transition cursor-pointer text-sm">
<p className="font-medium text-foreground truncate">{report.order_number}</p>
<p className="text-xs text-muted-foreground">{report.exam || 'Sem descrição'}</p>
</div>
))}
<Button onClick={() => router.push('/dashboard/relatorios')} variant="ghost" className="w-full mt-2" size="sm">
Ver Todos
</Button>
</div>
) : (
<p className="text-muted-foreground text-sm">Sem relatórios pendentes</p>
)}
</div>
</div>
{/* 4. NOVOS USUÁRIOS */}
<div className="bg-card p-6 rounded-lg border border-border">
<h2 className="text-lg font-semibold text-foreground mb-4">Novos Usuários (últimos 7 dias)</h2>
{newUsers.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{newUsers.map(user => (
<div key={user.id} className="p-3 bg-muted rounded-lg">
<p className="font-medium text-foreground truncate">{user.full_name || 'Sem nome'}</p>
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
</div>
))}
</div>
) : (
<p className="text-muted-foreground">Nenhum novo usuário nos últimos 7 dias</p>
)}
</div>
{/* 8. ALERTAS */}
{disabledUsers.length > 0 && (
<div className="bg-card p-6 rounded-lg border border-destructive/50">
<h2 className="text-lg font-semibold text-destructive mb-4 flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
Alertas - Usuários Desabilitados
</h2>
<div className="space-y-2">
{disabledUsers.map(user => (
<Alert key={user.id} variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<strong>{user.full_name}</strong> ({user.email}) está desabilitado
</AlertDescription>
</Alert>
))}
</div>
</div>
)}
{/* 11. LINK PARA RELATÓRIOS */}
<div className="bg-gradient-to-r from-blue-500/10 to-purple-500/10 p-6 rounded-lg border border-blue-500/20">
<h2 className="text-lg font-semibold text-foreground mb-2">Seção de Relatórios</h2>
<p className="text-muted-foreground text-sm mb-4">
Acesse a seção de relatórios médicos para gerenciar, visualizar e exportar documentos.
</p>
<Button asChild>
<Link href="/dashboard/relatorios">Ir para Relatórios</Link>
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,34 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function PerfillLoading() {
return (
<div className="space-y-6 p-6">
<div className="flex items-center gap-4 mb-8">
<Skeleton className="h-20 w-20 rounded-full" />
<div className="space-y-2 flex-1">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-4 w-48" />
</div>
</div>
<div className="grid gap-6">
<div className="rounded-lg border border-border p-6 space-y-4">
<Skeleton className="h-6 w-32" />
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
<div className="rounded-lg border border-border p-6 space-y-4">
<Skeleton className="h-6 w-32" />
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,653 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { UploadAvatar } from "@/components/ui/upload-avatar";
import { AlertCircle, ArrowLeft, CheckCircle, XCircle } from "lucide-react";
import { getUserInfoById } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { formatTelefone, formatCEP, validarCEP, buscarCEP } from "@/lib/utils";
interface UserProfile {
user: {
id: string;
email: string;
created_at: string;
last_sign_in_at: string | null;
email_confirmed_at: string | null;
};
profile: {
id: string;
full_name: string | null;
email: string | null;
phone: string | null;
avatar_url: string | null;
cep?: string | null;
street?: string | null;
number?: string | null;
complement?: string | null;
neighborhood?: string | null;
city?: string | null;
state?: string | null;
disabled: boolean;
created_at: string;
updated_at: string;
} | null;
roles: string[];
permissions: {
isAdmin: boolean;
isManager: boolean;
isDoctor: boolean;
isSecretary: boolean;
isAdminOrManager: boolean;
};
}
export default function PerfilPage() {
const router = useRouter();
const { user: authUser } = useAuth();
const [userInfo, setUserInfo] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [editingData, setEditingData] = useState<{
phone?: string;
full_name?: string;
avatar_url?: string;
cep?: string;
street?: string;
number?: string;
complement?: string;
neighborhood?: string;
city?: string;
state?: string;
}>({});
const [cepLoading, setCepLoading] = useState(false);
const [cepValid, setCepValid] = useState<boolean | null>(null);
useEffect(() => {
async function loadUserInfo() {
try {
setLoading(true);
if (!authUser?.id) {
throw new Error("ID do usuário não encontrado");
}
console.log('[PERFIL] Chamando getUserInfoById com ID:', authUser.id);
// Para admin/gestor, usar getUserInfoById com o ID do usuário logado
const info = await getUserInfoById(authUser.id);
console.log('[PERFIL] Sucesso ao carregar info:', info);
setUserInfo(info as UserProfile);
setError(null);
} catch (err: any) {
console.error('[PERFIL] Erro ao carregar:', err);
setError(err?.message || "Erro ao carregar informações do perfil");
setUserInfo(null);
} finally {
setLoading(false);
}
}
if (authUser) {
console.log('[PERFIL] useEffect acionado, authUser:', authUser);
loadUserInfo();
}
}, [authUser]);
if (authUser?.userType !== 'administrador') {
return (
<div className="flex flex-col h-screen">
<div className="flex-1 p-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Você não tem permissão para acessar esta página.
</AlertDescription>
</Alert>
<Button
variant="outline"
onClick={() => router.back()}
className="mt-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Voltar
</Button>
</div>
</div>
);
}
if (loading) {
return (
<div className="flex flex-col h-screen">
<div className="flex-1 p-6">
<div className="space-y-4">
<div className="h-20 bg-muted rounded-lg animate-pulse" />
<div className="h-64 bg-muted rounded-lg animate-pulse" />
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col h-screen">
<div className="flex-1 p-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
<Button
variant="outline"
onClick={() => window.location.reload()}
className="mt-4"
>
Tentar Novamente
</Button>
</div>
</div>
);
}
if (!userInfo) {
return (
<div className="flex flex-col h-screen">
<div className="flex-1 p-6">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Nenhuma informação de perfil disponível.
</AlertDescription>
</Alert>
</div>
</div>
);
}
const getInitials = (name: string | null | undefined) => {
if (!name) return "AD";
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const handleEditClick = () => {
if (!isEditing && userInfo) {
setEditingData({
full_name: userInfo.profile?.full_name || "",
phone: userInfo.profile?.phone || "",
avatar_url: userInfo.profile?.avatar_url || "",
cep: userInfo.profile?.cep || "",
street: userInfo.profile?.street || "",
number: userInfo.profile?.number || "",
complement: userInfo.profile?.complement || "",
neighborhood: userInfo.profile?.neighborhood || "",
city: userInfo.profile?.city || "",
state: userInfo.profile?.state || "",
});
// Se já existe CEP, marcar como válido
if (userInfo.profile?.cep) {
setCepValid(true);
}
}
setIsEditing(!isEditing);
};
const handleSaveEdit = async () => {
try {
// Aqui você implementaria a chamada para atualizar o perfil
console.log('[PERFIL] Salvando alterações:', editingData);
// await atualizarPerfil(userInfo?.user.id, editingData);
setIsEditing(false);
setUserInfo((prev) =>
prev ? {
...prev,
profile: prev.profile ? {
...prev.profile,
full_name: editingData.full_name || prev.profile.full_name,
phone: editingData.phone || prev.profile.phone,
avatar_url: editingData.avatar_url || prev.profile.avatar_url,
cep: editingData.cep || prev.profile.cep,
street: editingData.street || prev.profile.street,
number: editingData.number || prev.profile.number,
complement: editingData.complement || prev.profile.complement,
neighborhood: editingData.neighborhood || prev.profile.neighborhood,
city: editingData.city || prev.profile.city,
state: editingData.state || prev.profile.state,
} : null,
} : null
);
} catch (err: any) {
console.error('[PERFIL] Erro ao salvar:', err);
}
};
const handleCancelEdit = () => {
setIsEditing(false);
setEditingData({});
setCepValid(null);
};
const handleCepChange = async (cepValue: string) => {
// Formatar CEP
const formatted = formatCEP(cepValue);
setEditingData({...editingData, cep: formatted});
// Validar CEP
const isValid = validarCEP(cepValue);
setCepValid(isValid ? null : false); // null = não validado ainda, false = inválido
if (isValid) {
setCepLoading(true);
try {
const resultado = await buscarCEP(cepValue);
if (resultado) {
setCepValid(true);
// Preencher campos automaticamente
setEditingData(prev => ({
...prev,
street: resultado.street,
neighborhood: resultado.neighborhood,
city: resultado.city,
state: resultado.state,
}));
console.log('[PERFIL] CEP preenchido com sucesso:', resultado);
} else {
setCepValid(false);
}
} catch (err) {
console.error('[PERFIL] Erro ao buscar CEP:', err);
setCepValid(false);
} finally {
setCepLoading(false);
}
}
};
const handlePhoneChange = (phoneValue: string) => {
const formatted = formatTelefone(phoneValue);
setEditingData({...editingData, phone: formatted});
};
return (
<div className="flex flex-col h-screen bg-background">
<div className="flex-1 overflow-y-auto">
<div className="p-6 space-y-6">
{/* Header com Título e Botão */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold">Meu Perfil</h2>
<p className="text-muted-foreground mt-1">Bem-vindo à sua área exclusiva.</p>
</div>
{!isEditing ? (
<Button
className="bg-blue-600 hover:bg-blue-700"
onClick={handleEditClick}
>
Editar Perfil
</Button>
) : (
<div className="flex gap-2">
<Button
className="bg-green-600 hover:bg-green-700"
onClick={handleSaveEdit}
>
Salvar
</Button>
<Button
variant="outline"
onClick={handleCancelEdit}
>
Cancelar
</Button>
</div>
)}
</div>
{/* Grid de 2 colunas */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Coluna Esquerda - Informações Pessoais */}
<div className="lg:col-span-2 space-y-6">
{/* Informações Pessoais */}
<div className="border border-border rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4">Informações Pessoais</h3>
<div className="space-y-4">
{/* Nome Completo */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Nome Completo
</Label>
{isEditing ? (
<Input
value={editingData.full_name || ""}
onChange={(e) => setEditingData({...editingData, full_name: e.target.value})}
className="mt-2"
/>
) : (
<>
<div className="mt-2 p-3 bg-muted rounded text-foreground font-medium">
{userInfo.profile?.full_name || "Não preenchido"}
</div>
<p className="text-xs text-muted-foreground mt-1">
Este campo não pode ser alterado
</p>
</>
)}
</div>
{/* Email */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Email
</Label>
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.user.email}
</div>
<p className="text-xs text-muted-foreground mt-1">
Este campo não pode ser alterado
</p>
</div>
{/* UUID */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
UUID
</Label>
<div className="mt-2 p-3 bg-muted rounded text-foreground font-mono text-xs break-all">
{userInfo.user.id}
</div>
<p className="text-xs text-muted-foreground mt-1">
Este campo não pode ser alterado
</p>
</div>
{/* Permissões */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Permissões
</Label>
<div className="mt-2 flex flex-wrap gap-2">
{userInfo.roles && userInfo.roles.length > 0 ? (
userInfo.roles.map((role) => (
<Badge key={role} variant="outline">
{role}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">
Nenhuma permissão atribuída
</span>
)}
</div>
</div>
</div>
</div>
{/* Endereço e Contato */}
<div className="border border-border rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4">Endereço e Contato</h3>
<div className="space-y-4">
{/* Telefone */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Telefone
</Label>
{isEditing ? (
<Input
value={editingData.phone || ""}
onChange={(e) => handlePhoneChange(e.target.value)}
className="mt-2"
placeholder="(00) 00000-0000"
maxLength={15}
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.phone || "Não preenchido"}
</div>
)}
</div>
{/* Endereço */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Logradouro
</Label>
{isEditing ? (
<Input
value={editingData.street || ""}
onChange={(e) => setEditingData({...editingData, street: e.target.value})}
className="mt-2"
placeholder="Rua, avenida, etc."
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.street || "Não preenchido"}
</div>
)}
</div>
{/* Número */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Número
</Label>
{isEditing ? (
<Input
value={editingData.number || ""}
onChange={(e) => setEditingData({...editingData, number: e.target.value})}
className="mt-2"
placeholder="123"
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.number || "Não preenchido"}
</div>
)}
</div>
{/* Complemento */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Complemento
</Label>
{isEditing ? (
<Input
value={editingData.complement || ""}
onChange={(e) => setEditingData({...editingData, complement: e.target.value})}
className="mt-2"
placeholder="Apto 42, Bloco B, etc."
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.complement || "Não preenchido"}
</div>
)}
</div>
{/* Bairro */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Bairro
</Label>
{isEditing ? (
<Input
value={editingData.neighborhood || ""}
onChange={(e) => setEditingData({...editingData, neighborhood: e.target.value})}
className="mt-2"
placeholder="Vila, bairro, etc."
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.neighborhood || "Não preenchido"}
</div>
)}
</div>
{/* Cidade */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Cidade
</Label>
{isEditing ? (
<Input
value={editingData.city || ""}
onChange={(e) => setEditingData({...editingData, city: e.target.value})}
className="mt-2"
placeholder="São Paulo"
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.city || "Não preenchido"}
</div>
)}
</div>
{/* Estado */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Estado
</Label>
{isEditing ? (
<Input
value={editingData.state || ""}
onChange={(e) => setEditingData({...editingData, state: e.target.value})}
className="mt-2"
placeholder="SP"
maxLength={2}
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.state || "Não preenchido"}
</div>
)}
</div>
{/* CEP */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
CEP
</Label>
{isEditing ? (
<div className="space-y-2">
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
value={editingData.cep || ""}
onChange={(e) => handleCepChange(e.target.value)}
className="mt-2"
placeholder="00000-000"
maxLength={9}
disabled={cepLoading}
/>
</div>
{cepValid === true && (
<CheckCircle className="h-5 w-5 text-green-500 mb-2" />
)}
{cepValid === false && (
<XCircle className="h-5 w-5 text-red-500 mb-2" />
)}
</div>
{cepLoading && (
<p className="text-xs text-muted-foreground">Buscando CEP...</p>
)}
{cepValid === false && (
<p className="text-xs text-red-500">CEP inválido ou não encontrado</p>
)}
{cepValid === true && (
<p className="text-xs text-green-500"> CEP preenchido com sucesso</p>
)}
</div>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.cep || "Não preenchido"}
</div>
)}
</div>
</div>
</div>
</div>
{/* Coluna Direita - Foto do Perfil */}
<div>
<div className="border border-border rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4">Foto do Perfil</h3>
{isEditing ? (
<div className="space-y-4">
<UploadAvatar
userId={userInfo.user.id}
currentAvatarUrl={editingData.avatar_url || userInfo.profile?.avatar_url || "/avatars/01.png"}
onAvatarChange={(newUrl) => setEditingData({...editingData, avatar_url: newUrl})}
userName={editingData.full_name || userInfo.profile?.full_name || "Usuário"}
/>
</div>
) : (
<div className="flex flex-col items-center gap-4">
<Avatar className="h-24 w-24">
<AvatarImage
src={userInfo.profile?.avatar_url || "/avatars/01.png"}
alt={userInfo.profile?.full_name || "Usuário"}
/>
<AvatarFallback className="bg-primary text-primary-foreground text-2xl font-bold">
{getInitials(userInfo.profile?.full_name)}
</AvatarFallback>
</Avatar>
<div className="text-center space-y-2">
<p className="text-sm text-muted-foreground">
{getInitials(userInfo.profile?.full_name)}
</p>
</div>
</div>
)}
{/* Informações de Status */}
<div className="mt-6 pt-6 border-t border-border space-y-4">
<div>
<Label className="text-sm font-medium text-muted-foreground">
Status
</Label>
<div className="mt-2">
<Badge
variant={
userInfo.profile?.disabled ? "destructive" : "default"
}
>
{userInfo.profile?.disabled ? "Desabilitado" : "Ativo"}
</Badge>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Botão Voltar */}
<div className="flex gap-3 pb-6">
<Button
variant="outline"
onClick={() => router.back()}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Voltar
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { CalendarRegistrationForm } from "@/components/forms/calendar-registration-form";
import HeaderAgenda from "@/components/agenda/HeaderAgenda";
import FooterAgenda from "@/components/agenda/FooterAgenda";
@ -38,7 +38,6 @@ interface FormData {
export default function NovoAgendamentoPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [formData, setFormData] = useState<FormData>({});
const handleFormChange = (data: FormData) => {
@ -88,7 +87,7 @@ export default function NovoAgendamentoPage() {
const handleCancel = () => {
// If origin was provided (eg: consultas), return there. Default to calendar.
try {
const origin = searchParams?.get?.('origin');
const origin = (typeof window !== 'undefined') ? new URLSearchParams(window.location.search).get('origin') : null;
if (origin === 'consultas') {
router.push('/consultas');
return;

View File

@ -17,8 +17,10 @@ import Link from 'next/link'
import ProtectedRoute from '@/components/ProtectedRoute'
import { useAuth } from '@/hooks/useAuth'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { buscarPacientes, buscarPacientePorUserId, getUserInfo, listarMensagensPorPaciente } from '@/lib/api'
import { useReports } from '@/hooks/useReports'
import { buscarPacientes, buscarPacientePorUserId, getUserInfo, listarAgendamentos, buscarMedicosPorIds, atualizarPaciente, buscarPacientePorId } from '@/lib/api'
import { ENV_CONFIG } from '@/lib/env-config'
import { listarRelatoriosPorPaciente } from '@/lib/reports'
// reports are rendered statically for now
// Simulação de internacionalização básica
const strings = {
dashboard: 'Dashboard',
@ -53,7 +55,7 @@ const strings = {
export default function PacientePage() {
const { logout, user } = useAuth()
const [tab, setTab] = useState<'dashboard'|'consultas'|'exames'|'mensagens'|'perfil'>('dashboard')
const [tab, setTab] = useState<'dashboard'|'consultas'|'exames'|'perfil'>('dashboard')
// Simulação de loaders, empty states e erro
const [loading, setLoading] = useState(false)
@ -236,44 +238,142 @@ export default function PacientePage() {
const handleProfileChange = (field: string, value: string) => {
setProfileData((prev: any) => ({ ...prev, [field]: value }))
}
const handleSaveProfile = () => {
setIsEditingProfile(false)
setToast({ type: 'success', msg: strings.sucesso })
const handleSaveProfile = async () => {
if (!patientId) {
setToast({ type: 'error', msg: 'Paciente não identificado. Não foi possível salvar.' })
setIsEditingProfile(false)
return
}
setLoading(true)
try {
const payload: any = {}
if (profileData.email) payload.email = profileData.email
if (profileData.telefone) payload.phone_mobile = profileData.telefone
if (profileData.endereco) payload.street = profileData.endereco
if (profileData.cidade) payload.city = profileData.cidade
if (profileData.cep) payload.cep = profileData.cep
if (profileData.biografia) payload.notes = profileData.biografia
await atualizarPaciente(String(patientId), payload)
// refresh patient row
const refreshed = await buscarPacientePorId(String(patientId)).catch(() => null)
if (refreshed) {
const getFirst = (obj: any, keys: string[]) => {
if (!obj) return undefined
for (const k of keys) {
const v = obj[k]
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v)
}
return undefined
}
const nome = getFirst(refreshed, ['full_name','fullName','name','nome','social_name']) || profileData.nome
const telefone = getFirst(refreshed, ['phone_mobile','phone','telefone','mobile']) || profileData.telefone
const rua = getFirst(refreshed, ['street','logradouro','endereco','address'])
const numero = getFirst(refreshed, ['number','numero'])
const bairro = getFirst(refreshed, ['neighborhood','bairro'])
const endereco = rua ? (numero ? `${rua}, ${numero}` : rua) + (bairro ? ` - ${bairro}` : '') : profileData.endereco
const cidade = getFirst(refreshed, ['city','cidade','localidade']) || profileData.cidade
const cep = getFirst(refreshed, ['cep','postal_code','zip']) || profileData.cep
const biografia = getFirst(refreshed, ['biography','bio','notes']) || profileData.biografia || ''
const emailFromRow = getFirst(refreshed, ['email']) || profileData.email
const foto = getFirst(refreshed, ['foto_url','avatar_url','fotoUrl']) || profileData.foto_url
setProfileData((prev:any) => ({ ...prev, nome, email: emailFromRow, telefone, endereco, cidade, cep, biografia, foto_url: foto }))
}
setIsEditingProfile(false)
setToast({ type: 'success', msg: strings.sucesso })
} catch (err: any) {
console.warn('[PacientePage] erro ao atualizar paciente', err)
setToast({ type: 'error', msg: err?.message || strings.erroSalvar })
} finally {
setLoading(false)
}
}
const handleCancelEdit = () => {
setIsEditingProfile(false)
}
function DashboardCards() {
const [nextAppt, setNextAppt] = useState<string | null>(null)
const [examsCount, setExamsCount] = useState<number | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
let mounted = true
async function load() {
if (!patientId) {
setNextAppt(null)
setExamsCount(null)
return
}
setLoading(true)
try {
// Load appointments for this patient (upcoming)
const q = `patient_id=eq.${encodeURIComponent(String(patientId))}&order=scheduled_at.asc&limit=200`
const ags = await listarAgendamentos(q).catch(() => [])
if (!mounted) return
const now = Date.now()
// find the first appointment with scheduled_at >= now
const upcoming = (ags || []).map((a: any) => ({ ...a, _sched: a.scheduled_at ? new Date(a.scheduled_at).getTime() : null }))
.filter((a: any) => a._sched && a._sched >= now)
.sort((x: any, y: any) => Number(x._sched) - Number(y._sched))
if (upcoming && upcoming.length) {
setNextAppt(new Date(upcoming[0]._sched).toLocaleDateString('pt-BR'))
} else {
setNextAppt(null)
}
// Load reports/laudos count
const reports = await listarRelatoriosPorPaciente(String(patientId)).catch(() => [])
if (!mounted) return
setExamsCount(Array.isArray(reports) ? reports.length : 0)
} catch (e) {
console.warn('[DashboardCards] erro ao carregar dados', e)
if (!mounted) return
setNextAppt(null)
setExamsCount(null)
} finally {
if (mounted) setLoading(false)
}
}
load()
return () => { mounted = false }
}, [patientId])
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<Card className="flex flex-col items-center justify-center p-4">
<Calendar className="mb-2 text-primary" aria-hidden />
<span className="font-semibold">{strings.proximaConsulta}</span>
<span className="text-2xl">12/10/2025</span>
<span className="text-2xl">{loading ? '...' : (nextAppt ?? '-')}</span>
</Card>
<Card className="flex flex-col items-center justify-center p-4">
<FileText className="mb-2 text-primary" aria-hidden />
<span className="font-semibold">{strings.ultimosExames}</span>
<span className="text-2xl">2</span>
<span className="text-2xl">{loading ? '...' : (examsCount !== null ? String(examsCount) : '-')}</span>
</Card>
<Card className="flex flex-col items-center justify-center p-4">
<MessageCircle className="mb-2 text-primary" aria-hidden />
<span className="font-semibold">{strings.mensagensNaoLidas}</span>
<span className="text-2xl">1</span>
</Card>
</div>
</div>
)
}
// Consultas fictícias
const [currentDate, setCurrentDate] = useState(new Date())
// helper: produce a local YYYY-MM-DD key (uses local timezone, not toISOString UTC)
const localDateKey = (d: Date) => {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
const consultasFicticias = [
{
id: 1,
medico: "Dr. Carlos Andrade",
especialidade: "Cardiologia",
local: "Clínica Coração Feliz",
data: new Date().toISOString().split('T')[0],
data: localDateKey(new Date()),
hora: "09:00",
status: "Confirmada"
},
@ -282,7 +382,7 @@ export default function PacientePage() {
medico: "Dra. Fernanda Lima",
especialidade: "Dermatologia",
local: "Clínica Pele Viva",
data: new Date().toISOString().split('T')[0],
data: localDateKey(new Date()),
hora: "14:30",
status: "Pendente"
},
@ -291,7 +391,7 @@ export default function PacientePage() {
medico: "Dr. João Silva",
especialidade: "Ortopedia",
local: "Hospital Ortopédico",
data: (() => { let d = new Date(); d.setDate(d.getDate()+1); return d.toISOString().split('T')[0] })(),
data: (() => { let d = new Date(); d.setDate(d.getDate()+1); return localDateKey(d) })(),
hora: "11:00",
status: "Cancelada"
},
@ -310,7 +410,7 @@ export default function PacientePage() {
setCurrentDate(new Date());
}
const todayStr = currentDate.toISOString().split('T')[0];
const todayStr = localDateKey(currentDate)
const consultasDoDia = consultasFicticias.filter(c => c.data === todayStr);
function Consultas() {
@ -323,19 +423,132 @@ export default function PacientePage() {
const activeToggleClass = "w-full transition duration-200 focus-visible:ring-2 focus-visible:ring-[#2563eb]/60 active:scale-[0.97] bg-[#2563eb] text-white hover:bg-[#2563eb] hover:text-white"
const inactiveToggleClass = "w-full transition duration-200 bg-slate-50 text-[#2563eb] border border-[#2563eb]/30 hover:bg-slate-100 hover:text-[#2563eb] dark:bg-white/5 dark:text-white dark:hover:bg-white/10 dark:border-white/20"
const hoverPrimaryIconClass = "rounded-xl bg-white text-[#1e293b] border border-black/10 shadow-[0_2px_8px_rgba(0,0,0,0.03)] transition duration-200 hover:bg-[#2563eb] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2563eb] dark:bg-slate-800 dark:text-slate-100 dark:border-white/10 dark:shadow-none dark:hover:bg-[#2563eb] dark:hover:text-white"
const today = new Date(); today.setHours(0, 0, 0, 0);
const selectedDate = new Date(currentDate); selectedDate.setHours(0, 0, 0, 0);
const isSelectedDateToday = selectedDate.getTime() === today.getTime()
const today = new Date(); today.setHours(0, 0, 0, 0);
const selectedDate = new Date(currentDate); selectedDate.setHours(0, 0, 0, 0);
const isSelectedDateToday = selectedDate.getTime() === today.getTime()
const handlePesquisar = () => {
const params = new URLSearchParams({
tipo: tipoConsulta,
especialidade,
local: localizacao
})
router.push(`/resultados?${params.toString()}`)
// Appointments state (loaded when "Ver consultas agendadas" is opened)
const [appointments, setAppointments] = useState<any[] | null>(null)
const [loadingAppointments, setLoadingAppointments] = useState(false)
const [appointmentsError, setAppointmentsError] = useState<string | null>(null)
useEffect(() => {
let mounted = true
if (!mostrarAgendadas) return
if (!patientId) {
setAppointmentsError('Paciente não identificado. Faça login novamente.')
return
}
async function loadAppointments() {
try {
setLoadingAppointments(true)
setAppointmentsError(null)
setAppointments(null)
// Try `eq.` first, then fallback to `in.(id)` which some views expect
const baseEncoded = encodeURIComponent(String(patientId))
const queriesToTry = [
`patient_id=eq.${baseEncoded}&order=scheduled_at.asc&limit=200`,
`patient_id=in.(${baseEncoded})&order=scheduled_at.asc&limit=200`,
];
let rows: any[] = []
for (const q of queriesToTry) {
try {
// Debug: also fetch raw response to inspect headers/response body in the browser
try {
const token = (typeof window !== 'undefined') ? (localStorage.getItem('auth_token') || localStorage.getItem('token') || sessionStorage.getItem('auth_token') || sessionStorage.getItem('token')) : null
const headers: Record<string,string> = {
apikey: ENV_CONFIG.SUPABASE_ANON_KEY,
Accept: 'application/json',
}
if (token) headers.Authorization = `Bearer ${token}`
const rawUrl = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/appointments?${q}`
console.debug('[Consultas][debug] GET', rawUrl, 'Headers(masked):', { ...headers, Authorization: headers.Authorization ? `${String(headers.Authorization).slice(0,6)}...${String(headers.Authorization).slice(-6)}` : undefined })
const rawRes = await fetch(rawUrl, { method: 'GET', headers })
const rawText = await rawRes.clone().text().catch(() => '')
console.debug('[Consultas][debug] raw response', { url: rawUrl, status: rawRes.status, bodyPreview: (typeof rawText === 'string' && rawText.length > 0) ? rawText.slice(0, 200) : rawText })
} catch (dbgErr) {
console.debug('[Consultas][debug] não foi possível capturar raw response', dbgErr)
}
const r = await listarAgendamentos(q)
if (r && Array.isArray(r) && r.length) {
rows = r
break
}
// if r is empty array, continue to next query format
} catch (e) {
// keep trying next format
console.debug('[Consultas] tentativa listarAgendamentos falhou para query', q, e)
}
}
if (!mounted) return
if (!rows || rows.length === 0) {
// no appointments found for this patient using either filter
setAppointments([])
return
}
const doctorIds = Array.from(new Set(rows.map((r: any) => r.doctor_id).filter(Boolean)))
const doctorsMap: Record<string, any> = {}
if (doctorIds.length) {
try {
const docs = await buscarMedicosPorIds(doctorIds).catch(() => [])
for (const d of docs || []) doctorsMap[d.id] = d
} catch (e) {
// ignore
}
}
const mapped = (rows || []).map((a: any) => {
const sched = a.scheduled_at ? new Date(a.scheduled_at) : null
const doc = a.doctor_id ? doctorsMap[String(a.doctor_id)] : null
return {
id: a.id,
medico: doc?.full_name || a.doctor_id || '---',
especialidade: doc?.specialty || '',
local: a.location || a.place || '',
data: sched ? localDateKey(sched) : '',
hora: sched ? sched.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '',
status: a.status ? String(a.status) : 'Pendente',
}
})
setAppointments(mapped)
} catch (err: any) {
console.warn('[Consultas] falha ao carregar agendamentos', err)
if (!mounted) return
setAppointmentsError(err?.message ?? 'Falha ao carregar agendamentos.')
setAppointments([])
} finally {
if (mounted) setLoadingAppointments(false)
}
}
loadAppointments()
return () => { mounted = false }
}, [mostrarAgendadas, patientId])
// Monta a URL de resultados com os filtros atuais
const buildResultadosHref = () => {
const qs = new URLSearchParams()
qs.set('tipo', tipoConsulta) // 'teleconsulta' | 'presencial'
if (especialidade) qs.set('especialidade', especialidade)
if (localizacao) qs.set('local', localizacao)
// indicate navigation origin so destination can alter UX (e.g., show modal instead of redirect)
qs.set('origin', 'paciente')
return `/resultados?${qs.toString()}`
}
// derived lists for the "Ver consultas agendadas" dialog (computed after appointments state is declared)
const _dialogSource = (appointments !== null ? appointments : consultasFicticias)
const _todaysAppointments = (_dialogSource || []).filter((c: any) => c.data === todayStr)
return (
<section className="bg-card shadow-md rounded-lg border border-border p-6">
<div className="max-w-3xl mx-auto space-y-8">
@ -398,11 +611,11 @@ export default function PacientePage() {
</div>
</div>
<Button
className={`w-full md:w-auto md:self-start ${hoverPrimaryClass}`}
onClick={handlePesquisar}
>
Pesquisar
{/* Botão agora redireciona direto para /resultados */}
<Button asChild className={`w-full md:w-auto md:self-start ${hoverPrimaryClass}`}>
<Link href={buildResultadosHref()} prefetch={false}>
Pesquisar
</Link>
</Button>
</div>
@ -419,7 +632,7 @@ export default function PacientePage() {
</div>
<Dialog open={mostrarAgendadas} onOpenChange={open => setMostrarAgendadas(open)}>
<DialogContent className="max-w-3xl space-y-6 sm:max-h-[85vh] overflow-hidden">
<DialogContent className="max-w-3xl space-y-6 sm:max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-2xl font-semibold text-foreground">Consultas agendadas</DialogTitle>
<DialogDescription>Gerencie suas consultas confirmadas, pendentes ou canceladas.</DialogDescription>
@ -431,7 +644,7 @@ export default function PacientePage() {
type="button"
variant="outline"
size="icon"
onClick={() => navigateDate('prev')}
onClick={(e: any) => { e.stopPropagation(); e.preventDefault(); navigateDate('prev') }}
aria-label="Dia anterior"
className={`group shadow-sm ${hoverPrimaryIconClass}`}
>
@ -442,7 +655,7 @@ export default function PacientePage() {
type="button"
variant="outline"
size="icon"
onClick={() => navigateDate('next')}
onClick={(e: any) => { e.stopPropagation(); e.preventDefault(); navigateDate('next') }}
aria-label="Próximo dia"
className={`group shadow-sm ${hoverPrimaryIconClass}`}
>
@ -462,84 +675,95 @@ export default function PacientePage() {
)}
</div>
<div className="text-sm text-muted-foreground">
{consultasDoDia.length} consulta{consultasDoDia.length !== 1 ? 's' : ''} agendada{consultasDoDia.length !== 1 ? 's' : ''}
{`${_todaysAppointments.length} consulta${_todaysAppointments.length !== 1 ? 's' : ''} agendada${_todaysAppointments.length !== 1 ? 's' : ''}`}
</div>
</div>
<div className="flex flex-col gap-4 overflow-y-auto max-h-[70vh] pr-1 sm:pr-2">
{consultasDoDia.length === 0 ? (
<div className="text-center py-10 text-muted-foreground">
<Calendar className="h-12 w-12 mx-auto mb-4 opacity-60" />
<p className="text-lg font-medium">Nenhuma consulta agendada para este dia</p>
<p className="text-sm">Use a busca para marcar uma nova consulta.</p>
</div>
<div className="flex-1 flex flex-col gap-4 overflow-y-auto pr-1 sm:pr-2 pb-6">
{loadingAppointments && mostrarAgendadas ? (
<div className="text-center py-10 text-muted-foreground">Carregando consultas...</div>
) : appointmentsError ? (
<div className="text-center py-10 text-red-600">{appointmentsError}</div>
) : (
consultasDoDia.map(consulta => (
<div
key={consulta.id}
className="rounded-xl border border-black/5 dark:border-white/10 bg-card shadow-[0_4px_12px_rgba(0,0,0,0.05)] dark:shadow-none p-5"
>
<div className="grid gap-4 md:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.4fr)] items-start">
<div className="flex items-start gap-3">
<span
className="mt-1 h-3 w-3 flex-shrink-0 rounded-full"
style={{ backgroundColor: consulta.status === 'Confirmada' ? '#22c55e' : consulta.status === 'Pendente' ? '#fbbf24' : '#ef4444' }}
/>
<div className="space-y-1">
<div className="font-medium flex items-center gap-2 text-foreground">
<Stethoscope className="h-4 w-4 text-muted-foreground" />
{consulta.medico}
// prefer appointments (client-loaded) when present; fallback to fictitious list
(() => {
const todays = _todaysAppointments
if (!todays || todays.length === 0) {
return (
<div className="text-center py-10 text-muted-foreground">
<Calendar className="h-12 w-12 mx-auto mb-4 opacity-60" />
<p className="text-lg font-medium">Nenhuma consulta agendada para este dia</p>
<p className="text-sm">Use a busca para marcar uma nova consulta.</p>
</div>
)
}
return todays.map((consulta: any) => (
<div
key={consulta.id}
className="rounded-xl border border-black/5 dark:border-white/10 bg-card shadow-[0_4px_12px_rgba(0,0,0,0.05)] dark:shadow-none p-5"
>
<div className="grid gap-4 md:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.4fr)] items-start">
<div className="flex items-start gap-3">
<span
className="mt-1 h-3 w-3 flex-shrink-0 rounded-full"
style={{ backgroundColor: consulta.status === 'Confirmada' ? '#22c55e' : consulta.status === 'Pendente' ? '#fbbf24' : '#ef4444' }}
/>
<div className="space-y-1">
<div className="font-medium flex items-center gap-2 text-foreground">
<Stethoscope className="h-4 w-4 text-muted-foreground" />
{consulta.medico}
</div>
<p className="text-sm text-muted-foreground break-words">
{consulta.especialidade} {consulta.local}
</p>
</div>
<p className="text-sm text-muted-foreground break-words">
{consulta.especialidade} {consulta.local}
</p>
</div>
</div>
<div className="flex items-center gap-2 text-foreground">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{consulta.hora}</span>
</div>
<div className="flex items-center gap-2 text-foreground">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{consulta.hora}</span>
</div>
<div className="flex items-center">
<span className={`px-3 py-1 rounded-full text-sm font-medium text-white ${consulta.status === 'Confirmada' ? 'bg-green-600' : consulta.status === 'Pendente' ? 'bg-yellow-500' : 'bg-red-600'}`}>
{consulta.status}
</span>
</div>
<div className="flex items-center">
<span className={`px-3 py-1 rounded-full text-sm font-medium text-white ${consulta.status === 'Confirmada' ? 'bg-green-600' : consulta.status === 'Pendente' ? 'bg-yellow-500' : 'bg-red-600'}`}>
{consulta.status}
</span>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="border border-[#2563eb]/40 text-[#2563eb] hover:bg-transparent hover:text-[#2563eb] focus-visible:ring-2 focus-visible:ring-[#2563eb]/40 active:scale-[0.97]"
>
Detalhes
</Button>
{consulta.status !== 'Cancelada' && (
<Button type="button" variant="secondary" size="sm" className={hoverPrimaryClass}>
Reagendar
</Button>
)}
{consulta.status !== 'Cancelada' && (
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="destructive"
variant="outline"
size="sm"
className="transition duration-200 hover:bg-[#dc2626] focus-visible:ring-2 focus-visible:ring-[#dc2626]/60 active:scale-[0.97]"
className="border border-[#2563eb]/40 text-[#2563eb] hover:bg-transparent hover:text-[#2563eb] focus-visible:ring-2 focus-visible:ring-[#2563eb]/40 active:scale-[0.97]"
>
Cancelar
Detalhes
</Button>
)}
{consulta.status !== 'Cancelada' && (
<Button type="button" variant="secondary" size="sm" className={hoverPrimaryClass}>
Reagendar
</Button>
)}
{consulta.status !== 'Cancelada' && (
<Button
type="button"
variant="destructive"
size="sm"
className="transition duration-200 hover:bg-[#dc2626] focus-visible:ring-2 focus-visible:ring-[#dc2626]/60 active:scale-[0.97]"
>
Cancelar
</Button>
)}
</div>
</div>
</div>
</div>
))
))
})()
)}
</div>
<DialogFooter className="justify-center border-t border-border pt-4 mt-2">
<Button variant="outline" onClick={() => setMostrarAgendadas(false)} className="w-full sm:w-auto">
<Button variant="outline" onClick={() => { setMostrarAgendadas(false) }} className="w-full sm:w-auto">
Fechar
</Button>
</DialogFooter>
@ -549,120 +773,164 @@ export default function PacientePage() {
)
}
// Reports (laudos) hook
const { reports, loadReportsByPatient, loading: reportsLoading } = useReports()
// Selected report state
const [selectedReport, setSelectedReport] = useState<any | null>(null)
function ExamesLaudos() {
const [reports, setReports] = useState<any[] | null>(null)
const [loadingReports, setLoadingReports] = useState(false)
const [reportsError, setReportsError] = useState<string | null>(null)
const [reportDoctorName, setReportDoctorName] = useState<string | null>(null)
useEffect(() => {
let mounted = true
if (!patientId) return
// load laudos for this patient
loadReportsByPatient(patientId).catch(() => {})
setLoadingReports(true)
setReportsError(null)
listarRelatoriosPorPaciente(String(patientId))
.then(res => {
if (!mounted) return
setReports(Array.isArray(res) ? res : [])
})
.catch(err => {
console.warn('[ExamesLaudos] erro ao carregar laudos', err)
if (!mounted) return
setReportsError('Falha ao carregar laudos.')
})
.finally(() => { if (mounted) setLoadingReports(false) })
return () => { mounted = false }
}, [patientId])
// When a report is selected, try to fetch doctor name if we have an id
useEffect(() => {
let mounted = true
if (!selectedReport) {
setReportDoctorName(null)
return
}
const maybeDoctorId = selectedReport.doctor_id || selectedReport.created_by || null
if (!maybeDoctorId) {
setReportDoctorName(null)
return
}
(async () => {
try {
const docs = await buscarMedicosPorIds([String(maybeDoctorId)]).catch(() => [])
if (!mounted) return
if (docs && docs.length) {
const doc0: any = docs[0]
setReportDoctorName(doc0.full_name || doc0.name || doc0.fullName || null)
}
} catch (e) {
// ignore
}
})()
return () => { mounted = false }
}, [selectedReport])
return (
<section className="bg-card shadow-md rounded-lg border border-border p-6">
<h2 className="text-2xl font-bold mb-6">Laudos</h2>
{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">
{reports.map((r: any) => (
<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 className="space-y-3">
{loadingReports ? (
<div className="text-center py-8 text-muted-foreground">{strings.carregando}</div>
) : reportsError ? (
<div className="text-center py-8 text-red-600">{reportsError}</div>
) : (!reports || reports.length === 0) ? (
<div className="text-center py-8 text-muted-foreground">Nenhum laudo encontrado para este paciente.</div>
) : (
reports.map((r) => (
<div key={r.id || JSON.stringify(r)} className="flex flex-col md:flex-row md:items-center md:justify-between bg-muted rounded p-4">
<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(r.report_date || r.data || r.created_at || Date.now()).toLocaleDateString('pt-BR')}</div>
<div className="font-medium text-foreground">{r.title || r.name || r.report_name || 'Laudo'}</div>
<div className="text-sm text-muted-foreground">Data: {new Date(r.report_date || r.created_at || Date.now()).toLocaleDateString('pt-BR')}</div>
</div>
<div className="flex gap-2 mt-2 md:mt-0">
<Button variant="outline" onClick={async () => { setSelectedReport(r); }}>Visualizar</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>
<Button variant="outline" onClick={async () => { setSelectedReport(r); }}>{strings.visualizarLaudo}</Button>
<Button variant="secondary" onClick={async () => { try { await navigator.clipboard.writeText(JSON.stringify(r)); setToast({ type: 'success', msg: 'Laudo copiado.' }) } catch { setToast({ type: 'error', msg: 'Falha ao copiar.' }) } }}>{strings.compartilhar}</Button>
</div>
</div>
))}
</div>
)}
))
)}
</div>
<Dialog open={!!selectedReport} onOpenChange={open => !open && setSelectedReport(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Laudo Médico</DialogTitle>
<DialogDescription>
{selectedReport && (
<>
<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(selectedReport.report_date || selectedReport.data || selectedReport.created_at || Date.now()).toLocaleDateString('pt-BR')}</div>
<div className="mb-4 whitespace-pre-line">{selectedReport.content || selectedReport.laudo || selectedReport.body || JSON.stringify(selectedReport, null, 2)}</div>
</>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setSelectedReport(null)}>Fechar</Button>
</DialogFooter>
</DialogContent>
<DialogHeader>
<DialogTitle>Laudo Médico</DialogTitle>
<DialogDescription>
{selectedReport && (
<>
<div className="mb-2">
<div className="font-semibold text-lg">{selectedReport.title || selectedReport.name || 'Laudo'}</div>
<div className="text-sm text-muted-foreground">Data: {new Date(selectedReport.report_date || selectedReport.created_at || Date.now()).toLocaleDateString('pt-BR')}</div>
{reportDoctorName && <div className="text-sm text-muted-foreground">Profissional: <strong className="text-foreground">{reportDoctorName}</strong></div>}
</div>
{/* Standardized laudo sections: CID, Exame, Diagnóstico, Conclusão, Notas (prefer HTML when available) */}
{(() => {
const cid = selectedReport.cid ?? selectedReport.cid_code ?? selectedReport.cidCode ?? selectedReport.cie ?? '-'
const exam = selectedReport.exam ?? selectedReport.exame ?? selectedReport.especialidade ?? selectedReport.report_type ?? '-'
const diagnosis = selectedReport.diagnosis ?? selectedReport.diagnostico ?? selectedReport.diagnosis_text ?? selectedReport.diagnostico_text ?? ''
const conclusion = selectedReport.conclusion ?? selectedReport.conclusao ?? selectedReport.conclusion_text ?? selectedReport.conclusao_text ?? ''
const notesHtml = selectedReport.content_html ?? selectedReport.conteudo_html ?? selectedReport.contentHtml ?? null
const notesText = selectedReport.content ?? selectedReport.body ?? selectedReport.conteudo ?? selectedReport.notes ?? selectedReport.observacoes ?? ''
return (
<div className="space-y-3 mb-4">
<div>
<div className="text-xs text-muted-foreground">CID</div>
<div className="text-foreground">{cid || '-'}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Exame</div>
<div className="text-foreground">{exam || '-'}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Diagnóstico</div>
<div className="whitespace-pre-line text-foreground">{diagnosis || '-'}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Conclusão</div>
<div className="whitespace-pre-line text-foreground">{conclusion || '-'}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Notas do Profissional</div>
{notesHtml ? (
<div className="prose max-w-none p-2 bg-muted rounded" dangerouslySetInnerHTML={{ __html: String(notesHtml) }} />
) : (
<div className="whitespace-pre-line text-foreground p-2 bg-muted rounded">{notesText || '-'}</div>
)}
</div>
</div>
)
})()}
{/* Optional: doctor signature or footer */}
{selectedReport.doctor_signature && (
<div className="mt-4 text-sm text-muted-foreground">Assinatura: <img src={selectedReport.doctor_signature} alt="assinatura" className="inline-block h-10" /></div>
)}
</>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setSelectedReport(null)}>Fechar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
)
}
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 (
<section className="bg-card shadow-md rounded-lg border border-border p-6">
<h2 className="text-2xl font-bold mb-6">Mensagens Recebidas</h2>
<div className="space-y-3">
{loadingMsgs ? (
<div className="text-center py-8 text-muted-foreground">Carregando mensagens...</div>
) : msgsError ? (
<div className="text-center py-8 text-red-600">{msgsError}</div>
) : (!msgs || msgs.length === 0) ? (
<div className="text-center py-8 text-muted-foreground">Nenhuma mensagem encontrada.</div>
) : (
msgs.map((msg: any) => (
<div key={msg.id || JSON.stringify(msg)} className="bg-muted rounded p-4">
<div className="font-medium text-foreground flex items-center gap-2">
<User className="h-4 w-4 text-primary" />
{msg.sender_name || msg.from || msg.doctor_name || 'Remetente'}
{!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.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>
</section>
)
}
function Perfil() {
const hasAddress = Boolean(profileData.endereco || profileData.cidade || profileData.cep || profileData.biografia)
const hasAddress = Boolean(profileData.endereco || profileData.cidade || profileData.cep)
return (
<div className="space-y-6 max-w-2xl mx-auto">
<div className="flex items-center justify-between">
@ -732,14 +1000,7 @@ export default function PacientePage() {
<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>
{/* Biografia removed: not used */}
</div>
)}
</div>
@ -784,7 +1045,7 @@ export default function PacientePage() {
<Button variant={tab==='dashboard'?'secondary':'ghost'} aria-current={tab==='dashboard'} onClick={()=>setTab('dashboard')} className="justify-start"><Calendar className="mr-2 h-5 w-5" />{strings.dashboard}</Button>
<Button variant={tab==='consultas'?'secondary':'ghost'} aria-current={tab==='consultas'} onClick={()=>setTab('consultas')} className="justify-start"><Calendar className="mr-2 h-5 w-5" />{strings.consultas}</Button>
<Button variant={tab==='exames'?'secondary':'ghost'} aria-current={tab==='exames'} onClick={()=>setTab('exames')} className="justify-start"><FileText className="mr-2 h-5 w-5" />{strings.exames}</Button>
<Button variant={tab==='mensagens'?'secondary':'ghost'} aria-current={tab==='mensagens'} onClick={()=>setTab('mensagens')} className="justify-start"><MessageCircle className="mr-2 h-5 w-5" />{strings.mensagens}</Button>
<Button variant={tab==='perfil'?'secondary':'ghost'} aria-current={tab==='perfil'} onClick={()=>setTab('perfil')} className="justify-start"><UserCog className="mr-2 h-5 w-5" />{strings.perfil}</Button>
</nav>
{/* Conteúdo principal */}
@ -804,7 +1065,7 @@ export default function PacientePage() {
{tab==='dashboard' && <DashboardCards />}
{tab==='consultas' && <Consultas />}
{tab==='exames' && <ExamesLaudos />}
{tab==='mensagens' && <Mensagens />}
{tab==='perfil' && <Perfil />}
</main>
)}

View File

@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select";
import { SimpleThemeToggle } from "@/components/simple-theme-toggle";
import {
Table,
@ -20,13 +21,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
import { User, FolderOpen, X, Users, MessageSquare, ClipboardList, Plus, Edit, Trash2, ChevronLeft, ChevronRight, Clock, FileCheck, Upload, Download, Eye, History, Stethoscope, Pill, Activity, Search } from "lucide-react"
import { Calendar as CalendarIcon, FileText, Settings } from "lucide-react";
@ -39,6 +34,7 @@ import {
import dynamic from "next/dynamic";
import { ENV_CONFIG } from '@/lib/env-config';
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
@ -82,6 +78,20 @@ const colorsByType = {
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
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 ?? '';
@ -101,7 +111,7 @@ const colorsByType = {
};
const ProfissionalPage = () => {
const { logout, user } = useAuth();
const { logout, user, token } = useAuth();
const [activeSection, setActiveSection] = useState('calendario');
const [pacienteSelecionado, setPacienteSelecionado] = useState<any>(null);
@ -374,10 +384,98 @@ const ProfissionalPage = () => {
const [selectedEvent, setSelectedEvent] = useState<any>(null);
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();
console.log("Laudo salvo!");
window.scrollTo({ top: 0, behavior: "smooth" });
setSmsSending(true);
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 +2578,65 @@ const ProfissionalPage = () => {
<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>
<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">
<Label htmlFor="destinatario">Destinatário</Label>
<Select>
<SelectTrigger id="destinatario" className="hover:border-primary focus:border-primary cursor-pointer">
<SelectValue placeholder="Selecione o paciente" />
<Label htmlFor="patientSelect">Paciente *</Label>
<Select
value={commPatientId ?? ''}
onValueChange={(val: string) => {
// Radix Select does not allow an Item with empty string as value.
// Use a sentinel value "__none" for the "-- nenhum --" choice and map it to null here.
const v = val === "__none" ? null : (val || null);
setCommPatientId(v);
if (!v) {
setCommPhoneNumber('');
return;
}
try {
const found = (pacientes || []).find((p: any) => String(p.id ?? p.uuid ?? p.email ?? '') === String(v));
if (found) {
setCommPhoneNumber(
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('');
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="-- nenhum --" />
</SelectTrigger>
<SelectContent className="bg-popover border">
{pacientes.map((paciente) => (
<SelectItem
key={paciente.cpf}
value={paciente.nome}
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"
>
{paciente.nome} - {paciente.cpf}
<SelectContent>
<SelectItem value="__none">-- nenhum --</SelectItem>
{pacientes && pacientes.map((p:any) => (
<SelectItem 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 ?? '')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="tipoMensagem">Tipo de mensagem</Label>
<Select>
<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>
<Label htmlFor="phoneNumber">Número (phone_number)</Label>
<Input id="phoneNumber" placeholder="+5511999999999" value={commPhoneNumber} readOnly disabled className="bg-muted/50" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label htmlFor="dataEnvio">Data de envio</Label>
<p id="dataEnvio" className="text-sm text-muted-foreground">03/09/2025</p>
<div className="space-y-2">
<Label htmlFor="message">Mensagem (message)</Label>
<textarea id="message" className="w-full p-2 border rounded" rows={5} value={commMessage} onChange={(e) => setCommMessage(e.target.value)} />
</div>
<div>
<Label htmlFor="statusEntrega">Status da entrega</Label>
<p id="statusEntrega" className="text-sm text-muted-foreground">Pendente</p>
<div className="flex justify-end mt-6">
<Button onClick={handleSave} disabled={smsSending}>
{smsSending ? 'Enviando...' : 'Enviar SMS'}
</Button>
</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>
);

File diff suppressed because it is too large Load Diff

View File

@ -6,11 +6,13 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { SidebarTrigger } from "../ui/sidebar"
import { SimpleThemeToggle } from "@/components/simple-theme-toggle";
export function PagesHeader({ title = "", subtitle = "" }: { title?: string, subtitle?: string }) {
const { logout, user } = useAuth();
const router = useRouter();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@ -84,7 +86,14 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub
</div>
<div className="py-1">
<button className="w-full text-left px-4 py-2 text-sm hover:bg-accent cursor-pointer">
<button
onClick={(e) => {
e.preventDefault();
setDropdownOpen(false);
router.push('/perfil');
}}
className="w-full text-left px-4 py-2 text-sm hover:bg-accent cursor-pointer"
>
👤 Perfil
</button>
<button className="w-full text-left px-4 py-2 text-sm hover:bg-accent cursor-pointer">

View File

@ -25,14 +25,13 @@ import {
FileText,
BarChart3,
Stethoscope,
User,
} from "lucide-react"
const navigation = [
{ name: "Dashboard", href: "/dashboard", icon: Home },
{ name: "Calendario", href: "/calendar", icon: Calendar },
{ name: "Pacientes", href: "/pacientes", icon: Users },
{ name: "Médicos", href: "/doutores", icon: User },
{ name: "Médicos", href: "/doutores", icon: Stethoscope },
{ name: "Consultas", href: "/consultas", icon: UserCheck },
{ name: "Relatórios", href: "/dashboard/relatorios", icon: BarChart3 },
]

View File

@ -2,7 +2,7 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useMemo, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { loginUser, logoutUser, AuthenticationError } from '@/lib/auth'
import { getUserInfo } from '@/lib/api'
import { getUserInfo, getCurrentUser } from '@/lib/api'
import { ENV_CONFIG } from '@/lib/env-config'
import { isExpired, parseJwt } from '@/lib/jwt'
import { httpClient } from '@/lib/http'
@ -132,6 +132,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Restaurar sessão válida
const userData = JSON.parse(storedUser) as UserData
setToken(storedToken)
// Também buscar o usuário autenticado (/auth/v1/user) para garantir id/email atualizados
try {
const authUser = await getCurrentUser().catch(() => null)
if (authUser) {
userData.id = authUser.id ?? userData.id
userData.email = authUser.email ?? userData.email
}
} catch (e) {
console.warn('[AUTH] Falha ao buscar /auth/v1/user durante restauração de sessão:', e)
}
// Tentar buscar profile consolidado (user-info) e mesclar
try {
const info = await getUserInfo()
@ -247,6 +257,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.warn('[AUTH] Falha ao buscar user-info após login (não crítico):', err)
}
// Também chamar /auth/v1/user para obter dados básicos do usuário autenticado
try {
const curRes = await fetch(`${ENV_CONFIG.SUPABASE_URL}/auth/v1/user`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${response.access_token}`,
'apikey': ENV_CONFIG.SUPABASE_ANON_KEY,
}
})
if (curRes.ok) {
const cu = await curRes.json().catch(() => null)
if (cu && response.user) {
response.user.id = cu.id ?? response.user.id
response.user.email = cu.email ?? response.user.email
}
} else {
// não crítico
console.warn('[AUTH] /auth/v1/user retornou', curRes.status)
}
} catch (e) {
console.warn('[AUTH] Erro ao chamar /auth/v1/user após login (não crítico):', e)
}
saveAuthData(
response.access_token,
response.user,

View File

@ -1196,6 +1196,60 @@ export async function criarAgendamento(input: AppointmentCreate): Promise<Appoin
return created;
}
/**
* Cria um agendamento direto no endpoint REST sem realizar validações locais
* como checagem de disponibilidade ou exceções. Use com cautela.
*/
export async function criarAgendamentoDireto(input: AppointmentCreate & { created_by?: string | null }): Promise<Appointment> {
if (!input || !input.patient_id || !input.doctor_id || !input.scheduled_at) {
throw new Error('Parâmetros inválidos para criar agendamento. patient_id, doctor_id e scheduled_at são obrigatórios.');
}
// Determine created_by: prefer explicit, then localStorage, then user-info
let createdBy: string | null = input.created_by ?? null;
if (!createdBy && typeof window !== 'undefined') {
try {
const raw = localStorage.getItem(AUTH_STORAGE_KEYS.USER);
if (raw) {
const parsed = JSON.parse(raw);
createdBy = parsed?.id ?? parsed?.user?.id ?? null;
}
} catch (e) {
// ignore
}
}
if (!createdBy) {
try {
const info = await getUserInfo().catch(() => null);
createdBy = info?.user?.id ?? null;
} catch (e) {
// ignore
}
}
const payload: any = {
patient_id: input.patient_id,
doctor_id: input.doctor_id,
scheduled_at: new Date(input.scheduled_at).toISOString(),
duration_minutes: input.duration_minutes ?? 30,
appointment_type: input.appointment_type ?? 'presencial',
chief_complaint: input.chief_complaint ?? null,
patient_notes: input.patient_notes ?? null,
insurance_provider: input.insurance_provider ?? null,
};
if (createdBy) payload.created_by = createdBy;
const url = `${REST}/appointments`;
const res = await fetch(url, {
method: 'POST',
headers: withPrefer({ ...baseHeaders(), 'Content-Type': 'application/json' }, 'return=representation'),
body: JSON.stringify(payload),
});
const created = await parse<Appointment>(res);
return created;
}
// Payload for updating an appointment (PATCH /rest/v1/appointments/{id})
export type AppointmentUpdate = Partial<{
scheduled_at: string;
@ -2336,6 +2390,47 @@ export async function getUserInfo(): Promise<UserInfo> {
return await parse<UserInfo>(res);
}
/**
* Retorna dados de usuário específico (apenas admin/gestor)
*
* Endpoint: POST /functions/v1/user-info-by-id/{userId}
*
* @param userId - UUID do usuário a ser consultado
* @returns Informações do usuário (user, profile, roles)
* @throws Erro se não autenticado (401) ou sem permissão (403)
*
* Documentação: https://docs.mediconnect.com
*/
export async function getUserInfoById(userId: string): Promise<UserInfo> {
const jwt = getAuthToken();
if (!jwt) {
// No token available — avoid calling the protected function and throw a friendly error
throw new Error('Você não está autenticado. Faça login para acessar informações do usuário.');
}
if (!userId) {
throw new Error('userId é obrigatório');
}
// ID na URL path, não no body
const url = `${API_BASE}/functions/v1/user-info-by-id/${encodeURIComponent(userId)}`;
const res = await fetch(url, {
method: "POST",
headers: baseHeaders(),
});
// Avoid calling parse() for auth errors to prevent noisy console dumps
if (!res.ok && res.status === 401) {
throw new Error('Você não está autenticado. Faça login novamente.');
}
if (!res.ok && res.status === 403) {
throw new Error('Você não tem permissão para acessar informações de outro usuário. Apenas admin/gestor podem.');
}
return await parse<UserInfo>(res);
}
export type CreateUserInput = {
email: string;
password: string;
@ -2866,3 +2961,206 @@ export async function excluirPerfil(id: string | number): Promise<void> {
await parse<any>(res);
}
// ===== DASHBOARD WIDGETS =====
/**
* Busca contagem total de pacientes
*/
export async function countTotalPatients(): Promise<number> {
try {
const url = `${REST}/patients?select=id&limit=1`;
const res = await fetch(url, {
headers: {
...baseHeaders(),
'Prefer': 'count=exact'
}
});
const countHeader = res.headers.get('content-range');
if (countHeader) {
const match = countHeader.match(/\/(\d+)$/);
return match ? parseInt(match[1]) : 0;
}
return 0;
} catch (err) {
console.error('[countTotalPatients] Erro:', err);
return 0;
}
}
/**
* Busca contagem total de médicos
*/
export async function countTotalDoctors(): Promise<number> {
try {
const url = `${REST}/doctors?select=id&limit=1`;
const res = await fetch(url, {
headers: {
...baseHeaders(),
'Prefer': 'count=exact'
}
});
const countHeader = res.headers.get('content-range');
if (countHeader) {
const match = countHeader.match(/\/(\d+)$/);
return match ? parseInt(match[1]) : 0;
}
return 0;
} catch (err) {
console.error('[countTotalDoctors] Erro:', err);
return 0;
}
}
/**
* Busca contagem de agendamentos para hoje
*/
export async function countAppointmentsToday(): Promise<number> {
try {
const today = new Date().toISOString().split('T')[0];
const tomorrow = new Date(Date.now() + 86400000).toISOString().split('T')[0];
const url = `${REST}/appointments?scheduled_at=gte.${today}T00:00:00&scheduled_at=lt.${tomorrow}T00:00:00&select=id&limit=1`;
const res = await fetch(url, {
headers: {
...baseHeaders(),
'Prefer': 'count=exact'
}
});
const countHeader = res.headers.get('content-range');
if (countHeader) {
const match = countHeader.match(/\/(\d+)$/);
return match ? parseInt(match[1]) : 0;
}
return 0;
} catch (err) {
console.error('[countAppointmentsToday] Erro:', err);
return 0;
}
}
/**
* Busca próximas consultas (próximos 7 dias)
*/
export async function getUpcomingAppointments(limit: number = 10): Promise<any[]> {
try {
const today = new Date().toISOString();
const nextWeek = new Date(Date.now() + 7 * 86400000).toISOString();
const url = `${REST}/appointments?scheduled_at=gte.${today}&scheduled_at=lt.${nextWeek}&order=scheduled_at.asc&limit=${limit}&select=id,scheduled_at,status,doctor_id,patient_id`;
const res = await fetch(url, { headers: baseHeaders() });
return await parse<any[]>(res);
} catch (err) {
console.error('[getUpcomingAppointments] Erro:', err);
return [];
}
}
/**
* Busca agendamentos por data (para gráfico)
*/
export async function getAppointmentsByDateRange(days: number = 14): Promise<any[]> {
try {
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const endDate = new Date().toISOString();
const url = `${REST}/appointments?scheduled_at=gte.${startDate.toISOString()}&scheduled_at=lt.${endDate}&select=scheduled_at,status&order=scheduled_at.asc`;
const res = await fetch(url, { headers: baseHeaders() });
return await parse<any[]>(res);
} catch (err) {
console.error('[getAppointmentsByDateRange] Erro:', err);
return [];
}
}
/**
* Busca novos usuários (últimos 7 dias)
*/
export async function getNewUsersLastDays(days: number = 7): Promise<any[]> {
try {
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const url = `${REST}/profiles?created_at=gte.${startDate.toISOString()}&order=created_at.desc&limit=10&select=id,full_name,email`;
const res = await fetch(url, { headers: baseHeaders() });
return await parse<any[]>(res);
} catch (err) {
console.error('[getNewUsersLastDays] Erro:', err);
return [];
}
}
/**
* Busca relatórios pendentes (draft)
*/
export async function getPendingReports(limit: number = 5): Promise<any[]> {
try {
const url = `${REST}/reports?status=eq.draft&order=created_at.desc&limit=${limit}&select=id,order_number,patient_id,exam,requested_by,created_at`;
const res = await fetch(url, { headers: baseHeaders() });
return await parse<any[]>(res);
} catch (err) {
console.error('[getPendingReports] Erro:', err);
return [];
}
}
/**
* Busca usuários desabilitados (alertas)
*/
export async function getDisabledUsers(limit: number = 5): Promise<any[]> {
try {
const url = `${REST}/profiles?disabled=eq.true&order=updated_at.desc&limit=${limit}&select=id,full_name,email,disabled`;
const res = await fetch(url, { headers: baseHeaders() });
return await parse<any[]>(res);
} catch (err) {
console.error('[getDisabledUsers] Erro:', err);
return [];
}
}
/**
* Busca disponibilidade de médicos (para hoje)
*/
export async function getDoctorsAvailabilityToday(): Promise<any[]> {
try {
const today = new Date();
const weekday = today.getDay();
const url = `${REST}/doctor_availability?weekday=eq.${weekday}&active=eq.true&select=id,doctor_id,start_time,end_time,slot_minutes,appointment_type`;
const res = await fetch(url, { headers: baseHeaders() });
return await parse<any[]>(res);
} catch (err) {
console.error('[getDoctorsAvailabilityToday] Erro:', err);
return [];
}
}
/**
* Busca detalhes de paciente por ID
*/
export async function getPatientById(patientId: string): Promise<any> {
try {
const url = `${REST}/patients?id=eq.${patientId}&select=*&limit=1`;
const res = await fetch(url, { headers: baseHeaders() });
const arr = await parse<any[]>(res);
return arr && arr.length > 0 ? arr[0] : null;
} catch (err) {
console.error('[getPatientById] Erro:', err);
return null;
}
}
/**
* Busca detalhes de médico por ID
*/
export async function getDoctorById(doctorId: string): Promise<any> {
try {
const url = `${REST}/doctors?id=eq.${doctorId}&select=*&limit=1`;
const res = await fetch(url, { headers: baseHeaders() });
const arr = await parse<any[]>(res);
return arr && arr.length > 0 ? arr[0] : null;
} catch (err) {
console.error('[getDoctorById] Erro:', err);
return null;
}
}

View File

@ -19,8 +19,19 @@ function extractProjectRef(url: string): string | null {
*/
function extractProjectRefFromKey(apiKey: string): string | null {
try {
const payload = JSON.parse(atob(apiKey.split('.')[1]));
return payload.ref || null;
const part = apiKey.split('.')[1];
if (!part) return null;
// Decode base64 payload in both browser and Node environments
let jsonStr: string | null = null;
if (typeof atob === 'function') {
try { jsonStr = atob(part); } catch (e) { jsonStr = null; }
}
if (!jsonStr && typeof Buffer !== 'undefined') {
try { jsonStr = Buffer.from(part, 'base64').toString('utf8'); } catch (e) { jsonStr = null; }
}
if (!jsonStr) return null;
const payload = JSON.parse(jsonStr);
return payload?.ref ?? payload?.project_ref ?? null;
} catch {
return null;
}

View File

@ -271,7 +271,9 @@ export async function deletarRelatorio(id: string): Promise<void> {
export async function listarRelatoriosPorPaciente(idPaciente: string): Promise<Report[]> {
try {
console.log('👤 [API RELATÓRIOS] Buscando relatórios do paciente:', idPaciente);
const url = `${BASE_API_RELATORIOS}?patient_id=eq.${idPaciente}`;
// Try a strict eq lookup first (encode the id)
const encodedId = encodeURIComponent(String(idPaciente));
let url = `${BASE_API_RELATORIOS}?patient_id=eq.${encodedId}`;
const headers = obterCabecalhos();
const masked = (headers as any)['Authorization'] ? `${String((headers as any)['Authorization']).slice(0,6)}...${String((headers as any)['Authorization']).slice(-6)}` : null;
console.debug('[listarRelatoriosPorPaciente] URL:', url);
@ -281,8 +283,24 @@ export async function listarRelatoriosPorPaciente(idPaciente: string): Promise<R
headers,
});
const resultado = await tratarRespostaApi<Report[]>(resposta);
console.log('✅ [API RELATÓRIOS] Relatórios do paciente encontrados:', resultado.length);
return resultado;
console.log('✅ [API RELATÓRIOS] Relatórios do paciente encontrados (eq):', resultado.length);
// If eq returned results, return them. Otherwise retry using `in.(id)` which some setups prefer.
if (Array.isArray(resultado) && resultado.length) return resultado;
// Retry with in.(id) clause as a fallback
try {
const inClause = encodeURIComponent(`(${String(idPaciente)})`);
const urlIn = `${BASE_API_RELATORIOS}?patient_id=in.${inClause}`;
console.debug('[listarRelatoriosPorPaciente] retrying with IN clause URL:', urlIn);
const resp2 = await fetch(urlIn, { method: 'GET', headers });
const res2 = await tratarRespostaApi<Report[]>(resp2);
console.log('✅ [API RELATÓRIOS] Relatórios do paciente encontrados (in):', Array.isArray(res2) ? res2.length : 0);
return Array.isArray(res2) ? res2 : [];
} catch (e) {
console.warn('[listarRelatoriosPorPaciente] fallback in.() failed', e);
}
return [];
} catch (erro) {
console.error('❌ [API RELATÓRIOS] Erro ao buscar relatórios do paciente:', erro);
throw erro;

View File

@ -23,3 +23,69 @@ export function validarCPFLocal(cpf: string): boolean {
return true;
}
// ===== FORMATAÇÃO DE CEP =====
export function formatCEP(cep: string): string {
if (!cep) return "";
const cleaned = cep.replace(/\D/g, "");
if (cleaned.length !== 8) return cleaned;
return `${cleaned.slice(0, 5)}-${cleaned.slice(5)}`;
}
export function validarCEP(cep: string): boolean {
if (!cep) return false;
const cleaned = cep.replace(/\D/g, "");
return cleaned.length === 8 && /^\d{8}$/.test(cleaned);
}
// ===== FORMATAÇÃO DE TELEFONE =====
export function formatTelefone(telefone: string): string {
if (!telefone) return "";
const cleaned = telefone.replace(/\D/g, "");
// Formatar em (00) 9XXXX-XXXX ou (00) XXXX-XXXX
if (cleaned.length === 11) {
return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2, 7)}-${cleaned.slice(7)}`;
} else if (cleaned.length === 10) {
return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2, 6)}-${cleaned.slice(6)}`;
}
return cleaned;
}
// ===== BUSCA DE CEP =====
export async function buscarCEP(cep: string): Promise<{
street: string;
neighborhood: string;
city: string;
state: string;
} | null> {
try {
if (!validarCEP(cep)) {
throw new Error("CEP inválido");
}
const cleaned = cep.replace(/\D/g, "");
const response = await fetch(`https://viacep.com.br/ws/${cleaned}/json/`);
if (!response.ok) {
throw new Error("Erro ao buscar CEP");
}
const data = await response.json();
if (data.erro) {
throw new Error("CEP não encontrado");
}
return {
street: data.logradouro || "",
neighborhood: data.bairro || "",
city: data.localidade || "",
state: data.uf || "",
};
} catch (err) {
console.error("[buscarCEP] Erro:", err);
return null;
}
}

File diff suppressed because it is too large Load Diff