Compare commits
20 Commits
d21ed34715
...
47ef207454
| Author | SHA1 | Date | |
|---|---|---|---|
| 47ef207454 | |||
| 3c52ec5e3a | |||
|
|
708ec3cd93 | ||
| 7e17a9847b | |||
|
|
a37dbb4c75 | ||
| 1693a415e2 | |||
|
|
bb6e3b0d25 | ||
|
|
79eb63ad96 | ||
|
|
b53401ff39 | ||
|
|
0c8bc4534a | ||
|
|
cc43a2e9a9 | ||
|
|
7c9b2b6ca3 | ||
|
|
2527d28f6b | ||
| a2ca13607e | |||
| 99bd827c4c | |||
| 770eab9afe | |||
|
|
2c39f404d8 | ||
|
|
6a8a4af756 | ||
|
|
f67ff8df8c | ||
|
|
653b21e2d2 |
@ -4,6 +4,7 @@
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@supabase/supabase-js": "^2.75.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"next": "^16.0.0",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-signature-canvas": "^1.1.0-alpha.2"
|
||||
}
|
||||
|
||||
1107
pnpm-lock.yaml
generated
Normal file
1107
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -19,14 +19,48 @@ import { listarMedicos, excluirMedico, buscarMedicos, buscarMedicoPorId, buscarP
|
||||
import { listAssignmentsForUser } from '@/lib/assignment';
|
||||
|
||||
function normalizeMedico(m: any): Medico {
|
||||
const normalizeSex = (v: any) => {
|
||||
if (v === null || typeof v === 'undefined') return null;
|
||||
const s = String(v || '').trim().toLowerCase();
|
||||
if (!s) return null;
|
||||
const male = new Set(['m','masc','male','masculino','homem','h','1','mas']);
|
||||
const female = new Set(['f','fem','female','feminino','mulher','mul','2','fem']);
|
||||
const other = new Set(['o','outro','other','3','nb','nonbinary','nao binario','não binário']);
|
||||
if (male.has(s)) return 'masculino';
|
||||
if (female.has(s)) return 'feminino';
|
||||
if (other.has(s)) return 'outro';
|
||||
if (['masculino','feminino','outro'].includes(s)) return s;
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatBirth = (v: any) => {
|
||||
if (!v && typeof v !== 'string') return null;
|
||||
const s = String(v || '').trim();
|
||||
if (!s) return null;
|
||||
const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (iso) {
|
||||
const [, y, mth, d] = iso;
|
||||
return `${d.padStart(2,'0')}/${mth.padStart(2,'0')}/${y}`;
|
||||
}
|
||||
const ddmmyyyy = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
||||
if (ddmmyyyy) return s;
|
||||
const parsed = new Date(s);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
const d = String(parsed.getDate()).padStart(2,'0');
|
||||
const mth = String(parsed.getMonth() + 1).padStart(2,'0');
|
||||
const y = String(parsed.getFullYear());
|
||||
return `${d}/${mth}/${y}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
id: String(m.id ?? m.uuid ?? ""),
|
||||
full_name: m.full_name ?? m.nome ?? "", // 👈 Correção: usar full_name como padrão
|
||||
nome_social: m.nome_social ?? m.social_name ?? null,
|
||||
cpf: m.cpf ?? "",
|
||||
rg: m.rg ?? m.document_number ?? null,
|
||||
sexo: m.sexo ?? m.sex ?? null,
|
||||
data_nascimento: m.data_nascimento ?? m.birth_date ?? null,
|
||||
sexo: normalizeSex(m.sexo ?? m.sex ?? m.sexualidade ?? null),
|
||||
data_nascimento: formatBirth(m.data_nascimento ?? m.birth_date ?? m.birthDate ?? null),
|
||||
telefone: m.telefone ?? m.phone_mobile ?? "",
|
||||
celular: m.celular ?? m.phone2 ?? null,
|
||||
contato_emergencia: m.contato_emergencia ?? null,
|
||||
|
||||
34
susconecta/app/(main-routes)/perfil/loading.tsx
Normal file
34
susconecta/app/(main-routes)/perfil/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
653
susconecta/app/(main-routes)/perfil/page.tsx
Normal file
653
susconecta/app/(main-routes)/perfil/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
@ -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">
|
||||
|
||||
@ -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 },
|
||||
]
|
||||
|
||||
@ -202,37 +202,78 @@ export function DoctorRegistrationForm({
|
||||
"";
|
||||
console.log('🎯 Especialidade encontrada:', especialidade);
|
||||
|
||||
const m: any = medico as any;
|
||||
|
||||
const normalizeSex = (v: any): string | null => {
|
||||
if (v === null || typeof v === 'undefined') return null;
|
||||
const s = String(v).trim().toLowerCase();
|
||||
if (!s) return null;
|
||||
const male = new Set(['m','masc','male','masculino','homem','h','1','mas']);
|
||||
const female = new Set(['f','fem','female','feminino','mulher','mul','2','fem']);
|
||||
const other = new Set(['o','outro','other','3','nb','nonbinary','nao binario','não binário']);
|
||||
if (male.has(s)) return 'masculino';
|
||||
if (female.has(s)) return 'feminino';
|
||||
if (other.has(s)) return 'outro';
|
||||
// Already canonical?
|
||||
if (['masculino','feminino','outro'].includes(s)) return s;
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatBirth = (v: any) => {
|
||||
if (!v && typeof v !== 'string') return '';
|
||||
const s = String(v).trim();
|
||||
if (!s) return '';
|
||||
// Accept ISO YYYY-MM-DD or full ISO datetime
|
||||
const isoMatch = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (isoMatch) {
|
||||
const [, y, mth, d] = isoMatch;
|
||||
return `${d.padStart(2,'0')}/${mth.padStart(2,'0')}/${y}`;
|
||||
}
|
||||
// If already dd/mm/yyyy, return as-is
|
||||
const ddmmyyyy = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
||||
if (ddmmyyyy) return s;
|
||||
// Try parsing other common formats
|
||||
const maybe = new Date(s);
|
||||
if (!isNaN(maybe.getTime())) {
|
||||
const d = String(maybe.getDate()).padStart(2,'0');
|
||||
const mm = String(maybe.getMonth() + 1).padStart(2,'0');
|
||||
const y = String(maybe.getFullYear());
|
||||
return `${d}/${mm}/${y}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const formData = {
|
||||
photo: null,
|
||||
full_name: String(medico.full_name || ""),
|
||||
nome_social: String(medico.nome_social || ""),
|
||||
crm: String(medico.crm || ""),
|
||||
estado_crm: String(medico.estado_crm || ""),
|
||||
rqe: String(medico.rqe || ""),
|
||||
formacao_academica: Array.isArray(medico.formacao_academica) ? medico.formacao_academica : [],
|
||||
full_name: String(m.full_name || m.nome || ""),
|
||||
nome_social: String(m.nome_social || m.social_name || ""),
|
||||
crm: String(m.crm || ""),
|
||||
estado_crm: String(m.estado_crm || m.crm_uf || m.crm_state || ""),
|
||||
rqe: String(m.rqe || ""),
|
||||
formacao_academica: Array.isArray(m.formacao_academica) ? m.formacao_academica : [],
|
||||
curriculo: null,
|
||||
especialidade: String(especialidade),
|
||||
cpf: String(medico.cpf || ""),
|
||||
rg: String(medico.rg || ""),
|
||||
sexo: String(medico.sexo || ""),
|
||||
data_nascimento: String(medico.data_nascimento || ""),
|
||||
email: String(medico.email || ""),
|
||||
telefone: String(medico.telefone || ""),
|
||||
celular: String(medico.celular || ""),
|
||||
contato_emergencia: String(medico.contato_emergencia || ""),
|
||||
cep: String(medico.cep || ""),
|
||||
logradouro: String(medico.street || ""),
|
||||
numero: String(medico.number || ""),
|
||||
complemento: String(medico.complement || ""),
|
||||
bairro: String(medico.neighborhood || ""),
|
||||
cidade: String(medico.city || ""),
|
||||
estado: String(medico.state || ""),
|
||||
observacoes: String(medico.observacoes || ""),
|
||||
cpf: String(m.cpf || ""),
|
||||
rg: String(m.rg || m.document_number || ""),
|
||||
sexo: normalizeSex(m.sexo || m.sex || m.sexualidade || null) ?? "",
|
||||
data_nascimento: String(formatBirth(m.data_nascimento || m.birth_date || m.birthDate || "")),
|
||||
email: String(m.email || ""),
|
||||
telefone: String(m.telefone || m.phone_mobile || m.phone || m.mobile || ""),
|
||||
celular: String(m.celular || m.phone2 || ""),
|
||||
contato_emergencia: String(m.contato_emergencia || ""),
|
||||
cep: String(m.cep || ""),
|
||||
logradouro: String(m.street || m.logradouro || ""),
|
||||
numero: String(m.number || m.numero || ""),
|
||||
complemento: String(m.complement || m.complemento || ""),
|
||||
bairro: String(m.neighborhood || m.bairro || ""),
|
||||
cidade: String(m.city || m.cidade || ""),
|
||||
estado: String(m.state || m.estado || ""),
|
||||
observacoes: String(m.observacoes || m.notes || ""),
|
||||
anexos: [],
|
||||
tipo_vinculo: String(medico.tipo_vinculo || ""),
|
||||
dados_bancarios: medico.dados_bancarios || { banco: "", agencia: "", conta: "", tipo_conta: "" },
|
||||
agenda_horario: String(medico.agenda_horario || ""),
|
||||
valor_consulta: medico.valor_consulta ? String(medico.valor_consulta) : "",
|
||||
tipo_vinculo: String(m.tipo_vinculo || ""),
|
||||
dados_bancarios: m.dados_bancarios || { banco: "", agencia: "", conta: "", tipo_conta: "" },
|
||||
agenda_horario: String(m.agenda_horario || ""),
|
||||
valor_consulta: m.valor_consulta ? String(m.valor_consulta) : "",
|
||||
};
|
||||
|
||||
console.log("[DoctorForm] Dados do formulário preparados:", formData);
|
||||
@ -355,9 +396,12 @@ function setField<T extends keyof FormData>(k: T, v: FormData[T]) {
|
||||
if (!form.cpf.trim()) e.cpf = "CPF é obrigatório";
|
||||
if (!form.crm.trim()) e.crm = "CRM é obrigatório";
|
||||
if (!form.especialidade.trim()) e.especialidade = "Especialidade é obrigatória";
|
||||
if (!form.cep.trim()) e.cep = "CEP é obrigatório"; // Verifique se o CEP está preenchido
|
||||
if (!form.bairro.trim()) e.bairro = "Bairro é obrigatório"; // Verifique se o bairro está preenchido
|
||||
if (!form.cidade.trim()) e.cidade = "Cidade é obrigatória"; // Verifique se a cidade está preenchida
|
||||
// During edit, avoid forcing address fields. They are required on create only.
|
||||
if (mode !== 'edit') {
|
||||
if (!form.cep.trim()) e.cep = "CEP é obrigatório"; // Verifique se o CEP está preenchido
|
||||
if (!form.bairro.trim()) e.bairro = "Bairro é obrigatório"; // Verifique se o bairro está preenchido
|
||||
if (!form.cidade.trim()) e.cidade = "Cidade é obrigatória"; // Verifique se a cidade está preenchida
|
||||
}
|
||||
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
@ -426,7 +470,15 @@ function toPayload(): MedicoInput {
|
||||
|
||||
async function handleSubmit(ev: React.FormEvent) {
|
||||
ev.preventDefault();
|
||||
if (!validateLocal()) return;
|
||||
console.debug('[DoctorForm] handleSubmit invoked. mode=', mode, 'doctorId=', doctorId);
|
||||
if (!validateLocal()) {
|
||||
try {
|
||||
const { toast } = require('@/hooks/use-toast').useToast();
|
||||
const msgs = Object.entries(errors).map(([k,v]) => v).filter(Boolean).join('\n') || 'Preencha os campos obrigatórios';
|
||||
toast({ title: 'Erro de validação', description: msgs, variant: 'destructive' });
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setErrors({});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -898,6 +898,25 @@ export async function buscarPacientes(termo: string): Promise<Paciente[]> {
|
||||
return results.slice(0, 20); // Limita a 20 resultados
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca um paciente pelo user_id associado (campo user_id na tabela patients).
|
||||
* Retorna o primeiro registro encontrado ou null quando não achar.
|
||||
*/
|
||||
export async function buscarPacientePorUserId(userId?: string | null): Promise<Paciente | null> {
|
||||
if (!userId) return null;
|
||||
try {
|
||||
const url = `${REST}/patients?user_id=eq.${encodeURIComponent(String(userId))}&limit=1`;
|
||||
const headers = baseHeaders();
|
||||
console.debug('[buscarPacientePorUserId] URL:', url);
|
||||
const arr = await fetchWithFallback<Paciente[]>(url, headers).catch(() => []);
|
||||
if (arr && arr.length) return arr[0];
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn('[buscarPacientePorUserId] erro ao buscar por user_id', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buscarPacientePorId(id: string | number): Promise<Paciente> {
|
||||
const idParam = String(id);
|
||||
const headers = baseHeaders();
|
||||
@ -931,6 +950,42 @@ export async function buscarPacientePorId(id: string | number): Promise<Paciente
|
||||
throw new Error('404: Paciente não encontrado');
|
||||
}
|
||||
|
||||
// ===== MENSAGENS =====
|
||||
export type Mensagem = {
|
||||
id: string;
|
||||
patient_id?: string;
|
||||
doctor_id?: string | null;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
sender_name?: string | null;
|
||||
subject?: string | null;
|
||||
body?: string | null;
|
||||
content?: string | null;
|
||||
read?: boolean | null;
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lista mensagens (inbox) de um paciente específico.
|
||||
* Retorna array vazio se não houver mensagens.
|
||||
*/
|
||||
export async function listarMensagensPorPaciente(patientId: string): Promise<Mensagem[]> {
|
||||
if (!patientId) return [];
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('patient_id', `eq.${encodeURIComponent(String(patientId))}`);
|
||||
// Order by created_at descending if available
|
||||
qs.set('order', 'created_at.desc');
|
||||
const url = `${REST}/messages?${qs.toString()}`;
|
||||
const headers = baseHeaders();
|
||||
const res = await fetch(url, { method: 'GET', headers });
|
||||
return await parse<Mensagem[]>(res);
|
||||
} catch (err) {
|
||||
console.warn('[listarMensagensPorPaciente] erro ao buscar mensagens', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ===== RELATÓRIOS =====
|
||||
export type Report = {
|
||||
id: string;
|
||||
@ -1141,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;
|
||||
@ -2086,17 +2195,41 @@ export async function atualizarMedico(id: string | number, input: MedicoInput):
|
||||
|
||||
// Criar um payload limpo apenas com campos básicos que sabemos que existem
|
||||
const cleanPayload = {
|
||||
// Basic identification / contact
|
||||
full_name: input.full_name,
|
||||
nome_social: (input as any).nome_social || undefined,
|
||||
crm: input.crm,
|
||||
crm_uf: (input as any).crm_uf || (input as any).crmUf || undefined,
|
||||
rqe: (input as any).rqe || undefined,
|
||||
specialty: input.specialty,
|
||||
email: input.email,
|
||||
phone_mobile: input.phone_mobile,
|
||||
phone2: (input as any).phone2 ?? (input as any).telefone ?? undefined,
|
||||
cpf: input.cpf,
|
||||
rg: (input as any).rg ?? undefined,
|
||||
|
||||
// Address
|
||||
cep: input.cep,
|
||||
street: input.street,
|
||||
number: input.number,
|
||||
complement: (input as any).complement ?? undefined,
|
||||
neighborhood: (input as any).neighborhood ?? (input as any).bairro ?? undefined,
|
||||
city: input.city,
|
||||
state: input.state,
|
||||
|
||||
// Personal / professional
|
||||
birth_date: (input as any).birth_date ?? (input as any).data_nascimento ?? undefined,
|
||||
sexo: (input as any).sexo ?? undefined,
|
||||
formacao_academica: (input as any).formacao_academica ?? undefined,
|
||||
observacoes: (input as any).observacoes ?? undefined,
|
||||
|
||||
// Administrative / financial
|
||||
tipo_vinculo: (input as any).tipo_vinculo ?? undefined,
|
||||
dados_bancarios: (input as any).dados_bancarios ?? undefined,
|
||||
valor_consulta: (input as any).valor_consulta ?? undefined,
|
||||
agenda_horario: (input as any).agenda_horario ?? undefined,
|
||||
|
||||
// Flags
|
||||
active: input.active ?? true
|
||||
};
|
||||
|
||||
@ -2257,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;
|
||||
@ -2597,30 +2771,27 @@ export async function uploadFotoPaciente(_id: string | number, _file: File): Pro
|
||||
};
|
||||
const ext = extMap[_file.type] || 'jpg';
|
||||
|
||||
// O bucket deve ser 'avatars' e o caminho do objeto será userId/avatar.ext
|
||||
const bucket = 'avatars';
|
||||
const objectPath = `${userId}/avatar.${ext}`;
|
||||
const uploadUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/${bucket}/${encodeURIComponent(objectPath)}`;
|
||||
const uploadUrl = `https://mock.apidog.com/m1/1053378-0-default/storage/v1/object/avatars/${encodeURI(objectPath)}`;
|
||||
|
||||
// Build multipart form data
|
||||
const form = new FormData();
|
||||
form.append('file', _file, `avatar.${ext}`);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
// Supabase requires the anon key in 'apikey' header for client-side uploads
|
||||
apikey: ENV_CONFIG.SUPABASE_ANON_KEY,
|
||||
// Accept json
|
||||
Accept: 'application/json',
|
||||
Accept: 'application/json'
|
||||
};
|
||||
// if user is logged in, include Authorization header
|
||||
const jwt = getAuthToken();
|
||||
if (jwt) headers.Authorization = `Bearer ${jwt}`;
|
||||
|
||||
console.debug('[uploadFotoPaciente] Iniciando upload:', {
|
||||
const jwt = getAuthToken();
|
||||
if (!jwt) {
|
||||
throw new Error('Autenticação necessária: token JWT obrigatório para upload de avatar');
|
||||
}
|
||||
headers.Authorization = `Bearer ${jwt}`;
|
||||
|
||||
console.debug('[uploadFotoPaciente] Iniciando upload:', {
|
||||
url: uploadUrl,
|
||||
fileType: _file.type,
|
||||
fileSize: _file.size,
|
||||
hasAuth: !!jwt
|
||||
hasAuth: true
|
||||
});
|
||||
|
||||
const res = await fetch(uploadUrl, {
|
||||
@ -2653,7 +2824,7 @@ export async function uploadFotoPaciente(_id: string | number, _file: File): Pro
|
||||
|
||||
// The API may not return a structured body; return the Key we constructed
|
||||
const key = (json && (json.Key || json.key)) ?? objectPath;
|
||||
const publicUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/public/avatars/${encodeURIComponent(userId)}/avatar.${ext}`;
|
||||
const publicUrl = `https://mock.apidog.com/m1/1053378-0-default/storage/v1/object/avatars/${encodeURIComponent(userId)}/avatar.${ext}`;
|
||||
return { foto_url: publicUrl, Key: key };
|
||||
}
|
||||
|
||||
@ -2668,21 +2839,22 @@ export function getAvatarPublicUrl(userId: string | number): string {
|
||||
// Example: https://<project>.supabase.co/storage/v1/object/public/avatars/{userId}/avatar
|
||||
const id = String(userId || '').trim();
|
||||
if (!id) throw new Error('userId é obrigatório para obter URL pública do avatar');
|
||||
const base = String(ENV_CONFIG.SUPABASE_URL).replace(/\/$/, '');
|
||||
// Note: Supabase public object path does not require an extension in some setups
|
||||
return `${base}/storage/v1/object/public/${encodeURIComponent('avatars')}/${encodeURIComponent(id)}/avatar`;
|
||||
return `https://mock.apidog.com/m1/1053378-0-default/storage/v1/object/avatars/${encodeURIComponent(id)}/avatar`;
|
||||
}
|
||||
|
||||
export async function removerFotoPaciente(_id: string | number): Promise<void> {
|
||||
const userId = String(_id || '').trim();
|
||||
if (!userId) throw new Error('ID do paciente é obrigatório para remover foto');
|
||||
const deleteUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/avatars/${encodeURIComponent(userId)}/avatar`;
|
||||
const objectPath = `${userId}/avatar`;
|
||||
const deleteUrl = `https://mock.apidog.com/m1/1053378-0-default/storage/v1/object/avatars/${encodeURI(objectPath)}`;
|
||||
const headers: Record<string,string> = {
|
||||
apikey: ENV_CONFIG.SUPABASE_ANON_KEY,
|
||||
Accept: 'application/json',
|
||||
Accept: 'application/json'
|
||||
};
|
||||
|
||||
// Require auth for delete (follow security expectations)
|
||||
const jwt = getAuthToken();
|
||||
if (jwt) headers.Authorization = `Bearer ${jwt}`;
|
||||
if (!jwt) throw new Error('Autenticação necessária: token JWT obrigatório para remover avatar');
|
||||
headers.Authorization = `Bearer ${jwt}`;
|
||||
|
||||
try {
|
||||
console.debug('[removerFotoPaciente] Deleting avatar for user:', userId, 'url:', deleteUrl);
|
||||
@ -2787,3 +2959,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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
1227
susconecta/package-lock.json
generated
1227
susconecta/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
susconecta/types/apidog.ts
Normal file
11
susconecta/types/apidog.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// Types for APIdog/OpenAPI helper models
|
||||
export interface ApidogModel {
|
||||
/**
|
||||
* Path parameter used by the mock/OpenAPI for storage endpoints.
|
||||
* Example: "user-123/avatar.jpg"
|
||||
*/
|
||||
path: string;
|
||||
[property: string]: any;
|
||||
}
|
||||
|
||||
export default ApidogModel;
|
||||
Loading…
x
Reference in New Issue
Block a user