fix(doctor): Corrige exibição de consultas e validação de CPF

- Corrige bug na página de consultas do médico que impedia a exibição dos agendamentos devido a inconsistências nos IDs de usuário e médico. A lógica agora mapeia corretamente o user_id da autenticação para o doctor_id correspondente antes de buscar os dados.
Melhora a UX da agenda do médico, agrupando as consultas por dia e focando na data atual por padrão, com uma interface de cards mais limpa e informativa.
Adiciona validação de CPF no frontend no formulário de criação de novo usuário (/manager/usuario/novo) para evitar erros de check constraint do banco de dados, fornecendo feedback imediato ao usuário.
Refina o fluxo de login para múltiplos perfis, garantindo que a role seja salva corretamente e eliminando bugs de sessão.
This commit is contained in:
Gabriel Lira Figueira 2025-11-09 23:44:23 -03:00
parent 3f77c52bcd
commit 29e0a4ce1a
4 changed files with 325 additions and 376 deletions

View File

@ -1,272 +1,229 @@
// ARQUIVO COMPLETO COM A INTERFACE CORRIGIDA: app/doctor/consultas/page.tsx
"use client";
import type React from "react";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import DoctorLayout from "@/components/doctor-layout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuthLayout } from "@/hooks/useAuthLayout";
import { appointmentsService } from "@/services/appointmentsApi.mjs";
import { patientsService } from "@/services/patientsApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Clock, Calendar as CalendarIcon, MapPin, Phone, User, X, RefreshCw } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Calendar as CalendarShadcn } from "@/components/ui/calendar";
import { Separator } from "@/components/ui/separator";
import { Clock, Calendar as CalendarIcon, User, X, RefreshCw, Loader2, MapPin, Phone, List } from "lucide-react";
import { format, isFuture, parseISO, isValid, isToday, isTomorrow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { toast } from "sonner";
// IMPORTAR O COMPONENTE CALENDÁRIO DA SHADCN
import { Calendar } from "@/components/ui/calendar";
import { format } from "date-fns"; // Usaremos o date-fns para formatação e comparação de datas
const APPOINTMENTS_STORAGE_KEY = "clinic-appointments";
// --- TIPAGEM DA CONSULTA SALVA NO LOCALSTORAGE ---
interface LocalStorageAppointment {
id: number;
patientName: string;
doctor: string;
specialty: string;
date: string; // Data no formato YYYY-MM-DD
time: string; // Hora no formato HH:MM
status: "agendada" | "confirmada" | "cancelada" | "realizada";
location: string;
phone: string;
// Interfaces (sem alteração)
interface EnrichedAppointment {
id: string;
patientName: string;
patientPhone: string;
scheduled_at: string;
status: "requested" | "confirmed" | "completed" | "cancelled" | "checked_in" | "no_show";
location: string;
}
const LOGGED_IN_DOCTOR_NAME = "Dr. João Santos";
// Função auxiliar para comparar se duas datas (Date objects) são o mesmo dia
const isSameDay = (date1: Date, date2: Date) => {
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();
};
// --- COMPONENTE PRINCIPAL ---
export default function DoctorAppointmentsPage() {
const [allAppointments, setAllAppointments] = useState<LocalStorageAppointment[]>([]);
const [filteredAppointments, setFilteredAppointments] = useState<LocalStorageAppointment[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { user, isLoading: isAuthLoading } = useAuthLayout({ requiredRole: 'medico' });
const [allAppointments, setAllAppointments] = useState<EnrichedAppointment[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(new Date());
// NOVO ESTADO 1: Armazena os dias com consultas (para o calendário)
const [bookedDays, setBookedDays] = useState<Date[]>([]);
const fetchAppointments = async (authUserId: string) => {
setIsLoading(true);
try {
const allDoctors = await doctorsService.list();
const currentDoctor = allDoctors.find((doc: any) => doc.user_id === authUserId);
if (!currentDoctor) {
toast.error("Perfil de médico não encontrado para este usuário.");
return setIsLoading(false);
}
const doctorId = currentDoctor.id;
// NOVO ESTADO 2: Armazena a data selecionada no calendário
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | undefined>(new Date());
const [appointmentsList, patientsList] = await Promise.all([
appointmentsService.search_appointment(`doctor_id=eq.${doctorId}&order=scheduled_at.asc`),
patientsService.list()
]);
useEffect(() => {
loadAppointments();
}, []);
const patientsMap = new Map<string, { name: string; phone: string }>(
patientsList.map((p: any) => [p.id, { name: p.full_name, phone: p.phone_mobile }])
);
const enrichedAppointments = appointmentsList.map((apt: any) => ({
id: apt.id,
patientName: patientsMap.get(apt.patient_id)?.name || "Paciente Desconhecido",
patientPhone: patientsMap.get(apt.patient_id)?.phone || "N/A",
scheduled_at: apt.scheduled_at,
status: apt.status,
location: "Consultório Principal",
}));
// Efeito para filtrar a lista sempre que o calendário ou a lista completa for atualizada
useEffect(() => {
if (selectedCalendarDate) {
const dateString = format(selectedCalendarDate, 'yyyy-MM-dd');
setAllAppointments(enrichedAppointments);
} catch (error) {
console.error("Erro ao carregar a agenda:", error);
toast.error("Não foi possível carregar sua agenda.");
} finally {
setIsLoading(false);
}
};
// Filtra a lista completa de agendamentos pela data selecionada
const todayAppointments = allAppointments
.filter(app => app.date === dateString)
.sort((a, b) => a.time.localeCompare(b.time)); // Ordena por hora
useEffect(() => {
if (user?.id) {
fetchAppointments(user.id);
}
}, [user]);
setFilteredAppointments(todayAppointments);
} else {
// Se nenhuma data estiver selecionada (ou se for limpa), mostra todos (ou os de hoje)
const todayDateString = format(new Date(), 'yyyy-MM-dd');
const todayAppointments = allAppointments
.filter(app => app.date === todayDateString)
.sort((a, b) => a.time.localeCompare(b.time));
const groupedAppointments = useMemo(() => {
const appointmentsToDisplay = selectedDate
? allAppointments.filter(app => app.scheduled_at && app.scheduled_at.startsWith(format(selectedDate, "yyyy-MM-dd")))
: allAppointments.filter(app => {
if (!app.scheduled_at) return false;
const dateObj = parseISO(app.scheduled_at);
return isValid(dateObj) && isFuture(dateObj);
});
setFilteredAppointments(todayAppointments);
}
}, [allAppointments, selectedCalendarDate]);
return appointmentsToDisplay.reduce((acc, appointment) => {
const dateKey = format(parseISO(appointment.scheduled_at), "yyyy-MM-dd");
if (!acc[dateKey]) acc[dateKey] = [];
acc[dateKey].push(appointment);
return acc;
}, {} as Record<string, EnrichedAppointment[]>);
}, [allAppointments, selectedDate]);
const loadAppointments = () => {
setIsLoading(true);
try {
const storedAppointmentsRaw = localStorage.getItem(APPOINTMENTS_STORAGE_KEY);
const allAppts: LocalStorageAppointment[] = storedAppointmentsRaw ? JSON.parse(storedAppointmentsRaw) : [];
const bookedDays = useMemo(() => {
return allAppointments
.map(app => app.scheduled_at ? new Date(app.scheduled_at) : null)
.filter((date): date is Date => date !== null);
}, [allAppointments]);
// ***** NENHUM FILTRO POR MÉDICO AQUI (Como solicitado) *****
const appointmentsToShow = allAppts;
const formatDisplayDate = (dateString: string) => {
const date = parseISO(dateString);
if (isToday(date)) return `Hoje, ${format(date, "dd 'de' MMMM", { locale: ptBR })}`;
if (isTomorrow(date)) return `Amanhã, ${format(date, "dd 'de' MMMM", { locale: ptBR })}`;
return format(date, "EEEE, dd 'de' MMMM", { locale: ptBR });
};
// 1. EXTRAI E PREPARA AS DATAS PARA O CALENDÁRIO
const uniqueBookedDates = Array.from(new Set(appointmentsToShow.map(app => app.date)));
const getStatusVariant = (status: EnrichedAppointment['status']) => {
switch (status) {
case "confirmed": case "checked_in": return "default";
case "completed": return "secondary";
case "cancelled": case "no_show": return "destructive";
case "requested": return "outline";
default: return "outline";
}
};
// Converte YYYY-MM-DD para objetos Date, garantindo que o tempo seja meia-noite (00:00:00)
const dateObjects = uniqueBookedDates.map(dateString => new Date(dateString + 'T00:00:00'));
const handleCancel = async (id: string) => {
// ... (função sem alteração)
};
const handleReSchedule = (id: string) => {
// ... (função sem alteração)
};
setAllAppointments(appointmentsToShow);
setBookedDays(dateObjects);
toast.success("Agenda atualizada com sucesso!");
} catch (error) {
console.error("Erro ao carregar a agenda do LocalStorage:", error);
toast.error("Não foi possível carregar sua agenda.");
} finally {
setIsLoading(false);
}
};
if (isAuthLoading) {
return <DoctorLayout><div>Carregando...</div></DoctorLayout>;
}
const getStatusVariant = (status: LocalStorageAppointment['status']) => {
// ... (código mantido)
switch (status) {
case "confirmada":
case "agendada":
return "default";
case "realizada":
return "secondary";
case "cancelada":
return "destructive";
default:
return "outline";
}
};
return (
<DoctorLayout>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Agenda Médica</h1>
<p className="text-muted-foreground">Consultas para {user?.name || "você"}</p>
</div>
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold capitalize">
{selectedDate ? `Agenda de ${format(selectedDate, "dd/MM/yyyy")}` : "Próximas Consultas"}
</h2>
<div className="flex gap-2">
<Button onClick={() => setSelectedDate(undefined)} variant="ghost" size="sm"><List className="mr-2 h-4 w-4" />Mostrar Todas</Button>
<Button onClick={() => user?.id && fetchAppointments(user.id)} disabled={isLoading} variant="outline" size="sm"><RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />Atualizar</Button>
</div>
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-1">
<Card>
<CardHeader><CardTitle className="flex items-center"><CalendarIcon className="mr-2 h-5 w-5" />Filtrar por Data</CardTitle><CardDescription>Selecione um dia para ver os detalhes.</CardDescription></CardHeader>
<CardContent className="flex justify-center p-2">
<CalendarShadcn mode="single" selected={selectedDate} onSelect={setSelectedDate} modifiers={{ booked: bookedDays }} modifiersClassNames={{ booked: "bg-primary/20" }} className="rounded-md border p-2" locale={ptBR}/>
</CardContent>
</Card>
</div>
<div className="lg:col-span-2 space-y-6">
{isLoading ? (
<div className="flex justify-center items-center h-48"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>
) : Object.keys(groupedAppointments).length === 0 ? (
<Card className="flex flex-col items-center justify-center h-48 text-center">
<CardHeader><CardTitle>Nenhuma consulta encontrada</CardTitle></CardHeader>
<CardContent><p className="text-muted-foreground">{selectedDate ? "Não há agendamentos para esta data." : "Não há próximas consultas agendadas."}</p></CardContent>
</Card>
) : (
Object.entries(groupedAppointments).map(([date, appointmentsForDay]) => (
<div key={date}>
<h3 className="text-lg font-semibold text-foreground mb-3 capitalize">{formatDisplayDate(date)}</h3>
<div className="space-y-4">
{appointmentsForDay.map((appointment) => {
const showActions = appointment.status === "requested" || appointment.status === "confirmed";
const scheduledAtDate = parseISO(appointment.scheduled_at);
return (
// *** INÍCIO DA MUDANÇA NO CARD ***
<Card key={appointment.id} className="shadow-sm hover:shadow-md transition-shadow">
<CardContent className="p-4 grid grid-cols-3 items-center gap-4">
{/* Coluna 1: Nome e Hora */}
<div className="col-span-1 flex flex-col gap-2">
<div className="font-semibold flex items-center text-foreground">
<User className="mr-2 h-4 w-4 text-primary" />
{appointment.patientName}
</div>
<div className="flex items-center text-sm text-muted-foreground">
<Clock className="mr-2 h-4 w-4" />
{format(scheduledAtDate, "HH:mm")}
</div>
</div>
{/* Coluna 2: Status e Telefone */}
<div className="col-span-1 flex flex-col items-center gap-2">
<Badge variant={getStatusVariant(appointment.status)} className="capitalize text-xs">{appointment.status.replace('_', ' ')}</Badge>
<div className="flex items-center text-sm text-muted-foreground">
<Phone className="mr-2 h-4 w-4" />
{appointment.patientPhone}
</div>
</div>
const handleCancel = (id: number) => {
// ... (código mantido para cancelamento)
const storedAppointmentsRaw = localStorage.getItem(APPOINTMENTS_STORAGE_KEY);
const allAppts: LocalStorageAppointment[] = storedAppointmentsRaw ? JSON.parse(storedAppointmentsRaw) : [];
const updatedAppointments = allAppts.map(app =>
app.id === id ? { ...app, status: "cancelada" as const } : app
);
localStorage.setItem(APPOINTMENTS_STORAGE_KEY, JSON.stringify(updatedAppointments));
loadAppointments();
toast.info(`Consulta cancelada com sucesso.`);
};
const handleReSchedule = (id: number) => {
toast.info(`Reagendamento da Consulta ID: ${id}. Navegar para a página de agendamento.`);
};
const displayDate = selectedCalendarDate ?
new Date(selectedCalendarDate).toLocaleDateString("pt-BR", { weekday: 'long', day: '2-digit', month: 'long' }) :
"Selecione uma data";
return (
<DoctorLayout>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Agenda Médica Centralizada</h1>
<p className="text-gray-600">Todas as consultas do sistema são exibidas aqui ({LOGGED_IN_DOCTOR_NAME})</p>
</div>
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold">Consultas para: {displayDate}</h2>
<Button onClick={loadAppointments} disabled={isLoading} variant="outline" size="sm">
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Atualizar Agenda
</Button>
</div>
{/* NOVO LAYOUT DE DUAS COLUNAS */}
<div className="grid lg:grid-cols-3 gap-6">
{/* COLUNA 1: CALENDÁRIO */}
<div className="lg:col-span-1">
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<CalendarIcon className="mr-2 h-5 w-5" />
Calendário
</CardTitle>
<p className="text-sm text-gray-500">Dias em azul possuem agendamentos.</p>
</CardHeader>
<CardContent className="flex justify-center p-2">
<Calendar
mode="single"
selected={selectedCalendarDate}
onSelect={setSelectedCalendarDate}
initialFocus
// A CHAVE DO HIGHLIGHT: Passa o array de datas agendadas
modifiers={{ booked: bookedDays }}
// Define o estilo CSS para o modificador 'booked'
modifiersClassNames={{
booked: "bg-blue-600 text-white aria-selected:!bg-blue-700 hover:!bg-blue-700/90"
}}
className="rounded-md border p-2"
/>
</CardContent>
{/* Coluna 3: Ações */}
<div className="col-span-1 flex justify-end">
{showActions && (
<div className="flex flex-col sm:flex-row gap-2">
<Button variant="outline" size="sm" onClick={() => handleReSchedule(appointment.id)}>
<RefreshCw className="mr-1.5 h-4 w-4" />Reagendar
</Button>
<Button variant="destructive" size="sm" onClick={() => handleCancel(appointment.id)}>
<X className="mr-1.5 h-4 w-4" />Cancelar
</Button>
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* COLUNA 2: LISTA DE CONSULTAS FILTRADAS */}
<div className="lg:col-span-2 space-y-4">
{isLoading ? (
<p className="text-center text-lg text-gray-500">Carregando a agenda...</p>
) : filteredAppointments.length === 0 ? (
<p className="text-center text-lg text-gray-500">Nenhuma consulta encontrada para a data selecionada.</p>
) : (
filteredAppointments.map((appointment) => {
const showActions = appointment.status === "agendada" || appointment.status === "confirmada";
return (
<Card key={appointment.id} className="shadow-lg">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-xl font-semibold flex items-center">
<User className="mr-2 h-5 w-5 text-blue-600" />
{appointment.patientName}
</CardTitle>
<Badge variant={getStatusVariant(appointment.status)} className="uppercase">
{appointment.status}
</Badge>
</CardHeader>
<CardContent className="grid md:grid-cols-3 gap-4 pt-4">
{/* Detalhes e Ações... (mantidos) */}
<div className="space-y-3">
<div className="flex items-center text-sm text-gray-700">
<User className="mr-2 h-4 w-4 text-gray-500" />
<span className="font-semibold">Médico:</span> {appointment.doctor}
</div>
<div className="flex items-center text-sm text-gray-700">
<CalendarIcon className="mr-2 h-4 w-4 text-gray-500" />
{new Date(appointment.date).toLocaleDateString("pt-BR", { timeZone: "UTC" })}
</div>
<div className="flex items-center text-sm text-gray-700">
<Clock className="mr-2 h-4 w-4 text-gray-500" />
{appointment.time}
</div>
</div>
<div className="space-y-3">
<div className="flex items-center text-sm text-gray-700">
<MapPin className="mr-2 h-4 w-4 text-gray-500" />
{appointment.location}
</div>
<div className="flex items-center text-sm text-gray-700">
<Phone className="mr-2 h-4 w-4 text-gray-500" />
{appointment.phone || "N/A"}
</div>
</div>
<div className="flex flex-col justify-center items-end">
{showActions && (
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleReSchedule(appointment.id)}
>
<RefreshCw className="mr-2 h-4 w-4" />
Reagendar
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleCancel(appointment.id)}
>
<X className="mr-2 h-4 w-4" />
Cancelar
</Button>
</div>
)}
</div>
</CardContent>
</Card>
);
})
)}
</div>
// *** FIM DA MUDANÇA NO CARD ***
);
})}
</div>
<Separator className="my-6" />
</div>
</div>
</DoctorLayout>
);
))
)}
</div>
</div>
</div>
</DoctorLayout>
);
}

View File

@ -1,4 +1,4 @@
// /app/manager/usuario/novo/page.tsx
// ARQUIVO COMPLETO PARA: app/manager/usuario/novo/page.tsx
"use client";
@ -9,11 +9,12 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Save, Loader2, Pause } from "lucide-react";
import { Save, Loader2 } from "lucide-react";
import ManagerLayout from "@/components/manager-layout";
import { usersService } from "@/services/usersApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs"; // Importação adicionada
import { doctorsService } from "@/services/doctorsApi.mjs";
import { login } from "services/api.mjs";
import { isValidCPF } from "@/lib/utils"; // 1. IMPORTAÇÃO DA FUNÇÃO DE VALIDAÇÃO
interface UserFormData {
email: string;
@ -23,7 +24,6 @@ interface UserFormData {
senha: string;
confirmarSenha: string;
cpf: string;
// Novos campos para Médico
crm: string;
crm_uf: string;
specialty: string;
@ -37,7 +37,6 @@ const defaultFormData: UserFormData = {
senha: "",
confirmarSenha: "",
cpf: "",
// Valores iniciais para campos de Médico
crm: "",
crm_uf: "",
specialty: "",
@ -62,7 +61,6 @@ export default function NovoUsuarioPage() {
if (key === "telefone") {
updatedValue = formatPhone(value);
} else if (key === "crm_uf") {
// Converte UF para maiúsculas
updatedValue = value.toUpperCase();
}
setFormData((prev) => ({ ...prev, [key]: updatedValue }));
@ -72,7 +70,7 @@ export default function NovoUsuarioPage() {
e.preventDefault();
setError(null);
if (!formData.email || !formData.nomeCompleto || !formData.papel || !formData.senha || !formData.confirmarSenha) {
if (!formData.email || !formData.nomeCompleto || !formData.papel || !formData.senha || !formData.confirmarSenha || !formData.cpf) {
setError("Por favor, preencha todos os campos obrigatórios.");
return;
}
@ -82,7 +80,12 @@ export default function NovoUsuarioPage() {
return;
}
// Validação adicional para Médico
// 2. VALIDAÇÃO DO CPF ANTES DO ENVIO
if (!isValidCPF(formData.cpf)) {
setError("O CPF informado é inválido. Por favor, verifique os dígitos.");
return;
}
if (formData.papel === "medico") {
if (!formData.crm || !formData.crm_uf) {
setError("Para a função 'Médico', o CRM e a UF do CRM são obrigatórios.");
@ -94,7 +97,6 @@ export default function NovoUsuarioPage() {
try {
if (formData.papel === "medico") {
// Lógica para criação de Médico
const doctorPayload = {
email: formData.email.trim().toLowerCase(),
full_name: formData.nomeCompleto,
@ -102,19 +104,11 @@ export default function NovoUsuarioPage() {
crm: formData.crm,
crm_uf: formData.crm_uf,
specialty: formData.specialty || null,
phone_mobile: formData.telefone || null, // Usando phone_mobile conforme o schema
phone_mobile: formData.telefone || null,
};
console.log("📤 Enviando payload para Médico:");
console.log(doctorPayload);
// Chamada ao endpoint específico para criação de médico
await doctorsService.create(doctorPayload);
} else {
// Lógica para criação de Outras Roles
const isPatient = formData.papel === "paciente";
const userPayload = {
email: formData.email.trim().toLowerCase(),
password: formData.senha,
@ -122,21 +116,17 @@ export default function NovoUsuarioPage() {
phone: formData.telefone || null,
role: formData.papel,
cpf: formData.cpf,
create_patient_record: isPatient, // true se a role for 'paciente'
phone_mobile: isPatient ? formData.telefone || null : undefined, // Enviar phone_mobile se for paciente
create_patient_record: isPatient,
phone_mobile: isPatient ? formData.telefone || null : undefined,
};
console.log("📤 Enviando payload para Usuário Comum:");
console.log(userPayload);
// Chamada ao endpoint padrão para criação de usuário
await usersService.create_user(userPayload);
}
router.push("/manager/usuario");
} catch (e: any) {
console.error("Erro ao criar usuário:", e);
setError(e?.message || "Não foi possível criar o usuário. Verifique os dados e tente novamente.");
// 3. MENSAGEM DE ERRO MELHORADA
const detail = e.message?.split('detail:"')[1]?.split('"')[0] || e.message;
setError(detail.replace(/\\/g, '') || "Não foi possível criar o usuário. Verifique os dados e tente novamente.");
} finally {
setIsSaving(false);
}
@ -193,26 +183,22 @@ export default function NovoUsuarioPage() {
</Select>
</div>
{/* Campos Condicionais para Médico */}
{isMedico && (
<>
<div className="space-y-2">
<Label htmlFor="crm">CRM *</Label>
<Input id="crm" value={formData.crm} onChange={(e) => handleInputChange("crm", e.target.value)} placeholder="Número do CRM" required />
</div>
<div className="space-y-2">
<Label htmlFor="crm_uf">UF do CRM *</Label>
<Input id="crm_uf" value={formData.crm_uf} onChange={(e) => handleInputChange("crm_uf", e.target.value)} placeholder="Ex: SP" maxLength={2} required />
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="specialty">Especialidade (opcional)</Label>
<Input id="specialty" value={formData.specialty} onChange={(e) => handleInputChange("specialty", e.target.value)} placeholder="Ex: Cardiologia" />
</div>
</>
)}
{/* Fim dos Campos Condicionais */}
<div className="space-y-2">
<Label htmlFor="senha">Senha *</Label>
@ -233,7 +219,7 @@ export default function NovoUsuarioPage() {
<div className="space-y-2">
<Label htmlFor="cpf">Cpf *</Label>
<Input id="cpf" type="cpf" value={formData.cpf} onChange={(e) => handleInputChange("cpf", e.target.value)} placeholder="xxx.xxx.xxx-xx" required />
<Input id="cpf" value={formData.cpf} onChange={(e) => handleInputChange("cpf", e.target.value)} placeholder="Apenas números" required />
</div>
<div className="flex justify-end gap-4 pt-6 border-t mt-6">
@ -252,4 +238,4 @@ export default function NovoUsuarioPage() {
</div>
</ManagerLayout>
);
}
}

View File

@ -1,11 +1,10 @@
// Caminho: components/LoginForm.tsx
// ARQUIVO COMPLETO E CORRIGIDO PARA: components/LoginForm.tsx
"use client";
import type React from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
// Nossos serviços de API centralizados e limpos
import { login, api } from "@/services/api.mjs";
import { Button } from "@/components/ui/button";
@ -31,62 +30,39 @@ export function LoginForm({ children }: LoginFormProps) {
const router = useRouter();
const { toast } = useToast();
// --- NOVOS ESTADOS PARA CONTROLE DE MÚLTIPLOS PERFIS ---
const [userRoles, setUserRoles] = useState<string[]>([]);
const [authenticatedUser, setAuthenticatedUser] = useState<any>(null);
/**
* --- NOVA FUNÇÃO ---
* Finaliza o login com o perfil de dashboard escolhido e redireciona.
*/
const handleRoleSelection = (selectedDashboardRole: string) => {
const user = authenticatedUser;
// *** MUDANÇA 1: A função agora recebe o objeto 'user' como parâmetro ***
const handleRoleSelection = (selectedDashboardRole: string, user: any) => {
if (!user) {
toast({ title: "Erro de Sessão", description: "Não foi possível encontrar os dados do usuário. Tente novamente.", variant: "destructive" });
setUserRoles([]); // Volta para a tela de login
setUserRoles([]);
return;
}
// AQUI ESTÁ A CORREÇÃO:
const roleInLowerCase = selectedDashboardRole.toLowerCase();
// Adicionando o log que você pediu:
console.log("Salvando no localStorage com o perfil:", roleInLowerCase);
const roleInLowerCase = selectedDashboardRole.toLowerCase();
console.log("Salvando no localStorage com o perfil:", roleInLowerCase);
const completeUserInfo = { ...user, user_metadata: { ...user.user_metadata, role: roleInLowerCase } };
localStorage.setItem("user_info", JSON.stringify(completeUserInfo));
const completeUserInfo = { ...user, user_metadata: { ...user.user_metadata, role: roleInLowerCase } };
localStorage.setItem("user_info", JSON.stringify(completeUserInfo));
let redirectPath = "";
switch (roleInLowerCase) { // Usamos a variável em minúsculas aqui também
case "manager":
redirectPath = "/manager/home";
break;
case "doctor":
redirectPath = "/doctor/medicos";
break;
case "secretary":
redirectPath = "/secretary/pacientes";
break;
case "patient":
redirectPath = "/patient/dashboard";
break;
case "finance":
redirectPath = "/finance/home";
break;
}
let redirectPath = "";
switch (roleInLowerCase) {
case "manager": redirectPath = "/manager/home"; break;
case "doctor": redirectPath = "/doctor/medicos"; break;
case "secretary": redirectPath = "/secretary/pacientes"; break;
case "patient": redirectPath = "/patient/dashboard"; break;
case "finance": redirectPath = "/finance/home"; break;
}
if (redirectPath) {
toast({ title: `Entrando como ${selectedDashboardRole}...` });
router.push(redirectPath);
} else {
toast({ title: "Erro", description: "Perfil selecionado inválido.", variant: "destructive" });
}
};
if (redirectPath) {
toast({ title: `Entrando como ${selectedDashboardRole}...` });
router.push(redirectPath);
} else {
toast({ title: "Erro", description: "Perfil selecionado inválido.", variant: "destructive" });
}
};
/**
* --- FUNÇÃO ATUALIZADA ---
* Lida com a submissão do formulário, busca os perfis e decide o próximo passo.
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
@ -94,85 +70,81 @@ export function LoginForm({ children }: LoginFormProps) {
localStorage.removeItem("user_info");
try {
// A chamada de login continua a mesma
const authData = await login(form.email, form.password);
const user = authData.user;
if (!user || !user.id) {
throw new Error("Resposta de autenticação inválida.");
}
// Armazena o usuário para uso posterior na seleção de perfil
setAuthenticatedUser(user);
// A busca de roles também continua a mesma, usando nosso 'api.get'
const rolesData = await api.get(`/rest/v1/user_roles?user_id=eq.${user.id}&select=role`);
if (!rolesData || rolesData.length === 0) {
throw new Error("Nenhum perfil de acesso foi encontrado para este usuário.");
}
const rolesFromApi: string[] = rolesData.map((r: any) => r.role);
// *** MUDANÇA 2: Passamos o objeto 'user' diretamente para a função de seleção ***
const handleSelectionWithUser = (role: string) => handleRoleSelection(role, user);
// --- AQUI COMEÇA A NOVA LÓGICA DE DECISÃO ---
// Caso 1: Usuário é ADMIN, mostra todos os dashboards possíveis.
if (rolesFromApi.includes("admin")) {
setUserRoles(["manager", "doctor", "secretary", "patient", "finance"]);
setIsLoading(false); // Para o loading para mostrar a tela de seleção
const allRoles = ["manager", "doctor", "secretary", "patient", "finance"];
setUserRoles(allRoles);
// Atualizamos o onClick para usar a nova função que já tem o 'user'
const roleButtons = allRoles.map((role) => (
<Button key={role} variant="outline" className="h-11 text-base" onClick={() => handleSelectionWithUser(role)}>
Entrar como: {role.charAt(0).toUpperCase() + role.slice(1)}
</Button>
));
// Precisamos de um estado para renderizar os botões
setRoleSelectionUI(roleButtons);
setIsLoading(false);
return;
}
// Mapeia os roles da API para os perfis de dashboard que o usuário pode acessar
const displayRoles = new Set<string>();
rolesFromApi.forEach((role) => {
switch (role) {
case "gestor":
displayRoles.add("manager");
displayRoles.add("finance");
break;
case "medico":
displayRoles.add("doctor");
break;
case "secretaria":
displayRoles.add("secretary");
break;
case "patient": // Mapeamento de 'patient' (ou outro nome que você use para patiente)
displayRoles.add("patient");
break;
case "gestor": displayRoles.add("manager"); displayRoles.add("finance"); break;
case "medico": displayRoles.add("doctor"); break;
case "secretaria": displayRoles.add("secretary"); break;
case "paciente": displayRoles.add("patient"); break;
}
});
const finalRoles = Array.from(displayRoles);
// Caso 2: Se o usuário tem apenas UM perfil de dashboard, redireciona direto.
if (finalRoles.length === 1) {
handleRoleSelection(finalRoles[0]);
}
// Caso 3: Se tem múltiplos perfis (ex: 'gestor'), mostra a tela de seleção.
else {
handleSelectionWithUser(finalRoles[0]);
} else {
setUserRoles(finalRoles);
// Atualizamos o onClick aqui também
const roleButtons = finalRoles.map((role) => (
<Button key={role} variant="outline" className="h-11 text-base" onClick={() => handleSelectionWithUser(role)}>
Entrar como: {role.charAt(0).toUpperCase() + role.slice(1)}
</Button>
));
setRoleSelectionUI(roleButtons);
setIsLoading(false);
}
} catch (error) {
localStorage.removeItem("token");
localStorage.removeItem("user_info");
toast({
title: "Erro no Login",
description: error instanceof Error ? error.message : "Ocorreu um erro inesperado.",
variant: "destructive",
});
setIsLoading(false);
}
setIsLoading(false);
};
// --- JSX ATUALIZADO COM RENDERIZAÇÃO CONDICIONAL ---
// Estado para guardar os botões de seleção de perfil
const [roleSelectionUI, setRoleSelectionUI] = useState<React.ReactNode | null>(null);
return (
<Card className="w-full bg-transparent border-0 shadow-none">
<CardContent className="p-0">
{userRoles.length === 0 ? (
// VISÃO 1: Formulário de Login (se nenhum perfil foi carregado ainda)
{!roleSelectionUI ? (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="email">E-mail</Label>
@ -196,28 +168,16 @@ export function LoginForm({ children }: LoginFormProps) {
</Button>
</form>
) : (
// VISÃO 2: Tela de Seleção de Perfil (se múltiplos perfis foram encontrados)
<div className="space-y-4 animate-in fade-in-50">
<h3 className="text-lg font-medium text-center text-foreground">Você tem múltiplos perfis</h3>
<p className="text-sm text-muted-foreground text-center">Selecione com qual perfil deseja entrar:</p>
<div className="flex flex-col space-y-3 pt-2">
{userRoles.map((role) => (
<Button
key={role}
variant="outline"
className="h-11 text-base"
// AQUI ESTÁ A CORREÇÃO:
onClick={() => handleRoleSelection(role === 'paciente' ? 'patient' : role)}
>
Entrar como: {role.charAt(0).toUpperCase() + role.slice(1)}
</Button>
))}
{roleSelectionUI}
</div>
</div>
)}
{children}
</CardContent>
</Card>
);
}
}

View File

@ -1,6 +1,52 @@
// ARQUIVO: lib/utils.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// ADICIONE A FUNÇÃO ABAIXO
export function isValidCPF(cpf: string | null | undefined): boolean {
if (!cpf) return false;
// Remove caracteres não numéricos
const cpfDigits = cpf.replace(/\D/g, '');
if (cpfDigits.length !== 11 || /^(\d)\1+$/.test(cpfDigits)) {
return false;
}
let sum = 0;
let remainder;
for (let i = 1; i <= 9; i++) {
sum += parseInt(cpfDigits.substring(i - 1, i)) * (11 - i);
}
remainder = (sum * 10) % 11;
if (remainder === 10 || remainder === 11) {
remainder = 0;
}
if (remainder !== parseInt(cpfDigits.substring(9, 10))) {
return false;
}
sum = 0;
for (let i = 1; i <= 10; i++) {
sum += parseInt(cpfDigits.substring(i - 1, i)) * (12 - i);
}
remainder = (sum * 10) % 11;
if (remainder === 10 || remainder === 11) {
remainder = 0;
}
if (remainder !== parseInt(cpfDigits.substring(10, 11))) {
return false;
}
return true;
}