forked from RiseUP/riseup-squad20
Merge branch 'feature/endpoint-user-info-id' into backup/agendamento
This commit is contained in:
commit
708ec3cd93
File diff suppressed because it is too large
Load Diff
@ -1,41 +1,403 @@
|
|||||||
export default function DashboardPage() {
|
'use client';
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
import { useEffect, useState } from 'react';
|
||||||
<div className="bg-card p-6 rounded-lg border">
|
import { useRouter } from 'next/navigation';
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">
|
import {
|
||||||
Total de Pacientes
|
countTotalPatients,
|
||||||
</h3>
|
countTotalDoctors,
|
||||||
<p className="text-2xl font-bold text-foreground">1,234</p>
|
countAppointmentsToday,
|
||||||
</div>
|
getUpcomingAppointments,
|
||||||
<div className="bg-card p-6 rounded-lg border">
|
getAppointmentsByDateRange,
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">
|
getNewUsersLastDays,
|
||||||
Consultas Hoje
|
getPendingReports,
|
||||||
</h3>
|
getDisabledUsers,
|
||||||
<p className="text-2xl font-bold text-foreground">28</p>
|
getDoctorsAvailabilityToday,
|
||||||
</div>
|
getPatientById,
|
||||||
<div className="bg-card p-6 rounded-lg border">
|
getDoctorById,
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">
|
} from '@/lib/api';
|
||||||
Próximas Consultas
|
import { Button } from '@/components/ui/button';
|
||||||
</h3>
|
import { Badge } from '@/components/ui/badge';
|
||||||
<p className="text-2xl font-bold text-foreground">45</p>
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
</div>
|
import { AlertCircle, Calendar, Users, Stethoscope, Clock, FileText, AlertTriangle, Plus, ArrowLeft } from 'lucide-react';
|
||||||
<div className="bg-card p-6 rounded-lg border">
|
import Link from 'next/link';
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">
|
import { PatientRegistrationForm } from '@/components/forms/patient-registration-form';
|
||||||
Receita Mensal
|
import { DoctorRegistrationForm } from '@/components/forms/doctor-registration-form';
|
||||||
</h3>
|
|
||||||
<p className="text-2xl font-bold text-foreground">R$ 45.230</p>
|
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>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,11 +6,13 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { useState, useEffect, useRef } from "react"
|
import { useState, useEffect, useRef } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import { SidebarTrigger } from "../ui/sidebar"
|
import { SidebarTrigger } from "../ui/sidebar"
|
||||||
import { SimpleThemeToggle } from "@/components/simple-theme-toggle";
|
import { SimpleThemeToggle } from "@/components/simple-theme-toggle";
|
||||||
|
|
||||||
export function PagesHeader({ title = "", subtitle = "" }: { title?: string, subtitle?: string }) {
|
export function PagesHeader({ title = "", subtitle = "" }: { title?: string, subtitle?: string }) {
|
||||||
const { logout, user } = useAuth();
|
const { logout, user } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -84,7 +86,14 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="py-1">
|
<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
|
👤 Perfil
|
||||||
</button>
|
</button>
|
||||||
<button className="w-full text-left px-4 py-2 text-sm hover:bg-accent cursor-pointer">
|
<button className="w-full text-left px-4 py-2 text-sm hover:bg-accent cursor-pointer">
|
||||||
|
|||||||
@ -25,14 +25,13 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Stethoscope,
|
Stethoscope,
|
||||||
User,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: "Dashboard", href: "/dashboard", icon: Home },
|
{ name: "Dashboard", href: "/dashboard", icon: Home },
|
||||||
{ name: "Calendario", href: "/calendar", icon: Calendar },
|
{ name: "Calendario", href: "/calendar", icon: Calendar },
|
||||||
{ name: "Pacientes", href: "/pacientes", icon: Users },
|
{ 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: "Consultas", href: "/consultas", icon: UserCheck },
|
||||||
{ name: "Relatórios", href: "/dashboard/relatorios", icon: BarChart3 },
|
{ name: "Relatórios", href: "/dashboard/relatorios", icon: BarChart3 },
|
||||||
]
|
]
|
||||||
|
|||||||
@ -2390,6 +2390,47 @@ export async function getUserInfo(): Promise<UserInfo> {
|
|||||||
return await parse<UserInfo>(res);
|
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 = {
|
export type CreateUserInput = {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@ -2920,3 +2961,206 @@ export async function excluirPerfil(id: string | number): Promise<void> {
|
|||||||
await parse<any>(res);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -23,3 +23,69 @@ export function validarCPFLocal(cpf: string): boolean {
|
|||||||
|
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user