From 1693a415e20b3a13857fc2ec605ed7f1e0f906d2 Mon Sep 17 00:00:00 2001 From: M-Gabrielly Date: Mon, 27 Oct 2025 23:21:17 -0300 Subject: [PATCH 1/2] feat(perfil): add profile for admin and user information endpoint by id --- .../app/(main-routes)/perfil/loading.tsx | 34 + susconecta/app/(main-routes)/perfil/page.tsx | 653 ++++++++++++++++++ susconecta/components/dashboard/header.tsx | 11 +- susconecta/components/dashboard/sidebar.tsx | 3 +- susconecta/lib/api.ts | 41 ++ susconecta/lib/utils.ts | 66 ++ 6 files changed, 805 insertions(+), 3 deletions(-) create mode 100644 susconecta/app/(main-routes)/perfil/loading.tsx create mode 100644 susconecta/app/(main-routes)/perfil/page.tsx diff --git a/susconecta/app/(main-routes)/perfil/loading.tsx b/susconecta/app/(main-routes)/perfil/loading.tsx new file mode 100644 index 0000000..9b7a1af --- /dev/null +++ b/susconecta/app/(main-routes)/perfil/loading.tsx @@ -0,0 +1,34 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function PerfillLoading() { + return ( +
+
+ +
+ + +
+
+ +
+
+ +
+ + + +
+
+ +
+ +
+ + +
+
+
+
+ ); +} diff --git a/susconecta/app/(main-routes)/perfil/page.tsx b/susconecta/app/(main-routes)/perfil/page.tsx new file mode 100644 index 0000000..2db4cf9 --- /dev/null +++ b/susconecta/app/(main-routes)/perfil/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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(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 ( +
+
+ + + + Você não tem permissão para acessar esta página. + + + +
+
+ ); + } + + if (loading) { + return ( +
+
+
+
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
+ + + {error} + + +
+
+ ); + } + + if (!userInfo) { + return ( +
+
+ + + + Nenhuma informação de perfil disponível. + + +
+
+ ); + } + + 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 ( +
+
+
+ {/* Header com Título e Botão */} +
+
+

Meu Perfil

+

Bem-vindo à sua área exclusiva.

+
+ {!isEditing ? ( + + ) : ( +
+ + +
+ )} +
+ + {/* Grid de 2 colunas */} +
+ {/* Coluna Esquerda - Informações Pessoais */} +
+ {/* Informações Pessoais */} +
+

Informações Pessoais

+ +
+ {/* Nome Completo */} +
+ + {isEditing ? ( + setEditingData({...editingData, full_name: e.target.value})} + className="mt-2" + /> + ) : ( + <> +
+ {userInfo.profile?.full_name || "Não preenchido"} +
+

+ Este campo não pode ser alterado +

+ + )} +
+ + {/* Email */} +
+ +
+ {userInfo.user.email} +
+

+ Este campo não pode ser alterado +

+
+ + {/* UUID */} +
+ +
+ {userInfo.user.id} +
+

+ Este campo não pode ser alterado +

+
+ + {/* Permissões */} +
+ +
+ {userInfo.roles && userInfo.roles.length > 0 ? ( + userInfo.roles.map((role) => ( + + {role} + + )) + ) : ( + + Nenhuma permissão atribuída + + )} +
+
+
+
+ + {/* Endereço e Contato */} +
+

Endereço e Contato

+ +
+ {/* Telefone */} +
+ + {isEditing ? ( + handlePhoneChange(e.target.value)} + className="mt-2" + placeholder="(00) 00000-0000" + maxLength={15} + /> + ) : ( +
+ {userInfo.profile?.phone || "Não preenchido"} +
+ )} +
+ + {/* Endereço */} +
+ + {isEditing ? ( + setEditingData({...editingData, street: e.target.value})} + className="mt-2" + placeholder="Rua, avenida, etc." + /> + ) : ( +
+ {userInfo.profile?.street || "Não preenchido"} +
+ )} +
+ + {/* Número */} +
+ + {isEditing ? ( + setEditingData({...editingData, number: e.target.value})} + className="mt-2" + placeholder="123" + /> + ) : ( +
+ {userInfo.profile?.number || "Não preenchido"} +
+ )} +
+ + {/* Complemento */} +
+ + {isEditing ? ( + setEditingData({...editingData, complement: e.target.value})} + className="mt-2" + placeholder="Apto 42, Bloco B, etc." + /> + ) : ( +
+ {userInfo.profile?.complement || "Não preenchido"} +
+ )} +
+ + {/* Bairro */} +
+ + {isEditing ? ( + setEditingData({...editingData, neighborhood: e.target.value})} + className="mt-2" + placeholder="Vila, bairro, etc." + /> + ) : ( +
+ {userInfo.profile?.neighborhood || "Não preenchido"} +
+ )} +
+ + {/* Cidade */} +
+ + {isEditing ? ( + setEditingData({...editingData, city: e.target.value})} + className="mt-2" + placeholder="São Paulo" + /> + ) : ( +
+ {userInfo.profile?.city || "Não preenchido"} +
+ )} +
+ + {/* Estado */} +
+ + {isEditing ? ( + setEditingData({...editingData, state: e.target.value})} + className="mt-2" + placeholder="SP" + maxLength={2} + /> + ) : ( +
+ {userInfo.profile?.state || "Não preenchido"} +
+ )} +
+ + {/* CEP */} +
+ + {isEditing ? ( +
+
+
+ handleCepChange(e.target.value)} + className="mt-2" + placeholder="00000-000" + maxLength={9} + disabled={cepLoading} + /> +
+ {cepValid === true && ( + + )} + {cepValid === false && ( + + )} +
+ {cepLoading && ( +

Buscando CEP...

+ )} + {cepValid === false && ( +

CEP inválido ou não encontrado

+ )} + {cepValid === true && ( +

✓ CEP preenchido com sucesso

+ )} +
+ ) : ( +
+ {userInfo.profile?.cep || "Não preenchido"} +
+ )} +
+
+
+
+ + {/* Coluna Direita - Foto do Perfil */} +
+
+

Foto do Perfil

+ + {isEditing ? ( +
+ setEditingData({...editingData, avatar_url: newUrl})} + userName={editingData.full_name || userInfo.profile?.full_name || "Usuário"} + /> +
+ ) : ( +
+ + + + {getInitials(userInfo.profile?.full_name)} + + + +
+

+ {getInitials(userInfo.profile?.full_name)} +

+
+
+ )} + + {/* Informações de Status */} +
+
+ +
+ + {userInfo.profile?.disabled ? "Desabilitado" : "Ativo"} + +
+
+
+
+
+
+ + {/* Botão Voltar */} +
+ +
+
+
+
+ ); +} diff --git a/susconecta/components/dashboard/header.tsx b/susconecta/components/dashboard/header.tsx index 0872b66..002d16e 100644 --- a/susconecta/components/dashboard/header.tsx +++ b/susconecta/components/dashboard/header.tsx @@ -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(null); @@ -84,7 +86,14 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub
- +

{editingPatientId ? "Editar paciente" : "Novo paciente"}

+
+ + { + setShowPatientForm(false); + setEditingPatientId(null); + }} + /> +
+ ); + } + + // Se está exibindo formulário de médico + if (showDoctorForm) { + return ( +
+
+ +

{editingDoctorId ? "Editar Médico" : "Novo Médico"}

+
+ + { + setShowDoctorForm(false); + setEditingDoctorId(null); + }} + /> +
+ ); + } + + return ( +
+ {/* Header */} +
+

Dashboard

+

Bem-vindo ao painel de controle

+
+ + {/* 1. CARDS RESUMO */} +
+
+
+
+

Total de Pacientes

+

{stats.totalPatients}

+
+ +
+
+ +
+
+
+

Total de Médicos

+

{stats.totalDoctors}

+
+ +
+
+ +
+
+
+

Consultas Hoje

+

{stats.appointmentsToday}

+
+ +
+
+ +
+
+
+

Relatórios Pendentes

+

{pendingReports.length}

+
+ +
+
+
+ + {/* 6. AÇÕES RÁPIDAS */} +
+

Ações Rápidas

+
+ + + + +
+
+ + {/* 2. PRÓXIMAS CONSULTAS */} +
+
+

Próximas Consultas (7 dias)

+ {appointments.length > 0 ? ( +
+ {appointments.map(appt => ( +
+
+

+ {patients.get(appt.patient_id)?.full_name || 'Paciente desconhecido'} +

+

+ Médico: {doctors.get(appt.doctor_id)?.full_name || 'Médico desconhecido'} +

+

{formatDate(appt.scheduled_at)}

+
+
+ {getStatusBadge(appt.status)} +
+
+ ))} +
+ ) : ( +

Nenhuma consulta agendada para os próximos 7 dias

+ )} +
+ + {/* 5. RELATÓRIOS PENDENTES */} +
+

+ + Relatórios Pendentes +

+ {pendingReports.length > 0 ? ( +
+ {pendingReports.map(report => ( +
+

{report.order_number}

+

{report.exam || 'Sem descrição'}

+
+ ))} + +
+ ) : ( +

Sem relatórios pendentes

+ )} +
+
+ + {/* 4. NOVOS USUÁRIOS */} +
+

Novos Usuários (últimos 7 dias)

+ {newUsers.length > 0 ? ( +
+ {newUsers.map(user => ( +
+

{user.full_name || 'Sem nome'}

+

{user.email}

+
+ ))} +
+ ) : ( +

Nenhum novo usuário nos últimos 7 dias

+ )} +
+ + {/* 8. ALERTAS */} + {disabledUsers.length > 0 && ( +
+

+ + Alertas - Usuários Desabilitados +

+
+ {disabledUsers.map(user => ( + + + + {user.full_name} ({user.email}) está desabilitado + + + ))} +
+
+ )} + + {/* 11. LINK PARA RELATÓRIOS */} +
+

Seção de Relatórios

+

+ Acesse a seção de relatórios médicos para gerenciar, visualizar e exportar documentos. +

+ +
+
); } + diff --git a/susconecta/lib/api.ts b/susconecta/lib/api.ts index 8860cb9..71758e6 100644 --- a/susconecta/lib/api.ts +++ b/susconecta/lib/api.ts @@ -2961,3 +2961,206 @@ export async function excluirPerfil(id: string | number): Promise { await parse(res); } +// ===== DASHBOARD WIDGETS ===== + +/** + * Busca contagem total de pacientes + */ +export async function countTotalPatients(): Promise { + 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 { + 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 { + 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 { + 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(res); + } catch (err) { + console.error('[getUpcomingAppointments] Erro:', err); + return []; + } +} + +/** + * Busca agendamentos por data (para gráfico) + */ +export async function getAppointmentsByDateRange(days: number = 14): Promise { + 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(res); + } catch (err) { + console.error('[getAppointmentsByDateRange] Erro:', err); + return []; + } +} + +/** + * Busca novos usuários (últimos 7 dias) + */ +export async function getNewUsersLastDays(days: number = 7): Promise { + 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(res); + } catch (err) { + console.error('[getNewUsersLastDays] Erro:', err); + return []; + } +} + +/** + * Busca relatórios pendentes (draft) + */ +export async function getPendingReports(limit: number = 5): Promise { + 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(res); + } catch (err) { + console.error('[getPendingReports] Erro:', err); + return []; + } +} + +/** + * Busca usuários desabilitados (alertas) + */ +export async function getDisabledUsers(limit: number = 5): Promise { + 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(res); + } catch (err) { + console.error('[getDisabledUsers] Erro:', err); + return []; + } +} + +/** + * Busca disponibilidade de médicos (para hoje) + */ +export async function getDoctorsAvailabilityToday(): Promise { + 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(res); + } catch (err) { + console.error('[getDoctorsAvailabilityToday] Erro:', err); + return []; + } +} + +/** + * Busca detalhes de paciente por ID + */ +export async function getPatientById(patientId: string): Promise { + try { + const url = `${REST}/patients?id=eq.${patientId}&select=*&limit=1`; + const res = await fetch(url, { headers: baseHeaders() }); + const arr = await parse(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 { + try { + const url = `${REST}/doctors?id=eq.${doctorId}&select=*&limit=1`; + const res = await fetch(url, { headers: baseHeaders() }); + const arr = await parse(res); + return arr && arr.length > 0 ? arr[0] : null; + } catch (err) { + console.error('[getDoctorById] Erro:', err); + return null; + } +}