Merge pull request #15 from m1guelmcf/Stage

Stage
This commit is contained in:
DaniloSts 2025-11-13 15:21:13 -03:00 committed by GitHub
commit 945e9b49cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 2792 additions and 3992 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 DoctorLayout from "@/components/doctor-layout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useState, useEffect, useMemo } from "react";
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";
import Sidebar from "@/components/Sidebar";
// 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 <Sidebar><div>Carregando...</div></Sidebar>;
}
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 (
<Sidebar>
<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>
</Sidebar>
);
}

View File

@ -1,6 +1,5 @@
"use client";
import DoctorLayout from "@/components/doctor-layout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Calendar, Clock, User, Trash2 } from "lucide-react";
@ -14,6 +13,7 @@ import { AvailabilityService } from "@/services/availabilityApi.mjs";
import { exceptionsService } from "@/services/exceptionApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs";
import { usersService } from "@/services/usersApi.mjs";
import Sidebar from "@/components/Sidebar";
type Availability = {
id: string;
@ -231,7 +231,7 @@ export default function PatientDashboard() {
}, [availability]);
return (
<DoctorLayout>
<Sidebar>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
@ -409,6 +409,6 @@ export default function PatientDashboard() {
</AlertDialogContent>
</AlertDialog>
</div>
</DoctorLayout>
</Sidebar>
);
}

View File

@ -3,14 +3,12 @@
import type React from "react";
import Link from "next/link";
import { useState, useEffect } from "react";
import DoctorLayout from "@/components/doctor-layout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 { Clock, Calendar as CalendarIcon, MapPin, Phone, User, X, RefreshCw } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Calendar as CalendarIcon, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "@/hooks/use-toast";
import { exceptionsService } from "@/services/exceptionApi.mjs";
@ -19,6 +17,7 @@ import { exceptionsService } from "@/services/exceptionApi.mjs";
import { Calendar } from "@/components/ui/calendar";
import { format } from "date-fns"; // Usaremos o date-fns para formatação e comparação de datas
import { doctorsService } from "@/services/doctorsApi.mjs";
import Sidebar from "@/components/Sidebar";
type Doctor = {
id: string;
@ -147,7 +146,7 @@ export default function ExceptionPage() {
const displayDate = selectedCalendarDate ? new Date(selectedCalendarDate).toLocaleDateString("pt-BR", { weekday: "long", day: "2-digit", month: "long" }) : "Selecione uma data";
return (
<DoctorLayout>
<Sidebar>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Adicione exceções</h1>
@ -254,6 +253,6 @@ export default function ExceptionPage() {
</div>
</div>
</div>
</DoctorLayout>
</Sidebar>
);
}

View File

@ -7,7 +7,6 @@ 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 DoctorLayout from "@/components/doctor-layout";
import { AvailabilityService } from "@/services/availabilityApi.mjs";
import { usersService } from "@/services/usersApi.mjs";
@ -17,9 +16,10 @@ import { toast } from "@/hooks/use-toast";
import { useRouter } from "next/navigation";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Eye, Edit, Calendar, Trash2 } from "lucide-react";
import { Edit, Trash2 } from "lucide-react";
import { AvailabilityEditModal } from "@/components/ui/availability-edit-modal";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import Sidebar from "@/components/Sidebar";
// ... (Interfaces de tipo omitidas para brevidade, pois não foram alteradas)
@ -323,8 +323,8 @@ export default function AvailabilityPage() {
};
return (
<DoctorLayout>
<div className="space-y-6">
<Sidebar>
<div className="space-y-6 flex-1 overflow-y-auto p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Definir Disponibilidade</h1>
@ -416,11 +416,12 @@ export default function AvailabilityPage() {
{/* **AJUSTE DE RESPONSIVIDADE: BOTÕES DE AÇÃO** */}
{/* Alinha à direita em telas maiores e empilha (com o botão primário no final) em telas menores */}
{/* Alteração aqui: Adicionado w-full aos Links e Buttons para ocuparem a largura total em telas pequenas */}
<div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-4">
<Link href="/doctor/disponibilidade/excecoes" className="w-full sm:w-auto">
<Button variant="default" className="w-full sm:w-auto">Adicionar Exceção</Button>
</Link>
<div className="flex gap-4 w-full sm:w-auto">
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto"> {/* Ajustado para empilhar os botões Cancelar e Salvar em telas pequenas */}
<Link href="/doctor/dashboard" className="w-full sm:w-auto">
<Button variant="outline" className="w-full sm:w-auto">Cancelar</Button>
</Link>
@ -506,6 +507,6 @@ export default function AvailabilityPage() {
onSubmit={handleEdit}
/>
</DoctorLayout>
</Sidebar>
);
}

View File

@ -12,7 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { ArrowLeft, Save } from "lucide-react";
import Link from "next/link";
import DoctorLayout from "@/components/doctor-layout";
import Sidebar from "@/components/Sidebar";
// Mock data - in a real app, this would come from an API
const mockDoctors = [
@ -124,7 +124,7 @@ export default function EditarMedicoPage() {
};
return (
<DoctorLayout>
<Sidebar>
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/medicos">
@ -512,6 +512,6 @@ export default function EditarMedicoPage() {
</div>
</form>
</div>
</DoctorLayout>
</Sidebar>
);
}

View File

@ -2,7 +2,6 @@
import { useParams, useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import DoctorLayout from "@/components/doctor-layout";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@ -17,6 +16,7 @@ import { format } from "date-fns";
import TiptapEditor from "@/components/ui/tiptap-editor";
import { Skeleton } from "@/components/ui/skeleton";
import { reportsApi } from "@/services/reportsApi.mjs";
import Sidebar from "@/components/Sidebar";
export default function EditarLaudoPage() {
const router = useRouter();
@ -108,7 +108,7 @@ export default function EditarLaudoPage() {
if (loading) {
return (
<DoctorLayout>
<Sidebar>
<div className="container mx-auto p-4">
<Card>
<CardHeader>
@ -130,12 +130,12 @@ export default function EditarLaudoPage() {
</CardContent>
</Card>
</div>
</DoctorLayout>
</Sidebar>
)
}
return (
<DoctorLayout>
<Sidebar>
<div className="container mx-auto p-4">
<Card>
<CardHeader>
@ -232,6 +232,6 @@ export default function EditarLaudoPage() {
</CardContent>
</Card>
</div>
</DoctorLayout>
</Sidebar>
);
}

View File

@ -17,7 +17,7 @@ import { format } from "date-fns";
import TiptapEditor from "@/components/ui/tiptap-editor";
import { reportsApi } from "@/services/reportsApi.mjs";
import DoctorLayout from "@/components/doctor-layout";
import Sidebar from "@/components/Sidebar";
@ -97,7 +97,7 @@ export default function NovoLaudoPage() {
};
return (
<DoctorLayout>
<Sidebar>
<div className="container mx-auto p-4">
<Card>
<CardHeader>
@ -189,6 +189,6 @@ export default function NovoLaudoPage() {
</CardContent>
</Card>
</div>
</DoctorLayout>
</Sidebar>
);
}

View File

@ -8,7 +8,7 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import { api } from '@/services/api.mjs';
import { reportsApi } from '@/services/reportsApi.mjs';
import DoctorLayout from '@/components/doctor-layout';
import Sidebar from '@/components/Sidebar';
export default function LaudosPage() {
const [patient, setPatient] = useState(null);
@ -49,7 +49,7 @@ export default function LaudosPage() {
const paginate = (pageNumber) => setCurrentPage(pageNumber);
return (
<DoctorLayout>
<Sidebar>
<div className="container mx-auto p-4">
{loading ? (
<p>Carregando...</p>
@ -123,6 +123,6 @@ export default function LaudosPage() {
</>
)}
</div>
</DoctorLayout>
</Sidebar>
);
}

View File

@ -9,7 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Upload, Plus, X, ChevronDown } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import DoctorLayout from "@/components/doctor-layout";
import Sidebar from "@/components/Sidebar";
export default function NovoMedicoPage() {
const [anexosOpen, setAnexosOpen] = useState(false);
@ -24,7 +24,7 @@ export default function NovoMedicoPage() {
};
return (
<DoctorLayout>
<Sidebar>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
@ -466,6 +466,6 @@ export default function NovoMedicoPage() {
</div>
</form>
</div>
</DoctorLayout>
</Sidebar>
);
}

View File

@ -1,368 +1,421 @@
// app/doctor/pacientes/page.tsx (assumindo a localização)
"use client";
import { useEffect, useState, useCallback } from "react";
import DoctorLayout from "@/components/doctor-layout";
import Link from "next/link";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Eye, Edit, Calendar, Trash2, Loader2 } from "lucide-react";
import { api } from "@/services/api.mjs";
import { PatientDetailsModal } from "@/components/ui/patient-details-modal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import Sidebar from "@/components/Sidebar";
interface Paciente {
id: string;
nome: string;
telefone: string;
cidade: string;
estado: string;
ultimoAtendimento?: string;
proximoAtendimento?: string;
email?: string;
birth_date?: string;
cpf?: string;
blood_type?: string;
weight_kg?: number;
height_m?: number;
street?: string;
number?: string;
complement?: string;
neighborhood?: string;
cep?: string;
id: string;
nome: string;
telefone: string;
cidade: string;
estado: string;
ultimoAtendimento?: string;
proximoAtendimento?: string;
email?: string;
birth_date?: string;
cpf?: string;
blood_type?: string;
weight_kg?: number;
height_m?: number;
street?: string;
number?: string;
complement?: string;
neighborhood?: string;
cep?: string;
}
export default function PacientesPage() {
const [pacientes, setPacientes] = useState<Paciente[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedPatient, setSelectedPatient] = useState<Paciente | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [pacientes, setPacientes] = useState<Paciente[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedPatient, setSelectedPatient] = useState<Paciente | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
// --- Lógica de Paginação INÍCIO ---
const [itemsPerPage, setItemsPerPage] = useState(5);
const [currentPage, setCurrentPage] = useState(1);
// --- Lógica de Paginação INÍCIO ---
const [itemsPerPage, setItemsPerPage] = useState(5);
const [currentPage, setCurrentPage] = useState(1);
const totalPages = Math.ceil(pacientes.length / itemsPerPage);
const totalPages = Math.ceil(pacientes.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = pacientes.slice(indexOfFirstItem, indexOfLastItem);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = pacientes.slice(indexOfFirstItem, indexOfLastItem);
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
// Funções de Navegação
const goToPrevPage = () => {
setCurrentPage((prev) => Math.max(1, prev - 1));
};
// Funções de Navegação
const goToPrevPage = () => {
setCurrentPage((prev) => Math.max(1, prev - 1));
};
const goToNextPage = () => {
setCurrentPage((prev) => Math.min(totalPages, prev + 1));
};
// Lógica para gerar os números das páginas visíveis (máximo de 5)
const getVisiblePageNumbers = (totalPages: number, currentPage: number) => {
const pages: number[] = [];
const maxVisiblePages = 5;
const halfRange = Math.floor(maxVisiblePages / 2);
let startPage = Math.max(1, currentPage - halfRange);
let endPage = Math.min(totalPages, currentPage + halfRange);
const goToNextPage = () => {
setCurrentPage((prev) => Math.min(totalPages, prev + 1));
};
if (endPage - startPage + 1 < maxVisiblePages) {
if (endPage === totalPages) {
startPage = Math.max(1, totalPages - maxVisiblePages + 1);
}
if (startPage === 1) {
endPage = Math.min(totalPages, maxVisiblePages);
}
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
const visiblePageNumbers = getVisiblePageNumbers(totalPages, currentPage);
// Lógica para gerar os números das páginas visíveis (máximo de 5)
const getVisiblePageNumbers = (totalPages: number, currentPage: number) => {
const pages: number[] = [];
const maxVisiblePages = 5;
const halfRange = Math.floor(maxVisiblePages / 2);
let startPage = Math.max(1, currentPage - halfRange);
let endPage = Math.min(totalPages, currentPage + halfRange);
// Lógica para mudar itens por página, resetando para a página 1
const handleItemsPerPageChange = (value: string) => {
setItemsPerPage(Number(value));
setCurrentPage(1);
};
// --- Lógica de Paginação FIM ---
if (endPage - startPage + 1 < maxVisiblePages) {
if (endPage === totalPages) {
startPage = Math.max(1, totalPages - maxVisiblePages + 1);
}
if (startPage === 1) {
endPage = Math.min(totalPages, maxVisiblePages);
}
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
const visiblePageNumbers = getVisiblePageNumbers(totalPages, currentPage);
// Lógica para mudar itens por página, resetando para a página 1
const handleItemsPerPageChange = (value: string) => {
setItemsPerPage(Number(value));
setCurrentPage(1);
};
// --- Lógica de Paginação FIM ---
const handleOpenModal = (patient: Paciente) => {
setSelectedPatient(patient);
setIsModalOpen(true);
};
const handleOpenModal = (patient: Paciente) => {
setSelectedPatient(patient);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setSelectedPatient(null);
setIsModalOpen(false);
};
const handleCloseModal = () => {
setSelectedPatient(null);
setIsModalOpen(false);
};
const formatDate = (dateString: string | null | undefined) => {
if (!dateString) return "N/A";
try {
const date = new Date(dateString);
return new Intl.DateTimeFormat("pt-BR").format(date);
} catch (e) {
return dateString; // Retorna o string original se o formato for inválido
}
};
const formatDate = (dateString: string | null | undefined) => {
if (!dateString) return "N/A";
try {
const date = new Date(dateString);
return new Intl.DateTimeFormat("pt-BR").format(date);
} catch (e) {
return dateString; // Retorna o string original se o formato for inválido
}
};
const fetchPacientes = useCallback(async () => {
try {
setLoading(true);
setError(null);
const json = await api.get("/rest/v1/patients");
const items = Array.isArray(json)
? json
: Array.isArray(json?.data)
? json.data
: [];
const fetchPacientes = useCallback(async () => {
try {
setLoading(true);
setError(null);
const json = await api.get("/rest/v1/patients");
const items = Array.isArray(json)
? json
: Array.isArray(json?.data)
? json.data
: [];
const mapped: Paciente[] = items.map((p: any) => ({
id: String(p.id ?? ""),
nome: p.full_name ?? "—",
telefone: p.phone_mobile ?? "N/A",
cidade: p.city ?? "N/A",
estado: p.state ?? "N/A",
ultimoAtendimento: formatDate(p.created_at),
proximoAtendimento: "N/A", // Necessita de lógica de agendamento real
email: p.email ?? "N/A",
birth_date: p.birth_date ?? "N/A",
cpf: p.cpf ?? "N/A",
blood_type: p.blood_type ?? "N/A",
weight_kg: p.weight_kg ?? 0,
height_m: p.height_m ?? 0,
street: p.street ?? "N/A",
number: p.number ?? "N/A",
complement: p.complement ?? "N/A",
neighborhood: p.neighborhood ?? "N/A",
cep: p.cep ?? "N/A",
}));
const mapped: Paciente[] = items.map((p: any) => ({
id: String(p.id ?? ""),
nome: p.full_name ?? "—",
telefone: p.phone_mobile ?? "N/A",
cidade: p.city ?? "N/A",
estado: p.state ?? "N/A",
ultimoAtendimento: formatDate(p.created_at),
proximoAtendimento: "N/A", // Necessita de lógica de agendamento real
email: p.email ?? "N/A",
birth_date: p.birth_date ?? "N/A",
cpf: p.cpf ?? "N/A",
blood_type: p.blood_type ?? "N/A",
weight_kg: p.weight_kg ?? 0,
height_m: p.height_m ?? 0,
street: p.street ?? "N/A",
number: p.number ?? "N/A",
complement: p.complement ?? "N/A",
neighborhood: p.neighborhood ?? "N/A",
cep: p.cep ?? "N/A",
}));
setPacientes(mapped);
setCurrentPage(1); // Resetar a página ao carregar novos dados
} catch (e: any) {
console.error("Erro ao carregar pacientes:", e);
setError(e?.message || "Erro ao carregar pacientes");
} finally {
setLoading(false);
}
}, []);
setPacientes(mapped);
setCurrentPage(1); // Resetar a página ao carregar novos dados
} catch (e: any) {
console.error("Erro ao carregar pacientes:", e);
setError(e?.message || "Erro ao carregar pacientes");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchPacientes();
}, [fetchPacientes]);
useEffect(() => {
fetchPacientes();
}, [fetchPacientes]);
return (
<DoctorLayout>
<div className="space-y-6 px-2 sm:px-4 md:px-6">
{/* Cabeçalho */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">Pacientes</h1>
<p className="text-muted-foreground text-sm sm:text-base">
Lista de pacientes vinculados
</p>
</div>
{/* Adicione um seletor de itens por página ao lado de um botão de 'Novo Paciente' se aplicável */}
<div className="flex gap-3">
<Select
onValueChange={handleItemsPerPageChange}
defaultValue={String(itemsPerPage)}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Itens por pág." />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5 por página</SelectItem>
<SelectItem value="10">10 por página</SelectItem>
<SelectItem value="20">20 por página</SelectItem>
</SelectContent>
</Select>
<Link href="/doctor/pacientes/novo">
<Button variant="default" className="bg-green-600 hover:bg-green-700">
Novo Paciente
</Button>
</Link>
</div>
</div>
return (
<Sidebar>
<div className="space-y-6 px-2 sm:px-4 md:px-6">
{/* Cabeçalho */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> {/* Ajustado para flex-col em telas pequenas */}
<div>
<h1 className="text-2xl font-bold text-foreground">Pacientes</h1>
<p className="text-muted-foreground text-sm sm:text-base">
Lista de pacientes vinculados
</p>
</div>
{/* Controles de filtro e novo paciente */}
{/* Alterado para que o Select e o Link ocupem a largura total em telas pequenas e fiquem lado a lado em telas maiores */}
<div className="flex flex-wrap gap-3 mt-4 sm:mt-0 w-full sm:w-auto justify-start sm:justify-end">
<Select
onValueChange={handleItemsPerPageChange}
defaultValue={String(itemsPerPage)}
>
<SelectTrigger className="w-full sm:w-[140px]">
<SelectValue placeholder="Itens por pág." />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5 por página</SelectItem>
<SelectItem value="10">10 por página</SelectItem>
<SelectItem value="20">20 por página</SelectItem>
</SelectContent>
</Select>
<Link href="/doctor/pacientes/novo" className="w-full sm:w-auto">
<Button variant="default" className="bg-green-600 hover:bg-green-700 w-full sm:w-auto">
Novo Paciente
</Button>
</Link>
</div>
</div>
<div className="bg-card rounded-lg border border-border overflow-hidden shadow-md">
<div className="overflow-x-auto">
<table className="min-w-[600px] w-full">
<thead className="bg-muted border-b border-border">
<tr>
<th className="text-left p-3 sm:p-4 font-medium text-foreground">Nome</th>
<th className="text-left p-3 sm:p-4 font-medium text-foreground hidden md:table-cell">
Telefone
</th>
<th className="text-left p-3 sm:p-4 font-medium text-foreground hidden lg:table-cell">
Cidade
</th>
<th className="text-left p-3 sm:p-4 font-medium text-foreground hidden lg:table-cell">
Estado
</th>
<th className="text-left p-3 sm:p-4 font-medium text-foreground hidden xl:table-cell">
Último atendimento
</th>
<th className="text-left p-3 sm:p-4 font-medium text-foreground hidden xl:table-cell">
Próximo atendimento
</th>
<th className="text-left p-3 sm:p-4 font-medium text-foreground">Ações</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="p-6 text-muted-foreground text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-primary" />
Carregando pacientes...
</td>
</tr>
) : error ? (
<tr>
<td colSpan={7} className="p-6 text-red-600 text-center">{`Erro: ${error}`}</td>
</tr>
) : pacientes.length === 0 ? (
<tr>
<td colSpan={7} className="p-8 text-center text-muted-foreground">
Nenhum paciente encontrado
</td>
</tr>
) : (
currentItems.map((p) => (
<tr
key={p.id}
className="border-b border-border hover:bg-accent/40 transition-colors"
>
<td className="p-3 sm:p-4">{p.nome}</td>
<td className="p-3 sm:p-4 text-muted-foreground hidden md:table-cell">
{p.telefone}
</td>
<td className="p-3 sm:p-4 text-muted-foreground hidden lg:table-cell">
{p.cidade}
</td>
<td className="p-3 sm:p-4 text-muted-foreground hidden lg:table-cell">
{p.estado}
</td>
<td className="p-3 sm:p-4 text-muted-foreground hidden xl:table-cell">
{p.ultimoAtendimento}
</td>
<td className="p-3 sm:p-4 text-muted-foreground hidden xl:table-cell">
{p.proximoAtendimento}
</td>
<td className="p-3 sm:p-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="text-primary hover:underline text-sm sm:text-base">
Ações
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleOpenModal(p)}>
<Eye className="w-4 h-4 mr-2" />
Ver detalhes
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/doctor/pacientes/${p.id}/laudos`}>
<Edit className="w-4 h-4 mr-2" />
Laudos
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => alert(`Agenda para paciente ID: ${p.id}`)}>
<Calendar className="w-4 h-4 mr-2" />
Ver agenda
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
// Simulação de exclusão (A exclusão real deve ser feita via API)
const newPacientes = pacientes.filter((pac) => pac.id !== p.id);
setPacientes(newPacientes);
alert(`Paciente ID: ${p.id} excluído`);
// Necessário chamar a API de exclusão aqui
}}
className="text-red-600 focus:bg-red-50 focus:text-red-600"
<div className="bg-card rounded-lg border border-border overflow-hidden shadow-md">
{/* Tabela para Telas Médias e Grandes */}
<div className="overflow-x-auto hidden md:block"> {/* Esconde em telas pequenas */}
<table className="min-w-[600px] w-full">
<thead className="bg-muted border-b border-border">
<tr>
<th className="text-left p-3 sm:p-4 font-medium text-foreground">Nome</th>
<th className="text-left p-3 sm:p-4 font-medium text-foreground">
Telefone
</th>
<th className="text-left p-3 sm:p-4 font-medium text-foreground hidden lg:table-cell">
Cidade
</th>
<th className="text-left p-3 sm:p-4 font-medium text-foreground hidden lg:table-cell">
Estado
</th>
<th className="text-left p-3 sm:p-4 font-medium text-foreground hidden xl:table-cell">
Último atendimento
</th>
<th className="text-left p-3 sm:p-4 font-medium text-foreground hidden xl:table-cell">
Próximo atendimento
</th>
<th className="text-left p-3 sm:p-4 font-medium text-foreground">Ações</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="p-6 text-muted-foreground text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-primary" />
Carregando pacientes...
</td>
</tr>
) : error ? (
<tr>
<td colSpan={7} className="p-6 text-red-600 text-center">{`Erro: ${error}`}</td>
</tr>
) : pacientes.length === 0 ? (
<tr>
<td colSpan={7} className="p-8 text-center text-muted-foreground">
Nenhum paciente encontrado
</td>
</tr>
) : (
currentItems.map((p) => (
<tr
key={p.id}
className="border-b border-border hover:bg-accent/40 transition-colors"
>
<td className="p-3 sm:p-4">{p.nome}</td>
<td className="p-3 sm:p-4 text-muted-foreground">
{p.telefone}
</td>
<td className="p-3 sm:p-4 text-muted-foreground hidden lg:table-cell">
{p.cidade}
</td>
<td className="p-3 sm:p-4 text-muted-foreground hidden lg:table-cell">
{p.estado}
</td>
<td className="p-3 sm:p-4 text-muted-foreground hidden xl:table-cell">
{p.ultimoAtendimento}
</td>
<td className="p-3 sm:p-4 text-muted-foreground hidden xl:table-cell">
{p.proximoAtendimento}
</td>
<td className="p-3 sm:p-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="text-primary hover:underline text-sm sm:text-base">
Ações
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleOpenModal(p)}>
<Eye className="w-4 h-4 mr-2" />
Ver detalhes
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/doctor/pacientes/${p.id}/laudos`}>
<Edit className="w-4 h-4 mr-2" />
Laudos
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => alert(`Agenda para paciente ID: ${p.id}`)}>
<Calendar className="w-4 h-4 mr-2" />
Ver agenda
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
const newPacientes = pacientes.filter((pac) => pac.id !== p.id);
setPacientes(newPacientes);
alert(`Paciente ID: ${p.id} excluído`);
}}
className="text-red-600 focus:bg-red-50 focus:text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Layout em Cards/Lista para Telas Pequenas */}
<div className="md:hidden divide-y divide-border"> {/* Visível apenas em telas pequenas */}
{loading ? (
<div className="p-6 text-muted-foreground text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-primary" />
Carregando pacientes...
</div>
) : error ? (
<div className="p-6 text-red-600 text-center">{`Erro: ${error}`}</div>
) : pacientes.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
Nenhum paciente encontrado
</div>
) : (
currentItems.map((p) => (
<div key={p.id} className="flex items-center justify-between p-4 hover:bg-accent/40 transition-colors">
<div className="flex-1 min-w-0 pr-4"> {/* Adicionado padding à direita */}
<div className="text-base font-semibold text-foreground break-words"> {/* Aumentado a fonte e break-words para evitar corte do nome */}
{p.nome || "—"}
</div>
{/* Removido o 'truncate' e adicionado 'break-words' no telefone */}
<div className="text-sm text-muted-foreground break-words">
Telefone: **{p.telefone || "N/A"}**
</div>
</div>
<div className="ml-4 flex-shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Eye className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleOpenModal(p)}>
<Eye className="w-4 h-4 mr-2" />
Ver detalhes
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/doctor/pacientes/${p.id}/laudos`}>
<Edit className="w-4 h-4 mr-2" />
Laudos
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => alert(`Agenda para paciente ID: ${p.id}`)}>
<Calendar className="w-4 h-4 mr-2" />
Ver agenda
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
const newPacientes = pacientes.filter((pac) => pac.id !== p.id);
setPacientes(newPacientes);
alert(`Paciente ID: ${p.id} excluído`);
}}
className="text-red-600 focus:bg-red-50 focus:text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))
)}
</div>
{/* Paginação */}
{totalPages > 1 && (
<div className="flex flex-wrap justify-center items-center gap-2 border-t border-border p-4 bg-muted/40">
{/* Botão Anterior */}
<button
onClick={goToPrevPage}
disabled={currentPage === 1}
className="flex items-center px-4 py-2 rounded-md font-medium transition-colors text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80 disabled:opacity-50 disabled:cursor-not-allowed border border-border"
>
<Trash2 className="w-4 h-4 mr-2" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{"< Anterior"}
</button>
{/* Paginação ATUALIZADA */}
{totalPages > 1 && (
<div className="flex flex-wrap justify-center items-center gap-2 border-t border-border p-4 bg-muted/40">
{/* Botão Anterior */}
<button
onClick={goToPrevPage}
disabled={currentPage === 1}
className="flex items-center px-4 py-2 rounded-md font-medium transition-colors text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80 disabled:opacity-50 disabled:cursor-not-allowed border border-border"
>
{"< Anterior"}
</button>
{/* Números das Páginas */}
{visiblePageNumbers.map((number) => (
<button
key={number}
onClick={() => paginate(number)}
className={`px-4 py-2 rounded-md font-medium transition-colors text-sm border border-border ${
currentPage === number
? "bg-green-600 text-primary-foreground shadow-md border-green-600"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
>
{number}
</button>
))}
{/* Números das Páginas */}
{visiblePageNumbers.map((number) => (
<button
key={number}
onClick={() => paginate(number)}
className={`px-4 py-2 rounded-md font-medium transition-colors text-sm border border-border ${
currentPage === number
? "bg-green-600 text-primary-foreground shadow-md border-green-600"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
>
{number}
</button>
))}
{/* Botão Próximo */}
<button
onClick={goToNextPage}
disabled={currentPage === totalPages}
className="flex items-center px-4 py-2 rounded-md font-medium transition-colors text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80 disabled:opacity-50 disabled:cursor-not-allowed border border-border"
>
{"Próximo >"}
</button>
{/* Botão Próximo */}
<button
onClick={goToNextPage}
disabled={currentPage === totalPages}
className="flex items-center px-4 py-2 rounded-md font-medium transition-colors text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80 disabled:opacity-50 disabled:cursor-not-allowed border border-border"
>
{"Próximo >"}
</button>
</div>
)}
</div>
</div>
)}
{/* Fim da Paginação ATUALIZADA */}
</div>
</div>
<PatientDetailsModal
patient={selectedPatient}
isOpen={isModalOpen}
onClose={handleCloseModal}
/>
</DoctorLayout>
</Sidebar>
);
}

View File

@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import FinancierLayout from "@/components/finance-layout";
import Sidebar from "@/components/Sidebar";
interface Paciente {
id: string;
@ -14,43 +14,10 @@ interface Paciente {
}
export default function PacientesPage() {
const [pacientes, setPacientes] = useState<Paciente[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchPacientes() {
try {
setLoading(true);
setError(null);
const res = await fetch("https://mock.apidog.com/m1/1053378-0-default/pacientes");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const items = Array.isArray(json?.data) ? json.data : [];
const mapped = items.map((p: any) => ({
id: String(p.id ?? ""),
nome: p.nome ?? "",
telefone: p?.contato?.celular ?? p?.contato?.telefone1 ?? p?.telefone ?? "",
cidade: p?.endereco?.cidade ?? p?.cidade ?? "",
estado: p?.endereco?.estado ?? p?.estado ?? "",
ultimoAtendimento: p.ultimo_atendimento ?? p.ultimoAtendimento ?? "",
proximoAtendimento: p.proximo_atendimento ?? p.proximoAtendimento ?? "",
}));
setPacientes(mapped);
} catch (e: any) {
setError(e?.message || "Erro ao carregar pacientes");
} finally {
setLoading(false);
}
}
fetchPacientes();
}, []);
return (
<FinancierLayout>
<Sidebar>
<div></div>
</FinancierLayout>
</Sidebar>
);
}

View File

@ -1,82 +1,257 @@
// Caminho: app/login/page.tsx
"use client";
import { usersService } from "@/services/usersApi.mjs";
import { LoginForm } from "@/components/LoginForm";
import Link from "next/link";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react"; // Importa o ícone de seta
import { Input } from "@/components/ui/input";
import { ArrowLeft, X } from "lucide-react";
import { useState } from "react";
import RenderFromTemplateContext from "next/dist/client/components/render-from-template-context";
export default function LoginPage() {
return (
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-2">
{/* PAINEL ESQUERDO: O Formulário */}
<div className="relative flex flex-col items-center justify-center p-8 bg-background">
{/* Link para Voltar */}
<div className="absolute top-8 left-8">
<Link href="/" className="inline-flex items-center text-muted-foreground hover:text-primary transition-colors font-medium">
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar à página inicial
</Link>
</div>
const [isModalOpen, setIsModalOpen] = useState(false);
const [email, setEmail] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
{/* O contêiner principal que agora terá a sombra e o estilo de card */}
<div className="w-full max-w-md bg-card p-10 rounded-2xl shadow-xl">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-foreground">Acesse sua conta</h1>
<p className="text-muted-foreground mt-2">Bem-vindo(a) de volta ao MedConnect!</p>
const handleOpenModal = () => {
// Tenta pegar o email do input do formulário de login
const emailInput = document.querySelector(
'input[type="email"]'
) as HTMLInputElement;
if (emailInput?.value) {
setEmail(emailInput.value);
}
setIsModalOpen(true);
};
const handleResetPassword = async () => {
if (!email.trim()) {
setMessage({
type: "error",
text: "Por favor, insira um e-mail válido.",
});
return;
}
setIsLoading(true);
setMessage(null);
try {
// Chama o método que já faz o fetch corretamente
const data = await usersService.resetPassword(email);
console.log("Resposta resetPassword:", data);
setMessage({
type: "success",
text: "E-mail de recuperação enviado! Verifique sua caixa de entrada.",
});
setTimeout(() => {
setIsModalOpen(false);
setMessage(null);
setEmail("");
}, 2000);
} catch (error) {
console.error("Erro no reset de senha:", error);
setMessage({
type: "error",
text:
error instanceof Error
? error.message
: "Erro ao enviar e-mail. Tente novamente.",
});
} finally {
setIsLoading(false);
}
};
const closeModal = () => {
setIsModalOpen(false);
setMessage(null);
setEmail("");
};
return (
<>
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-2">
{/* PAINEL ESQUERDO: O Formulário */}
<div className="relative flex flex-col items-center justify-center p-8 bg-background">
{/* Link para Voltar */}
<div className="absolute top-8 left-8">
<Link
href="/"
className="inline-flex items-center text-muted-foreground hover:text-primary transition-colors font-medium"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar à página inicial
</Link>
</div>
<LoginForm>
{/* Children para o LoginForm */}
<div className="mt-4 text-center text-sm">
<Link href="/esqueci-minha-senha">
<span className="text-muted-foreground hover:text-primary cursor-pointer underline">
{/* O contêiner principal que agora terá a sombra e o estilo de card */}
<div className="w-full max-w-md bg-card p-10 rounded-2xl shadow-xl">
{/* NOVO: Bloco da Logo e Nome (Painel Esquerdo) */}
<div className="flex items-center justify-center space-x-3 mb-8">
<img
src="/Logo MedConnect.png" // Caminho da sua logo
alt="Logo MediConnect"
className="w-16 h-16 object-contain" // Mesmo tamanho que usamos na página inicial
/>
<span className="text-3xl font-extrabold text-primary">
MedConnect
</span>
</div>
{/* FIM: Bloco da Logo e Nome */}
<div className="text-center mb-8">
{/* Título de boas-vindas movido para baixo da logo */}
<h1 className="text-3xl font-bold text-foreground">
Acesse sua conta
</h1>
<p className="text-muted-foreground mt-2">
Bem-vindo(a) de volta ao MedConnect!
</p>
</div>
<LoginForm>
{/* Children para o LoginForm */}
<div className="mt-4 text-center text-sm">
<button
onClick={handleOpenModal}
className="text-muted-foreground hover:text-primary cursor-pointer underline bg-transparent border-none"
>
Esqueceu sua senha?
</button>
</div>
</LoginForm>
<div className="mt-6 text-center text-sm">
<span className="text-muted-foreground">
Não tem uma conta de paciente?{" "}
</span>
<Link href="/patient/register">
<span className="font-semibold text-primary hover:underline cursor-pointer">
Crie uma agora
</span>
</Link>
</div>
</LoginForm>
</div>
</div>
<div className="mt-6 text-center text-sm">
<span className="text-muted-foreground">Não tem uma conta de paciente? </span>
<Link href="/patient/register">
<span className="font-semibold text-primary hover:underline cursor-pointer">
Crie uma agora
</span>
</Link>
{/* PAINEL DIREITO: A Imagem e Branding */}
<div className="hidden lg:block relative">
{/* Usamos o componente <Image> para otimização e performance */}
<Image
src="https://images.unsplash.com/photo-1576091160550-2173dba999ef?q=80&w=2070"
alt="Médica utilizando um tablet na clínica MedConnect"
fill
style={{ objectFit: "cover" }}
priority
/>
{/* Camada de sobreposição para escurecer a imagem e destacar o texto */}
<div className="absolute inset-0 bg-primary/80 flex flex-col items-start justify-end p-12 text-left">
{/* BLOCO DE NOME ADICIONADO */}
<div className="mb-6 border-l-4 border-primary-foreground pl-4">
<h1 className="text-5xl font-extrabold text-primary-foreground tracking-wider">
MedConnect
</h1>
</div>
<h2 className="text-4xl font-bold text-primary-foreground leading-tight">
Tecnologia e Cuidado a Serviço da Sua Saúde.
</h2>
<p className="mt-4 text-lg text-primary-foreground/80">
Acesse seu portal para uma experiência de saúde integrada, segura
e eficiente.
</p>
</div>
</div>
</div>
{/* PAINEL DIREITO: A Imagem e Branding */}
<div className="hidden lg:block relative">
{/* Usamos o componente <Image> para otimização e performance */}
<Image
src="https://images.unsplash.com/photo-1576091160550-2173dba999ef?q=80&w=2070" // Uma imagem profissional de alta qualidade
alt="Médica utilizando um tablet na clínica MedConnect"
fill
style={{ objectFit: 'cover' }}
priority // Ajuda a carregar a imagem mais rápido
/>
{/* Camada de sobreposição para escurecer a imagem e destacar o texto */}
<div className="absolute inset-0 bg-primary/80 flex flex-col items-start justify-end p-12 text-left">
{/* BLOCO DE NOME ADICIONADO */}
<div className="mb-6 border-l-4 border-primary-foreground pl-4">
<h1 className="text-5xl font-extrabold text-primary-foreground tracking-wider">
MedConnect
</h1>
</div>
<h2 className="text-4xl font-bold text-primary-foreground leading-tight">
Tecnologia e Cuidado a Serviço da Sua Saúde.
</h2>
<p className="mt-4 text-lg text-primary-foreground/80">
Acesse seu portal para uma experiência de saúde integrada, segura e eficiente.
</p>
</div>
</div>
{/* Modal de Recuperação de Senha */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="relative w-full max-w-md bg-card p-8 rounded-2xl shadow-2xl mx-4">
{/* Botão de fechar */}
<button
onClick={closeModal}
className="absolute top-4 right-4 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Cabeçalho */}
<div className="mb-6">
<h2 className="text-2xl font-bold text-foreground">
Recuperar Senha
</h2>
<p className="text-muted-foreground mt-2">
Insira seu e-mail e enviaremos um link para redefinir sua senha.
</p>
</div>
{/* Input de e-mail */}
<div className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-foreground mb-2"
>
E-mail
</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="seu@email.com"
disabled={isLoading}
className="w-full"
/>
</div>
{/* Mensagem de feedback */}
{message && (
<div
className={`p-3 rounded-lg text-sm ${
message.type === "success"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
}`}
>
{message.text}
</div>
)}
{/* Botões */}
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={closeModal}
disabled={isLoading}
className="flex-1"
>
Cancelar
</Button>
<Button
onClick={handleResetPassword}
disabled={isLoading}
className="flex-1"
>
{isLoading ? "Enviando..." : "Resetar Senha"}
</Button>
</div>
</div>
</div>
</div>
)}
</>
);
}
}

View File

@ -1,6 +1,5 @@
"use client";
import ManagerLayout from "@/components/manager-layout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Calendar, Clock, Plus, User } from "lucide-react";
@ -8,6 +7,7 @@ import Link from "next/link";
import React, { useState, useEffect } from "react";
import { usersService } from "services/usersApi.mjs";
import { doctorsService } from "services/doctorsApi.mjs";
import Sidebar from "@/components/Sidebar";
export default function ManagerDashboard() {
// 🔹 Estados para usuários
@ -55,7 +55,7 @@ export default function ManagerDashboard() {
}, []);
return (
<ManagerLayout>
<Sidebar>
<div className="space-y-6">
{/* Cabeçalho */}
<div>
@ -185,6 +185,6 @@ export default function ManagerDashboard() {
</Card>
</div>
</div>
</ManagerLayout>
</Sidebar>
);
}

View File

@ -10,7 +10,7 @@ import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { Save, Loader2, ArrowLeft } from "lucide-react"
import ManagerLayout from "@/components/manager-layout"
import Sidebar from "@/components/Sidebar"
import { doctorsService } from "services/doctorsApi.mjs";
const UF_LIST = ["AC", "AL", "AP", "AM", "BA", "CE", "DF", "ES", "GO", "MA", "MT", "MS", "MG", "PA", "PB", "PR", "PE", "PI", "RJ", "RN", "RS", "RO", "RR", "SC", "SP", "SE", "TO"];
@ -207,17 +207,17 @@ export default function EditarMedicoPage() {
};
if (loading) {
return (
<ManagerLayout>
<Sidebar>
<div className="flex justify-center items-center h-full w-full py-16">
<Loader2 className="w-8 h-8 animate-spin text-green-600" />
<p className="ml-2 text-gray-600">Carregando dados do médico...</p>
</div>
</ManagerLayout>
</Sidebar>
);
}
return (
<ManagerLayout>
<Sidebar>
<div className="w-full space-y-6 p-4 md:p-8">
<div className="flex items-center justify-between">
<div>
@ -487,6 +487,6 @@ export default function EditarMedicoPage() {
</div>
</form>
</div>
</ManagerLayout>
</Sidebar>
);
}

View File

@ -1,25 +1,16 @@
"use client";
import React, { useEffect, useState, useCallback, useMemo } from "react"
import ManagerLayout from "@/components/manager-layout";
import Link from "next/link"
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Plus, Edit, Trash2, Eye, Calendar, Filter, Loader2 } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Edit, Trash2, Eye, Calendar, Filter, Loader2 } from "lucide-react"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
import { doctorsService } from "services/doctorsApi.mjs";
import Sidebar from "@/components/Sidebar";
interface Doctor {
@ -33,7 +24,6 @@ interface Doctor {
status?: string;
}
interface DoctorDetails {
nome: string;
crm: string;
@ -41,11 +31,11 @@ interface DoctorDetails {
contato: {
celular?: string;
telefone1?: string;
}
};
endereco: {
cidade?: string;
estado?: string;
}
};
convenio?: string;
vip?: boolean;
status?: string;
@ -80,7 +70,7 @@ export default function DoctorsPage() {
const data: Doctor[] = await doctorsService.list();
const dataWithStatus = data.map((doc, index) => ({
...doc,
status: index % 3 === 0 ? "Inativo" : index % 2 === 0 ? "Férias" : "Ativo"
status: index % 3 === 0 ? "Inativo" : index % 2 === 0 ? "Férias" : "Ativo",
}));
setDoctors(dataWithStatus || []);
setCurrentPage(1);
@ -93,12 +83,10 @@ export default function DoctorsPage() {
}
}, []);
useEffect(() => {
fetchDoctors();
}, [fetchDoctors]);
const openDetailsDialog = async (doctor: Doctor) => {
setDetailsDialogOpen(true);
setDoctorDetails({
@ -115,7 +103,6 @@ export default function DoctorsPage() {
});
};
const handleDelete = async () => {
if (doctorToDeleteId === null) return;
setLoading(true);
@ -138,11 +125,11 @@ export default function DoctorsPage() {
};
const uniqueSpecialties = useMemo(() => {
const specialties = doctors.map(doctor => doctor.specialty).filter(Boolean);
const specialties = doctors.map((doctor) => doctor.specialty).filter(Boolean);
return [...new Set(specialties)];
}, [doctors]);
const filteredDoctors = doctors.filter(doctor => {
const filteredDoctors = doctors.filter((doctor) => {
const specialtyMatch = specialtyFilter === "all" || doctor.specialty === specialtyFilter;
const statusMatch = statusFilter === "all" || doctor.status === statusFilter;
return specialtyMatch && statusMatch;
@ -191,11 +178,9 @@ export default function DoctorsPage() {
setCurrentPage(1);
};
return (
<ManagerLayout>
<Sidebar>
<div className="space-y-6 px-2 sm:px-4 md:px-6">
{/* Cabeçalho */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div>
@ -204,29 +189,26 @@ export default function DoctorsPage() {
</div>
</div>
{/* Filtros e Itens por Página */}
<div className="flex flex-wrap items-center gap-3 bg-white p-3 sm:p-4 rounded-lg border border-gray-200">
<div className="flex items-center gap-2 w-full md:w-auto">
<span className="text-sm font-medium text-foreground">
Especialidade
</span>
<span className="text-sm font-medium text-foreground">Especialidade</span>
<Select value={specialtyFilter} onValueChange={setSpecialtyFilter}>
<SelectTrigger className="w-[160px] sm:w-[180px]">
<SelectValue placeholder="Especialidade" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas</SelectItem>
{uniqueSpecialties.map(specialty => (
<SelectItem key={specialty} value={specialty}>{specialty}</SelectItem>
{uniqueSpecialties.map((specialty) => (
<SelectItem key={specialty} value={specialty}>
{specialty}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 w-full md:w-auto">
<span className="text-sm font-medium text-foreground">
Status
</span>
<span className="text-sm font-medium text-foreground">Status</span>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[160px] sm:w-[180px]">
<SelectValue placeholder="Status" />
@ -240,12 +222,8 @@ export default function DoctorsPage() {
</Select>
</div>
<div className="flex items-center gap-2 w-full md:w-auto">
<span className="text-sm font-medium text-foreground">
Itens por página
</span>
<Select
onValueChange={handleItemsPerPageChange}
defaultValue={String(itemsPerPage)}>
<span className="text-sm font-medium text-foreground">Itens por página</span>
<Select onValueChange={handleItemsPerPageChange} defaultValue={String(itemsPerPage)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Itens por pág." />
</SelectTrigger>
@ -262,9 +240,8 @@ export default function DoctorsPage() {
</Button>
</div>
{/* Tabela de Médicos */}
<div className="bg-white rounded-lg border border-gray-200 shadow-md overflow-hidden">
{/* Tabela de Médicos (Visível em Telas Médias e Maiores) */}
<div className="bg-white rounded-lg border border-gray-200 shadow-md overflow-hidden hidden md:block">
{loading ? (
<div className="p-8 text-center text-gray-500">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-3 text-green-600" />
@ -287,8 +264,8 @@ export default function DoctorsPage() {
<th className="text-left p-2 md:p-4 font-medium text-gray-700">Nome</th>
<th className="text-left p-2 md:p-4 font-medium text-gray-700">CRM</th>
<th className="text-left p-2 md:p-4 font-medium text-gray-700">Especialidade</th>
<th className="text-left p-2 md:p-4 font-medium text-gray-700">Status</th>
<th className="text-left p-2 md:p-4 font-medium text-gray-700">Cidade/Estado</th>
<th className="text-left p-2 md:p-4 font-medium text-gray-700 hidden lg:table-cell">Status</th>
<th className="text-left p-2 md:p-4 font-medium text-gray-700 hidden xl:table-cell">Cidade/Estado</th>
<th className="text-right p-4 font-medium text-gray-700">Ações</th>
</tr>
</thead>
@ -305,7 +282,6 @@ export default function DoctorsPage() {
: "N/A"}
</td>
<td className="px-4 py-3 text-right">
{/* ===== INÍCIO DA ALTERAÇÃO ===== */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="text-blue-600 cursor-pointer inline-block">Ações</div>
@ -331,7 +307,6 @@ export default function DoctorsPage() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* ===== FIM DA ALTERAÇÃO ===== */}
</td>
</tr>
))}
@ -341,6 +316,61 @@ export default function DoctorsPage() {
)}
</div>
{/* Cards de Médicos (Visível Apenas em Telas Pequenas) */}
<div className="bg-white rounded-lg border border-gray-200 shadow-md p-4 block md:hidden">
{loading ? (
<div className="p-8 text-center text-gray-500">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-3 text-green-600" />
Carregando médicos...
</div>
) : error ? (
<div className="p-8 text-center text-red-600">{error}</div>
) : filteredDoctors.length === 0 ? (
<div className="p-8 text-center text-gray-500">
{doctors.length === 0
? <>Nenhum médico cadastrado. <Link href="/manager/home/novo" className="text-green-600 hover:underline">Adicione um novo</Link>.</>
: "Nenhum médico encontrado com os filtros aplicados."
}
</div>
) : (
<div className="space-y-4">
{currentItems.map((doctor) => (
<div key={doctor.id} className="bg-white-50 rounded-lg p-4 flex justify-between items-center border border-white-200">
<div>
<div className="font-semibold text-gray-900">{doctor.full_name}</div>
<div className="text-sm text-gray-600">{doctor.specialty}</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="text-blue-600 cursor-pointer inline-block">Ações</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openDetailsDialog(doctor)}>
<Eye className="mr-2 h-4 w-4" />
Ver detalhes
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/manager/home/${doctor.id}/editar`}>
<Edit className="mr-2 h-4 w-4" />
Editar
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Calendar className="mr-2 h-4 w-4" />
Marcar consulta
</DropdownMenuItem>
<DropdownMenuItem className="text-red-600" onClick={() => openDeleteDialog(doctor.id)}>
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
</div>
{/* Paginação */}
{totalPages > 1 && (
<div className="flex flex-wrap justify-center items-center gap-2 mt-4 p-4 bg-white rounded-lg border border-gray-200 shadow-md">
@ -356,10 +386,11 @@ export default function DoctorsPage() {
<button
key={number}
onClick={() => paginate(number)}
className={`px-4 py-2 rounded-md font-medium transition-colors text-sm border border-gray-300 ${currentPage === number
? "bg-green-600 text-white shadow-md border-green-600"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
className={`px-4 py-2 rounded-md font-medium transition-colors text-sm border border-gray-300 ${
currentPage === number
? "bg-green-600 text-white shadow-md border-green-600"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{number}
</button>
@ -380,9 +411,7 @@ export default function DoctorsPage() {
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirma a exclusão?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação é irreversível e excluirá permanentemente o registro deste médico.
</AlertDialogDescription>
<AlertDialogDescription>Esta ação é irreversível e excluirá permanentemente o registro deste médico.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancelar</AlertDialogCancel>
@ -403,25 +432,41 @@ export default function DoctorsPage() {
<div className="space-y-3 text-left">
<h3 className="font-semibold mt-2">Informações Principais</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-y-2 gap-x-4 text-sm">
<div><strong>CRM:</strong> {doctorDetails.crm}</div>
<div><strong>Especialidade:</strong> {doctorDetails.especialidade}</div>
<div><strong>Celular:</strong> {doctorDetails.contato.celular || 'N/A'}</div>
<div><strong>Localização:</strong> {`${doctorDetails.endereco.cidade || 'N/A'}/${doctorDetails.endereco.estado || 'N/A'}`}</div>
<div>
<strong>CRM:</strong> {doctorDetails.crm}
</div>
<div>
<strong>Especialidade:</strong> {doctorDetails.especialidade}
</div>
<div>
<strong>Celular:</strong> {doctorDetails.contato.celular || "N/A"}
</div>
<div>
<strong>Localização:</strong> {`${doctorDetails.endereco.cidade || "N/A"}/${doctorDetails.endereco.estado || "N/A"}`}
</div>
</div>
<h3 className="font-semibold mt-4">Atendimento e Convênio</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-y-2 gap-x-4 text-sm">
<div><strong>Convênio:</strong> {doctorDetails.convenio || 'N/A'}</div>
<div><strong>VIP:</strong> {doctorDetails.vip ? "Sim" : "Não"}</div>
<div><strong>Status:</strong> {doctorDetails.status || 'N/A'}</div>
<div><strong>Último atendimento:</strong> {doctorDetails.ultimo_atendimento || 'N/A'}</div>
<div><strong>Próximo atendimento:</strong> {doctorDetails.proximo_atendimento || 'N/A'}</div>
<div>
<strong>Convênio:</strong> {doctorDetails.convenio || "N/A"}
</div>
<div>
<strong>VIP:</strong> {doctorDetails.vip ? "Sim" : "Não"}
</div>
<div>
<strong>Status:</strong> {doctorDetails.status || "N/A"}
</div>
<div>
<strong>Último atendimento:</strong> {doctorDetails.ultimo_atendimento || "N/A"}
</div>
<div>
<strong>Próximo atendimento:</strong> {doctorDetails.proximo_atendimento || "N/A"}
</div>
</div>
</div>
)}
{doctorDetails === null && !loading && (
<div className="text-red-600">Detalhes não disponíveis.</div>
)}
{doctorDetails === null && !loading && <div className="text-red-600">Detalhes não disponíveis.</div>}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@ -430,6 +475,6 @@ export default function DoctorsPage() {
</AlertDialogContent>
</AlertDialog>
</div>
</ManagerLayout>
</Sidebar>
);
}

View File

@ -13,9 +13,8 @@ import { Checkbox } from "@/components/ui/checkbox";
import { ArrowLeft, Save, Trash2, Paperclip, Upload } from "lucide-react";
import Link from "next/link";
import { useToast } from "@/hooks/use-toast";
import SecretaryLayout from "@/components/secretary-layout";
import { patientsService } from "@/services/patientsApi.mjs";
import { json } from "stream/consumers";
import Sidebar from "@/components/Sidebar";
export default function EditarPacientePage() {
const router = useRouter();
@ -247,7 +246,7 @@ export default function EditarPacientePage() {
};
return (
<SecretaryLayout>
<Sidebar>
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/manager/pacientes">
@ -677,6 +676,6 @@ export default function EditarPacientePage() {
</div>
</form>
</div>
</SecretaryLayout>
</Sidebar>
);
}

View File

@ -1,4 +1,3 @@
"use client";
import { useState, useEffect, useCallback } from "react";
@ -6,10 +5,10 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Edit, Trash2, Eye, Calendar, Filter, Loader2 } from "lucide-react";
import { Edit, Trash2, Eye, Calendar, Filter, Loader2 } from "lucide-react";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { patientsService } from "@/services/patientsApi.mjs";
import ManagerLayout from "@/components/manager-layout";
import Sidebar from "@/components/Sidebar";
// Defina o tamanho da página.
const PAGE_SIZE = 5;
@ -145,7 +144,7 @@ export default function PacientesPage() {
};
return (
<ManagerLayout>
<Sidebar>
<div className="space-y-6 px-2 sm:px-4 md:px-8">
{/* Header (Responsividade OK) */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
@ -156,6 +155,7 @@ export default function PacientesPage() {
</div>
{/* Bloco de Filtros (Responsividade APLICADA) */}
{/* Adicionado flex-wrap para permitir que os itens quebrem para a linha de baixo */}
<div className="flex flex-wrap items-center gap-4 bg-card p-4 rounded-lg border border-border">
<Filter className="w-5 h-5 text-gray-400" />
@ -165,14 +165,15 @@ export default function PacientesPage() {
placeholder="Buscar por nome ou telefone..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full sm:flex-grow sm:min-w-[150px] p-2 border rounded-md text-sm"
// w-full no mobile, depois flex-grow para ocupar o espaço disponível
className="w-full sm:flex-grow sm:max-w-[300px] p-2 border rounded-md text-sm"
/>
{/* Convênio - Ocupa metade da linha no mobile */}
<div className="flex items-center gap-2 w-[calc(50%-8px)] sm:w-auto sm:flex-grow sm:max-w-[200px]">
{/* Convênio - Ocupa a largura total em telas pequenas, depois se ajusta */}
<div className="flex items-center gap-2 w-full sm:w-auto sm:flex-grow sm:max-w-[200px]">
<span className="text-sm font-medium text-foreground whitespace-nowrap hidden md:block">Convênio</span>
<Select value={convenioFilter} onValueChange={setConvenioFilter}>
<SelectTrigger className="w-full sm:w-40">
<SelectTrigger className="w-full sm:w-40"> {/* w-full para mobile, w-40 para sm+ */}
<SelectValue placeholder="Convênio" />
</SelectTrigger>
<SelectContent>
@ -185,11 +186,11 @@ export default function PacientesPage() {
</Select>
</div>
{/* VIP - Ocupa a outra metade da linha no mobile */}
<div className="flex items-center gap-2 w-[calc(50%-8px)] sm:w-auto sm:flex-grow sm:max-w-[150px]">
{/* VIP - Ocupa a largura total em telas pequenas, depois se ajusta */}
<div className="flex items-center gap-2 w-full sm:w-auto sm:flex-grow sm:max-w-[150px]">
<span className="text-sm font-medium text-foreground whitespace-nowrap hidden md:block">VIP</span>
<Select value={vipFilter} onValueChange={setVipFilter}>
<SelectTrigger className="w-full sm:w-32">
<SelectTrigger className="w-full sm:w-32"> {/* w-full para mobile, w-32 para sm+ */}
<SelectValue placeholder="VIP" />
</SelectTrigger>
<SelectContent>
@ -200,16 +201,17 @@ export default function PacientesPage() {
</Select>
</div>
{/* Aniversariantes - Vai para a linha de baixo no mobile, ocupando 100% */}
{/* Aniversariantes - Ocupa 100% no mobile, e se alinha à direita no md+ */}
<Button variant="outline" className="w-full md:w-auto md:ml-auto">
<Calendar className="w-4 h-4 mr-2" />
Aniversariantes
</Button>
</div>
{/* Tabela (Responsividade APLICADA) */}
<div className="bg-white rounded-lg border border-gray-200 shadow-md">
<div className="overflow-x-auto">
{/* --- SEÇÃO DE TABELA (VISÍVEL EM TELAS MAIORES OU IGUAIS A MD) --- */}
{/* Garantir que a tabela se esconda em telas menores e apareça em MD+ */}
<div className="bg-white rounded-lg border border-gray-200 shadow-md hidden md:block">
<div className="overflow-x-auto"> {/* Permite rolagem horizontal se a tabela for muito larga */}
{error ? (
<div className="p-6 text-red-600">{`Erro ao carregar pacientes: ${error}`}</div>
) : loading ? (
@ -217,18 +219,14 @@ export default function PacientesPage() {
<Loader2 className="w-6 h-6 mr-2 animate-spin text-green-600" /> Carregando pacientes...
</div>
) : (
// min-w ajustado para responsividade
<table className="w-full min-w-[650px] md:min-w-[900px]">
<table className="w-full min-w-[650px]"> {/* min-w para evitar que a tabela se contraia demais */}
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left p-4 font-medium text-gray-700 w-[20%]">Nome</th>
{/* Coluna oculta em telas muito pequenas */}
{/* Ajustes de visibilidade de colunas para diferentes breakpoints */}
<th className="text-left p-4 font-medium text-gray-700 w-[15%] hidden sm:table-cell">Telefone</th>
{/* Coluna oculta em telas pequenas e muito pequenas */}
<th className="text-left p-4 font-medium text-gray-700 w-[15%] hidden md:table-cell">Cidade / Estado</th>
{/* Coluna oculta em telas muito pequenas */}
<th className="text-left p-4 font-medium text-gray-700 w-[15%] hidden sm:table-cell">Convênio</th>
{/* Colunas ocultas em telas médias, pequenas e muito pequenas */}
<th className="text-left p-4 font-medium text-gray-700 w-[15%] hidden lg:table-cell">Último atendimento</th>
<th className="text-left p-4 font-medium text-gray-700 w-[15%] hidden lg:table-cell">Próximo atendimento</th>
<th className="text-left p-4 font-medium text-gray-700 w-[5%]">Ações</th>
@ -257,7 +255,6 @@ export default function PacientesPage() {
</span>
</div>
</td>
{/* Aplicação das classes de visibilidade */}
<td className="p-4 text-gray-600 hidden sm:table-cell">{patient.telefone}</td>
<td className="p-4 text-gray-600 hidden md:table-cell">{`${patient.cidade} / ${patient.estado}`}</td>
<td className="p-4 text-gray-600 hidden sm:table-cell">{patient.convenio}</td>
@ -300,53 +297,109 @@ export default function PacientesPage() {
</table>
)}
</div>
</div>
{/* Paginação */}
{totalPages > 1 && !loading && (
<div className="flex flex-col sm:flex-row items-center justify-center p-4 border-t border-gray-200">
{/* Renderização dos botões de número de página (Limitando a 5) */}
<div className="flex space-x-2"> {/* Increased space-x for more separation */}
{/* Botão Anterior */}
<Button
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
disabled={page === 1}
variant="outline"
size="lg" // Changed to "lg" for larger buttons
>
&lt; Anterior
</Button>
{/* --- SEÇÃO DE CARDS (VISÍVEL APENAS EM TELAS MENORES QUE MD) --- */}
{/* Garantir que os cards apareçam em telas menores e se escondam em MD+ */}
<div className="bg-white rounded-lg border border-gray-200 shadow-md p-4 block md:hidden">
{error ? (
<div className="p-6 text-red-600">{`Erro ao carregar pacientes: ${error}`}</div>
) : loading ? (
<div className="p-6 text-center text-gray-500 flex items-center justify-center">
<Loader2 className="w-6 h-6 mr-2 animate-spin text-green-600" /> Carregando pacientes...
</div>
) : filteredPatients.length === 0 ? (
<div className="p-8 text-center text-gray-500">
{allPatients.length === 0 ? "Nenhum paciente cadastrado" : "Nenhum paciente encontrado com os filtros aplicados"}
</div>
) : (
<div className="space-y-4">
{currentPatients.map((patient) => (
<div key={patient.id} className="bg-gray-50 rounded-lg p-4 flex flex-col sm:flex-row justify-between items-start sm:items-center border border-gray-200">
<div className="flex-grow mb-2 sm:mb-0">
<div className="font-semibold text-lg text-gray-900 flex items-center">
{patient.nome}
{patient.vip && (
<span className="ml-2 px-2 py-0.5 text-xs font-semibold text-purple-600 bg-purple-100 rounded-full">VIP</span>
)}
</div>
<div className="text-sm text-gray-600">Telefone: {patient.telefone}</div>
<div className="text-sm text-gray-600">Convênio: {patient.convenio}</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="w-full"><Button variant="outline" className="w-full">Ações</Button></div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openDetailsDialog(String(patient.id))}>
<Eye className="w-4 h-4 mr-2" />
Ver detalhes
</DropdownMenuItem>
{Array.from({ length: totalPages }, (_, index) => index + 1)
.slice(Math.max(0, page - 3), Math.min(totalPages, page + 2))
.map((pageNumber) => (
<Button
key={pageNumber}
onClick={() => setPage(pageNumber)}
variant={pageNumber === page ? "default" : "outline"}
size="lg" // Changed to "lg" for larger buttons
className={pageNumber === page ? "bg-green-600 hover:bg-green-700 text-white" : "text-gray-700"}
>
{pageNumber}
</Button>
))}
<DropdownMenuItem asChild>
<Link href={`/secretary/pacientes/${patient.id}/editar`} className="flex items-center w-full">
<Edit className="w-4 h-4 mr-2" />
Editar
</Link>
</DropdownMenuItem>
{/* Botão Próximo */}
<Button
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
disabled={page === totalPages}
variant="outline"
size="lg" // Changed to "lg" for larger buttons
>
Próximo &gt;
</Button>
</div>
<DropdownMenuItem>
<Calendar className="w-4 h-4 mr-2" />
Marcar consulta
</DropdownMenuItem>
<DropdownMenuItem className="text-red-600" onClick={() => openDeleteDialog(String(patient.id))}>
<Trash2 className="w-4 h-4 mr-2" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
</div>
{/* Paginação */}
{totalPages > 1 && !loading && (
<div className="flex flex-col sm:flex-row items-center justify-center p-4 border-t border-gray-200">
<div className="flex space-x-2 flex-wrap justify-center"> {/* Adicionado flex-wrap e justify-center para botões da paginação */}
<Button
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
disabled={page === 1}
variant="outline"
size="lg"
>
&lt; Anterior
</Button>
{Array.from({ length: totalPages }, (_, index) => index + 1)
.slice(Math.max(0, page - 3), Math.min(totalPages, page + 2))
.map((pageNumber) => (
<Button
key={pageNumber}
onClick={() => setPage(pageNumber)}
variant={pageNumber === page ? "default" : "outline"}
size="lg"
className={pageNumber === page ? "bg-green-600 hover:bg-green-700 text-white" : "text-gray-700"}
>
{pageNumber}
</Button>
))}
<Button
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
disabled={page === totalPages}
variant="outline"
size="lg"
>
Próximo &gt;
</Button>
</div>
</div>
)}
{/* AlertDialogs (Permanecem os mesmos) */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
{/* ... (AlertDialog de Exclusão) ... */}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
@ -362,7 +415,6 @@ export default function PacientesPage() {
</AlertDialog>
<AlertDialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
{/* ... (AlertDialog de Detalhes) ... */}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Detalhes do Paciente</AlertDialogTitle>
@ -376,7 +428,7 @@ export default function PacientesPage() {
<div className="text-red-600 p-4">{patientDetails.error}</div>
) : (
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<p className="font-semibold">Nome Completo</p>
<p>{patientDetails.full_name}</p>
@ -412,7 +464,7 @@ export default function PacientesPage() {
</div>
<div className="border-t pt-4 mt-4">
<h3 className="font-semibold mb-2">Endereço</h3>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<p className="font-semibold">Rua</p>
<p>{`${patientDetails.street}, ${patientDetails.number}`}</p>
@ -449,6 +501,6 @@ export default function PacientesPage() {
</AlertDialogContent>
</AlertDialog>
</div>
</ManagerLayout>
</Sidebar>
);
}

View File

@ -8,7 +8,7 @@ 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, ArrowLeft } from "lucide-react"
import ManagerLayout from "@/components/manager-layout"
import Sidebar from "@/components/Sidebar"
// Mock user service for demonstration. Replace with your actual API service.
const usersService = {
@ -155,17 +155,17 @@ export default function EditarUsuarioPage() {
if (loading) {
return (
<ManagerLayout>
<Sidebar>
<div className="flex justify-center items-center h-full w-full py-16">
<Loader2 className="w-8 h-8 animate-spin text-green-600" />
<p className="ml-2 text-gray-600">Carregando dados do usuário...</p>
</div>
</ManagerLayout>
</Sidebar>
);
}
return (
<ManagerLayout>
<Sidebar>
<div className="w-full max-w-2xl mx-auto space-y-6 p-4 md:p-8">
<div className="flex items-center justify-between">
<div>
@ -274,6 +274,6 @@ export default function EditarUsuarioPage() {
</div>
</form>
</div>
</ManagerLayout>
</Sidebar>
);
}

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 ManagerLayout from "@/components/manager-layout";
import { Save, Loader2 } from "lucide-react";
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
import Sidebar from "@/components/Sidebar";
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);
}
@ -145,7 +135,7 @@ export default function NovoUsuarioPage() {
const isMedico = formData.papel === "medico";
return (
<ManagerLayout>
<Sidebar>
<div className="w-full h-full p-4 md:p-8 flex justify-center items-start">
<div className="w-full max-w-screen-lg space-y-8">
<div className="flex items-center justify-between border-b pb-4">
@ -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">
@ -250,6 +236,6 @@ export default function NovoUsuarioPage() {
</form>
</div>
</div>
</ManagerLayout>
</Sidebar>
);
}
}

View File

@ -1,29 +1,14 @@
// app/manager/usuario/page.tsx
"use client";
import React, { useEffect, useState, useCallback } from "react";
import ManagerLayout from "@/components/manager-layout";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Eye, Filter, Loader2 } from "lucide-react";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { api, login } from "services/api.mjs";
import { usersService } from "services/usersApi.mjs";
import Sidebar from "@/components/Sidebar";
interface FlatUser {
id: string;
@ -49,17 +34,15 @@ export default function UsersPage() {
const [userDetails, setUserDetails] = useState<UserInfoResponse | null>(
null
);
// Ajuste 1: Definir 'all' como valor inicial para garantir que todos os usuários sejam exibidos por padrão.
const [selectedRole, setSelectedRole] = useState<string>("all");
// --- Lógica de Paginação INÍCIO ---
const [itemsPerPage, setItemsPerPage] = useState(10);
const [currentPage, setCurrentPage] = useState(1);
// Lógica para mudar itens por página, resetando para a página 1
const handleItemsPerPageChange = (value: string) => {
setItemsPerPage(Number(value));
setCurrentPage(1); // Resetar para a primeira página
setCurrentPage(1);
};
// --- Lógica de Paginação FIM ---
@ -95,8 +78,7 @@ export default function UsersPage() {
});
setUsers(mapped);
setCurrentPage(1); // Resetar a página após carregar
console.log("[fetchUsers] mapped count:", mapped.length);
setCurrentPage(1);
} catch (err: any) {
console.error("Erro ao buscar usuários:", err);
setError("Não foi possível carregar os usuários. Veja console.");
@ -123,9 +105,7 @@ export default function UsersPage() {
setUserDetails(null);
try {
console.log("[openDetailsDialog] user_id:", flatUser.user_id);
const data = await usersService.full_data(flatUser.user_id);
console.log("[openDetailsDialog] full_data returned:", data);
setUserDetails(data);
} catch (err: any) {
console.error("Erro ao carregar detalhes:", err);
@ -138,23 +118,19 @@ export default function UsersPage() {
}
};
// 1. Filtragem
const filteredUsers =
selectedRole && selectedRole !== "all"
? users.filter((u) => u.role === selectedRole)
: users;
// 2. Paginação (aplicada sobre a lista filtrada)
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredUsers.slice(indexOfFirstItem, indexOfLastItem);
// Função para mudar de página
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
const totalPages = Math.ceil(filteredUsers.length / itemsPerPage);
// --- Funções e Lógica de Navegação ADICIONADAS ---
const goToPrevPage = () => {
setCurrentPage((prev) => Math.max(1, prev - 1));
};
@ -163,15 +139,13 @@ export default function UsersPage() {
setCurrentPage((prev) => Math.min(totalPages, prev + 1));
};
// Lógica para gerar os números das páginas visíveis
const getVisiblePageNumbers = (totalPages: number, currentPage: number) => {
const pages: number[] = [];
const maxVisiblePages = 5; // Número máximo de botões de página a serem exibidos (ex: 2, 3, 4, 5, 6)
const maxVisiblePages = 5;
const halfRange = Math.floor(maxVisiblePages / 2);
let startPage = Math.max(1, currentPage - halfRange);
let endPage = Math.min(totalPages, currentPage + halfRange);
// Ajusta para manter o número fixo de botões quando nos limites
if (endPage - startPage + 1 < maxVisiblePages) {
if (endPage === totalPages) {
startPage = Math.max(1, totalPages - maxVisiblePages + 1);
@ -188,11 +162,9 @@ export default function UsersPage() {
};
const visiblePageNumbers = getVisiblePageNumbers(totalPages, currentPage);
// --- Fim das Funções e Lógica de Navegação ADICIONADAS ---
return (
<ManagerLayout>
<Sidebar>
<div className="space-y-6 px-2 sm:px-4 md:px-8">
{/* Header */}
@ -213,17 +185,17 @@ export default function UsersPage() {
{/* Select de Filtro por Papel - Ajustado para resetar a página */}
<div className="flex items-center gap-2 w-full md:w-auto">
<span className="text-sm font-medium text-foreground">
<span className="text-sm font-medium text-foreground whitespace-nowrap">
Filtrar por papel
</span>
<Select
onValueChange={(value) => {
setSelectedRole(value);
setCurrentPage(1); // Resetar para a primeira página ao mudar o filtro
setCurrentPage(1);
}}
value={selectedRole}>
<SelectTrigger className="w-[180px]">
<SelectTrigger className="w-full sm:w-[180px]"> {/* w-full para mobile, w-[180px] para sm+ */}
<SelectValue placeholder="Filtrar por papel" />
</SelectTrigger>
<SelectContent>
@ -239,14 +211,14 @@ export default function UsersPage() {
{/* Select de Itens por Página */}
<div className="flex items-center gap-2 w-full md:w-auto">
<span className="text-sm font-medium text-foreground">
<span className="text-sm font-medium text-foreground whitespace-nowrap">
Itens por página
</span>
<Select
onValueChange={handleItemsPerPageChange}
defaultValue={String(itemsPerPage)}
>
<SelectTrigger className="w-[140px]">
<SelectTrigger className="w-full sm:w-[140px]"> {/* w-full para mobile, w-[140px] para sm+ */}
<SelectValue placeholder="Itens por pág." />
</SelectTrigger>
<SelectContent>
@ -263,7 +235,7 @@ export default function UsersPage() {
</div>
{/* Fim do Filtro e Itens por Página */}
{/* Tabela */}
{/* Tabela/Lista */}
<div className="bg-white rounded-lg border border-gray-200 shadow-md overflow-x-auto">
{loading ? (
<div className="p-8 text-center text-gray-500">
@ -278,10 +250,10 @@ export default function UsersPage() {
</div>
) : (
<>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 hidden md:table-header-group">
{/* Tabela para Telas Médias e Grandes */}
<table className="min-w-full divide-y divide-gray-200 hidden md:table">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nome</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">E-mail</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Telefone</th>
@ -290,15 +262,8 @@ export default function UsersPage() {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{/* Usando currentItems para a paginação */}
{currentItems.map((u) => (
<tr
key={u.id}
className="flex flex-col md:table-row md:flex-row border-b md:border-0 hover:bg-gray-50"
>
<td className="px-6 py-4 text-sm text-gray-500 break-all md:whitespace-nowrap">
{u.id}
</td>
<tr key={u.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm text-gray-900">
{u.full_name}
</td>
@ -326,7 +291,33 @@ export default function UsersPage() {
</tbody>
</table>
{/* Paginação ATUALIZADA */}
{/* Layout em Cards/Lista para Telas Pequenas */}
<div className="md:hidden divide-y divide-gray-200">
{currentItems.map((u) => (
<div key={u.id} className="flex items-center justify-between p-4 hover:bg-gray-50">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
{u.full_name || "—"}
</div>
<div className="text-sm text-gray-500 capitalize">
{u.role || "—"}
</div>
</div>
<div className="ml-4 flex-shrink-0">
<Button
variant="outline"
size="icon"
onClick={() => openDetailsDialog(u)}
title="Visualizar"
>
<Eye className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
{/* Paginação */}
{totalPages > 1 && (
<div className="flex flex-wrap justify-center items-center gap-2 mt-4 p-4 border-t border-gray-200">
@ -364,7 +355,6 @@ export default function UsersPage() {
</div>
)}
{/* Fim da Paginação ATUALIZADA */}
</>
)}
</div>
@ -401,7 +391,6 @@ export default function UsersPage() {
<strong>Roles:</strong>{" "}
{userDetails.roles?.join(", ")}
</div>
{/* Melhoria na visualização das permissões no modal */}
<div className="pt-2">
<strong className="block mb-1">Permissões:</strong>
<ul className="list-disc list-inside space-y-0.5 text-sm">
@ -424,6 +413,6 @@ export default function UsersPage() {
</AlertDialogContent>
</AlertDialog>
</div>
</ManagerLayout>
</Sidebar>
);
}

View File

@ -1,112 +1,184 @@
"use client";
import Link from "next/link"
import { Button } from "@/components/ui/button"
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useState } from "react";
export default function InicialPage() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<div className="min-h-screen flex flex-col bg-background">
{}
<div className="bg-primary text-primary-foreground text-sm py-2 px-6 flex justify-between">
<span> Horário: 08h00 - 21h00</span>
<span> Email: contato@mediconnect.com</span>
{/* Barra superior de informações */}
<div className="bg-primary text-primary-foreground text-sm py-2 px-4 md:px-6 flex justify-between items-center">
<span className="hidden sm:inline">Horário: 08h00 - 21h00</span>
<span>Email: contato@mediconnect.com</span>
</div>
{}
<header className="bg-card shadow-md py-4 px-6 flex justify-between items-center">
<h1 className="text-2xl font-bold text-primary">MediConnect</h1>
<nav className="flex space-x-6 text-muted-foreground font-medium">
<Link href="/cadastro" className="hover:text-primary"> Home</Link>
<a href="#about" className="hover:text-primary">Sobre</a>
<a href="#departments" className="hover:text-primary">Departamentos</a>
<a href="#doctors" className="hover:text-primary">Médicos</a>
<a href="#contact" className="hover:text-primary">Contato</a>
{/* Header principal - Com Logo REAL */}
<header className="bg-card shadow-md py-4 px-4 md:px-6 flex justify-between items-center relative">
{/* Agrupamento do Logo e Nome do Site */}
<a href="#home" className="flex items-center space-x-1 cursor-pointer">
{/* 1. IMAGEM/LOGO REAL: Referenciando o arquivo placeholder-logo.png na pasta public */}
<img
src="/android-chrome-512x512.png" // O caminho se inicia a partir da pasta 'public'
alt="Logo MediConnect"
className="w-14 h-14 object-contain" // ALTERADO: Aumentado para w-14 h-14
/>
{/* 2. NOME DO SITE */}
<h1 className="text-2xl font-bold text-primary">MediConnect</h1>
</a>
{/* Botão do menu hambúrguer para telas menores */}
<div className="md:hidden flex items-center space-x-4">
{/* O botão de login agora estará sempre aqui, fora do menu */}
<Link href="/login">
<Button
variant="outline"
className="rounded-full px-4 py-2 text-sm border-2 transition cursor-pointer"
>
Login
</Button>
</Link>
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="text-primary-foreground focus:outline-none"
>
<svg
className="w-6 h-6 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
{isMenuOpen ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
></path>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
></path>
)}
</svg>
</button>
</div>
{/* Navegação principal */}
<nav
className={`${
isMenuOpen ? "block" : "hidden"
} absolute top-[76px] left-0 w-full bg-card shadow-md py-4 md:relative md:top-auto md:left-auto md:w-auto md:block md:bg-transparent md:shadow-none z-10`}
>
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-6 text-muted-foreground font-medium items-center">
<Link href="#home" className="hover:text-primary">
Home
</Link>
<a href="#about" className="hover:text-primary">
Sobre
</a>
<a href="#departments" className="hover:text-primary">
Departamentos
</a>
<a href="#doctors" className="hover:text-primary">
Médicos
</a>
<a href="#contact" className="hover:text-primary">
Contato
</a>
</div>
</nav>
<div className="flex space-x-4">
{}
<Link href="/login">
<Button
variant="outline"
className="rounded-full px-6 py-2 border-2 transition cursor-pointer"
>
Login
</Button>
</Link>
{/* Botão de Login para telas maiores (md e acima) */}
<div className="hidden md:flex space-x-4">
<Link href="/login">
<Button
variant="outline"
className="rounded-full px-6 py-2 border-2 transition cursor-pointer"
>
Login
</Button>
</Link>
</div>
</header>
{}
<section className="flex flex-col md:flex-row items-center justify-between px-10 md:px-20 py-16 bg-background">
<div className="max-w-lg">
<h2 className="text-muted-foreground uppercase text-sm">Bem-vindo à Saúde Digital</h2>
<h1 className="text-4xl font-extrabold text-foreground leading-tight mt-2">
{/* Seção principal de destaque */}
<section className="flex flex-col md:flex-row items-center justify-between px-6 md:px-10 lg:px-20 py-16 bg-background text-center md:text-left">
<div className="max-w-lg mx-auto md:mx-0">
<h2 className="text-muted-foreground uppercase text-sm">
Bem-vindo à Saúde Digital
</h2>
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-foreground leading-tight mt-2">
Soluções Médicas <br /> & Cuidados com a Saúde
</h1>
<p className="text-muted-foreground mt-4">
Excelência em saúde mais de 25 anos. Atendimento médicio com qualidade,segurança e carinho.
<p className="text-muted-foreground mt-4 text-sm sm:text-base">
Excelência em saúde mais de 25 anos. Atendimento médico com
qualidade, segurança e carinho.
</p>
<div className="mt-6 flex space-x-4">
<Button>
Nossos Serviços
</Button>
<Button
variant="secondary"
>
Saiba Mais
</Button>
<div className="mt-6 flex flex-col sm:flex-row space-y-4 sm:space-y-0 sm:space-x-4 justify-center md:justify-start">
<Button>Nossos Serviços</Button>
<Button variant="secondary">Saiba Mais</Button>
</div>
</div>
<div className="mt-10 md:mt-0">
<div className="mt-10 md:mt-0 flex justify-center">
<img
src="https://t4.ftcdn.net/jpg/03/20/52/31/360_F_320523164_tx7Rdd7I2XDTvvKfz2oRuRpKOPE5z0ni.jpg"
alt="Médico"
className="w-80"
className="w-60 sm:w-80 lg:w-96 h-auto object-cover rounded-lg shadow-lg"
/>
</div>
</section>
{}
<section className="py-16 px-10 md:px-20 bg-card">
<h2 className="text-center text-3xl font-bold text-foreground">Cuidados completos para a sua saúde</h2>
<p className="text-center text-muted-foreground mt-2">Serviços médicos que oferecemos</p>
{/* Seção de serviços */}
<section className="py-16 px-6 md:px-10 lg:px-20 bg-card">
<h2 className="text-center text-2xl sm:text-3xl font-bold text-foreground">
Cuidados completos para a sua saúde
</h2>
<p className="text-center text-muted-foreground mt-2 text-sm sm:text-base">
Serviços médicos que oferecemos
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mt-10">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mt-10 max-w-5xl mx-auto">
<div className="p-6 bg-background rounded-xl shadow hover:shadow-lg transition">
<h3 className="text-xl font-semibold text-primary">Clínica Geral</h3>
<p className="text-muted-foreground mt-2">
Seu primeiro passo para o cuidado. Atendimento focado na prevenção e no diagnóstico inicial.
<h3 className="text-xl font-semibold text-primary">
Clínica Geral
</h3>
<p className="text-muted-foreground mt-2 text-sm">
Seu primeiro passo para o cuidado. Atendimento focado na prevenção
e no diagnóstico inicial.
</p>
<Button className="mt-4">
Agendar
</Button>
<Button className="mt-4 w-full">Agendar</Button>
</div>
<div className="p-6 bg-background rounded-xl shadow hover:shadow-lg transition">
<h3 className="text-xl font-semibold text-primary">Pediatria</h3>
<p className="text-muted-foreground mt-2">
Cuidado gentil e especializado para garantir a saúde e o desenvolvimeto de crianças e adolescentes.
<p className="text-muted-foreground mt-2 text-sm">
Cuidado gentil e especializado para garantir a saúde e o
desenvolvimento de crianças e adolescentes.
</p>
<Button className="mt-4">
Agendar
</Button>
<Button className="mt-4 w-full">Agendar</Button>
</div>
<div className="p-6 bg-background rounded-xl shadow hover:shadow-lg transition">
<h3 className="text-xl font-semibold text-primary">Exames</h3>
<p className="text-muted-foreground mt-2">
Resultados rápidos e precisos em exames laboratoriais e de imagem essenciais para seu diagnóstico.
<p className="text-muted-foreground mt-2 text-sm">
Resultados rápidos e precisos em exames laboratoriais e de imagem
essenciais para seu diagnóstico.
</p>
<Button className="mt-4">
Agendar
</Button>
<Button className="mt-4 w-full">Agendar</Button>
</div>
</div>
</section>
{}
<footer className="bg-primary text-primary-foreground py-6 text-center">
{/* Footer */}
<footer className="bg-primary text-primary-foreground py-6 text-center text-sm">
<p>© 2025 MediConnect</p>
</footer>
</div>
);
}
}

View File

@ -1,7 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import PatientLayout from "@/components/patient-layout";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@ -10,6 +9,7 @@ import { toast } from "sonner";
import { appointmentsService } from "@/services/appointmentsApi.mjs";
import { usersService } from "@/services/usersApi.mjs";
import Sidebar from "@/components/Sidebar";
// Tipagem correta para o usuário
interface UserProfile {
@ -129,7 +129,7 @@ export default function PatientAppointmentsPage() {
};
return (
<PatientLayout>
<Sidebar>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
@ -185,6 +185,6 @@ export default function PatientAppointmentsPage() {
)}
</div>
</div>
</PatientLayout>
</Sidebar>
);
}

View File

@ -1,12 +1,12 @@
import PatientLayout from "@/components/patient-layout"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Calendar, Clock, User, Plus } from "lucide-react"
import Link from "next/link"
import Sidebar from "@/components/Sidebar"
export default function PatientDashboard() {
return (
<PatientLayout>
<Sidebar>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
@ -108,6 +108,6 @@ export default function PatientDashboard() {
</Card>
</div>
</div>
</PatientLayout>
</Sidebar>
)
}

View File

@ -1,155 +1,168 @@
"use client"
// ARQUIVO COMPLETO PARA: app/patient/profile/page.tsx
import { useState, useEffect } from "react"
import PatientLayout from "@/components/patient-layout"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { User, Mail, Phone, Calendar, FileText } from "lucide-react"
"use client";
interface PatientData {
name: string
email: string
phone: string
cpf: string
birthDate: string
address: string
import { useState, useEffect, useRef } from "react";
import Sidebar from "@/components/Sidebar"
import { useAuthLayout } from "@/hooks/useAuthLayout";
import { patientsService } from "@/services/patientsApi.mjs";
import { api } from "@/services/api.mjs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { User, Mail, Phone, Calendar, Upload } from "lucide-react";
import { toast } from "@/hooks/use-toast";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
interface PatientProfileData {
name: string;
email: string;
phone: string;
cpf: string;
birthDate: string;
cep: string;
street: string;
number: string;
city: string;
avatarFullUrl?: string;
}
export default function PatientProfile() {
const [patientData, setPatientData] = useState<PatientData>({
name: "",
email: "",
phone: "",
cpf: "",
birthDate: "",
address: "",
})
const [isEditing, setIsEditing] = useState(false)
const { user, isLoading: isAuthLoading } = useAuthLayout({ requiredRole: 'patient' });
const [patientData, setPatientData] = useState<PatientProfileData | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const data = localStorage.getItem("patientData")
if (data) {
setPatientData(JSON.parse(data))
if (user?.id) {
const fetchPatientDetails = async () => {
try {
const patientDetails = await patientsService.getById(user.id);
setPatientData({
name: patientDetails.full_name || user.name,
email: user.email,
phone: patientDetails.phone_mobile || '',
cpf: patientDetails.cpf || '',
birthDate: patientDetails.birth_date || '',
cep: patientDetails.cep || '',
street: patientDetails.street || '',
number: patientDetails.number || '',
city: patientDetails.city || '',
avatarFullUrl: user.avatarFullUrl,
});
} catch (error) {
console.error("Erro ao buscar detalhes do paciente:", error);
toast({ title: "Erro", description: "Não foi possível carregar seus dados completos.", variant: "destructive" });
}
};
fetchPatientDetails();
}
}, [])
}, [user]);
const handleSave = () => {
localStorage.setItem("patientData", JSON.stringify(patientData))
setIsEditing(false)
alert("Dados atualizados com sucesso!")
}
const handleInputChange = (field: keyof PatientProfileData, value: string) => {
setPatientData((prev) => (prev ? { ...prev, [field]: value } : null));
};
const handleInputChange = (field: keyof PatientData, value: string) => {
setPatientData((prev) => ({
...prev,
[field]: value,
}))
const handleSave = async () => {
if (!patientData || !user) return;
setIsSaving(true);
try {
const patientPayload = {
full_name: patientData.name,
cpf: patientData.cpf,
birth_date: patientData.birthDate,
phone_mobile: patientData.phone,
cep: patientData.cep,
street: patientData.street,
number: patientData.number,
city: patientData.city,
};
await patientsService.update(user.id, patientPayload);
toast({ title: "Sucesso!", description: "Seus dados foram atualizados." });
setIsEditing(false);
} catch (error) {
console.error("Erro ao salvar dados:", error);
toast({ title: "Erro", description: "Não foi possível salvar suas alterações.", variant: "destructive" });
} finally {
setIsSaving(false);
}
};
const handleAvatarClick = () => {
fileInputRef.current?.click();
};
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !user) return;
const fileExt = file.name.split('.').pop();
// *** A CORREÇÃO ESTÁ AQUI ***
// O caminho salvo no banco de dados não deve conter o nome do bucket.
const filePath = `${user.id}/avatar.${fileExt}`;
try {
await api.storage.upload('avatars', filePath, file);
await api.patch(`/rest/v1/profiles?id=eq.${user.id}`, { avatar_url: filePath });
const newFullUrl = `https://yuanqfswhberkoevtmfr.supabase.co/storage/v1/object/public/avatars/${filePath}?t=${new Date().getTime()}`;
setPatientData(prev => prev ? { ...prev, avatarFullUrl: newFullUrl } : null);
toast({ title: "Sucesso!", description: "Sua foto de perfil foi atualizada." });
} catch (error) {
console.error("Erro no upload do avatar:", error);
toast({ title: "Erro de Upload", description: "Não foi possível enviar sua foto.", variant: "destructive" });
}
};
if (isAuthLoading || !patientData) {
return <Sidebar><div>Carregando seus dados...</div></Sidebar>;
}
return (
<PatientLayout>
<Sidebar>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Meus Dados</h1>
<p className="text-gray-600">Gerencie suas informações pessoais</p>
</div>
<Button
onClick={() => (isEditing ? handleSave() : setIsEditing(true))}
variant={isEditing ? "default" : "outline"}
>
{isEditing ? "Salvar Alterações" : "Editar Dados"}
<Button onClick={() => (isEditing ? handleSave() : setIsEditing(true))} disabled={isSaving}>
{isEditing ? (isSaving ? "Salvando..." : "Salvar Alterações") : "Editar Dados"}
</Button>
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<User className="mr-2 h-5 w-5" />
Informações Pessoais
</CardTitle>
<CardDescription>Seus dados pessoais básicos</CardDescription>
</CardHeader>
<CardHeader><CardTitle className="flex items-center"><User className="mr-2 h-5 w-5" />Informações Pessoais</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Nome Completo</Label>
<Input
id="name"
value={patientData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
disabled={!isEditing}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cpf">CPF</Label>
<Input
id="cpf"
value={patientData.cpf}
onChange={(e) => handleInputChange("cpf", e.target.value)}
disabled={!isEditing}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="birthDate">Data de Nascimento</Label>
<Input
id="birthDate"
type="date"
value={patientData.birthDate}
onChange={(e) => handleInputChange("birthDate", e.target.value)}
disabled={!isEditing}
/>
<div><Label htmlFor="name">Nome Completo</Label><Input id="name" value={patientData.name} onChange={(e) => handleInputChange("name", e.target.value)} disabled={!isEditing} /></div>
<div><Label htmlFor="cpf">CPF</Label><Input id="cpf" value={patientData.cpf} onChange={(e) => handleInputChange("cpf", e.target.value)} disabled={!isEditing} /></div>
</div>
<div><Label htmlFor="birthDate">Data de Nascimento</Label><Input id="birthDate" type="date" value={patientData.birthDate} onChange={(e) => handleInputChange("birthDate", e.target.value)} disabled={!isEditing} /></div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Mail className="mr-2 h-5 w-5" />
Contato
</CardTitle>
<CardDescription>Informações de contato</CardDescription>
</CardHeader>
<CardHeader><CardTitle className="flex items-center"><Mail className="mr-2 h-5 w-5" />Contato e Endereço</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={patientData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
disabled={!isEditing}
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefone</Label>
<Input
id="phone"
value={patientData.phone}
onChange={(e) => handleInputChange("phone", e.target.value)}
disabled={!isEditing}
/>
</div>
<div><Label htmlFor="email">Email</Label><Input id="email" type="email" value={patientData.email} disabled /></div>
<div><Label htmlFor="phone">Telefone</Label><Input id="phone" value={patientData.phone} onChange={(e) => handleInputChange("phone", e.target.value)} disabled={!isEditing} /></div>
</div>
<div className="space-y-2">
<Label htmlFor="address">Endereço</Label>
<Textarea
id="address"
value={patientData.address}
onChange={(e) => handleInputChange("address", e.target.value)}
disabled={!isEditing}
rows={3}
/>
<div className="grid md:grid-cols-3 gap-4">
<div><Label htmlFor="cep">CEP</Label><Input id="cep" value={patientData.cep} onChange={(e) => handleInputChange("cep", e.target.value)} disabled={!isEditing} /></div>
<div className="md:col-span-2"><Label htmlFor="street">Rua / Logradouro</Label><Input id="street" value={patientData.street} onChange={(e) => handleInputChange("street", e.target.value)} disabled={!isEditing} /></div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div><Label htmlFor="number">Número</Label><Input id="number" value={patientData.number} onChange={(e) => handleInputChange("number", e.target.value)} disabled={!isEditing} /></div>
<div><Label htmlFor="city">Cidade</Label><Input id="city" value={patientData.city} onChange={(e) => handleInputChange("city", e.target.value)} disabled={!isEditing} /></div>
</div>
</CardContent>
</Card>
@ -157,66 +170,34 @@ export default function PatientProfile() {
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Resumo do Perfil</CardTitle>
</CardHeader>
<CardHeader><CardTitle>Resumo do Perfil</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-3">
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<User className="h-6 w-6 text-blue-600" />
<div className="relative">
<Avatar className="w-16 h-16 cursor-pointer" onClick={handleAvatarClick}>
<AvatarImage src={patientData.avatarFullUrl} />
<AvatarFallback className="text-2xl">{patientData.name.split(" ").map((n) => n[0]).join("")}</AvatarFallback>
</Avatar>
<div className="absolute bottom-0 right-0 bg-primary text-primary-foreground rounded-full p-1 cursor-pointer hover:bg-primary/80" onClick={handleAvatarClick}>
<Upload className="w-3 h-3" />
</div>
<input type="file" ref={fileInputRef} onChange={handleAvatarUpload} className="hidden" accept="image/png, image/jpeg" />
</div>
<div>
<p className="font-medium">{patientData.name}</p>
<p className="text-sm text-gray-500">Paciente</p>
</div>
</div>
<div className="space-y-3 pt-4 border-t">
<div className="flex items-center text-sm">
<Mail className="mr-2 h-4 w-4 text-gray-500" />
<span className="truncate">{patientData.email}</span>
</div>
<div className="flex items-center text-sm">
<Phone className="mr-2 h-4 w-4 text-gray-500" />
<span>{patientData.phone}</span>
</div>
<div className="flex items-center text-sm">
<Calendar className="mr-2 h-4 w-4 text-gray-500" />
<span>
{patientData.birthDate
? new Date(patientData.birthDate).toLocaleDateString("pt-BR")
: "Não informado"}
</span>
</div>
<div className="flex items-center text-sm"><Mail className="mr-2 h-4 w-4 text-gray-500" /><span className="truncate">{patientData.email}</span></div>
<div className="flex items-center text-sm"><Phone className="mr-2 h-4 w-4 text-gray-500" /><span>{patientData.phone || "Não informado"}</span></div>
<div className="flex items-center text-sm"><Calendar className="mr-2 h-4 w-4 text-gray-500" /><span>{patientData.birthDate ? new Date(patientData.birthDate).toLocaleDateString("pt-BR", { timeZone: 'UTC' }) : "Não informado"}</span></div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<FileText className="mr-2 h-5 w-5" />
Documentos
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Button variant="outline" size="sm" className="w-full justify-start bg-transparent">
<FileText className="mr-2 h-4 w-4" />
Carteirinha do Convênio
</Button>
<Button variant="outline" size="sm" className="w-full justify-start bg-transparent">
<FileText className="mr-2 h-4 w-4" />
Histórico Médico
</Button>
<Button variant="outline" size="sm" className="w-full justify-start bg-transparent">
<FileText className="mr-2 h-4 w-4" />
Exames Recentes
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
</PatientLayout>
</Sidebar>
)
}
}

View File

@ -1,13 +1,13 @@
"use client"
import { useState, useEffect } from "react"
import PatientLayout from "@/components/patient-layout"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { toast } from "@/hooks/use-toast"
import { FileText, Download, Eye, Calendar, User, X } from "lucide-react"
import Sidebar from "@/components/Sidebar"
interface Report {
id: string
@ -287,7 +287,7 @@ export default function ReportsPage() {
const pendingReports = reports.filter((report) => report.status === "pendente")
return (
<PatientLayout>
<Sidebar>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Meus Laudos</h1>
@ -536,6 +536,6 @@ export default function ReportsPage() {
</DialogContent>
</Dialog>
</div>
</PatientLayout>
</Sidebar>
)
}

View File

@ -1,555 +1,12 @@
"use client";
import { usersService } from "services/usersApi.mjs";
import { doctorsService } from "services/doctorsApi.mjs";
import { appointmentsService } from "services/appointmentsApi.mjs";
import { AvailabilityService } from "services/availabilityApi.mjs";
import { Calendar as CalendarShadcn } from "@/components/ui/calendar";
import { format, addDays } from "date-fns";
import { useState, useEffect, useCallback, useRef } from "react";
import { Calendar, Clock, User, StickyNote } from "lucide-react";
import PatientLayout from "@/components/patient-layout";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "@/hooks/use-toast";
// app/patient/appointments/page.tsx
import Sidebar from "@/components/Sidebar";
import ScheduleForm from "@/components/schedule/schedule-form";
interface Doctor {
id: string;
full_name: string;
specialty: string;
}
interface Disponibilidade {
id?: string;
doctor_id?: string;
weekday: string;
start_time: string;
end_time: string;
slot_minutes?: number;
}
export default function ScheduleAppointment() {
const [selectedDoctor, setSelectedDoctor] = useState("");
const [selectedDate, setSelectedDate] = useState("");
const [selectedTime, setSelectedTime] = useState("");
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [loadingDoctors, setLoadingDoctors] = useState(true);
const [tipoConsulta, setTipoConsulta] = useState("presencial");
const [duracao, setDuracao] = useState("30");
const [notes, setNotes] = useState("");
const [disponibilidades, setDisponibilidades] = useState<Disponibilidade[]>([]);
const [availableWeekdays, setAvailableWeekdays] = useState<number[]>([]); // 1..7
const [availabilityCounts, setAvailabilityCounts] = useState<Record<string, number>>({}); // "yyyy-MM-dd" -> count
const calendarRef = useRef<HTMLDivElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
// --- Helpers ---
const getWeekdayNumber = (weekday: string) =>
["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
.indexOf(weekday.toLowerCase()) + 1; // monday=1 ... sunday=7
const getBrazilDate = (date: Date) =>
new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0));
// --- Fetch doctors ---
const fetchDoctors = useCallback(async () => {
setLoadingDoctors(true);
try {
const data: Doctor[] = await doctorsService.list();
setDoctors(data || []);
} catch (e) {
console.error("Erro ao buscar médicos:", e);
toast({ title: "Erro", description: "Não foi possível carregar médicos." });
} finally {
setLoadingDoctors(false);
}
}, []);
// --- Load disponibilidades details for selected doctor and compute weekdays ---
const loadDoctorDisponibilidades = useCallback(async (doctorId?: string) => {
if (!doctorId) {
setDisponibilidades([]);
setAvailableWeekdays([]);
setAvailabilityCounts({});
return;
}
try {
const disp: Disponibilidade[] = await AvailabilityService.listById(doctorId);
setDisponibilidades(disp || []);
const nums = (disp || []).map((d) => getWeekdayNumber(d.weekday)).filter(Boolean);
setAvailableWeekdays(Array.from(new Set(nums)));
// compute counts preview for next 90 days
await computeAvailabilityCountsPreview(doctorId, disp || []);
} catch (e) {
console.error("Erro disponibilidades:", e);
setDisponibilidades([]);
setAvailableWeekdays([]);
setAvailabilityCounts({});
}
}, []);
// --- Compute availability counts for next 90 days (efficient) ---
const computeAvailabilityCountsPreview = async (doctorId: string, dispList: Disponibilidade[]) => {
try {
const today = new Date();
const start = format(today, "yyyy-MM-dd");
const endDate = addDays(today, 90);
const end = format(endDate, "yyyy-MM-dd");
// fetch appointments for this doctor for the whole window (one call)
const appointments = await appointmentsService.search_appointment(
`doctor_id=eq.${doctorId}&scheduled_at=gte.${start}T00:00:00Z&scheduled_at=lt.${end}T23:59:59Z`
);
// group appointments by date
const apptsByDate: Record<string, number> = {};
(appointments || []).forEach((a: any) => {
const d = String(a.scheduled_at).split("T")[0];
apptsByDate[d] = (apptsByDate[d] || 0) + 1;
});
const counts: Record<string, number> = {};
for (let i = 0; i <= 90; i++) {
const d = addDays(today, i);
const key = format(d, "yyyy-MM-dd");
const dayOfWeek = d.getDay() === 0 ? 7 : d.getDay(); // 1..7
// find all disponibilidades matching this weekday
const dailyDisp = dispList.filter((p) => getWeekdayNumber(p.weekday) === dayOfWeek);
if (dailyDisp.length === 0) {
counts[key] = 0;
continue;
}
// compute total possible slots for the day summing multiple intervals
let possible = 0;
dailyDisp.forEach((p) => {
const [sh, sm] = p.start_time.split(":").map(Number);
const [eh, em] = p.end_time.split(":").map(Number);
const startMin = sh * 60 + sm;
const endMin = eh * 60 + em;
const slot = p.slot_minutes || 30;
// inclusive handling: if start==end -> 1 slot? normally not, we do Math.floor((end - start)/slot) + 1 if end >= start
if (endMin >= startMin) {
possible += Math.floor((endMin - startMin) / slot) + 1;
}
});
const occupied = apptsByDate[key] || 0;
const free = Math.max(0, possible - occupied);
counts[key] = free;
}
setAvailabilityCounts(counts);
} catch (e) {
console.error("Erro ao calcular contagens de disponibilidade:", e);
setAvailabilityCounts({});
}
};
// --- When doctor changes ---
useEffect(() => {
fetchDoctors();
}, [fetchDoctors]);
useEffect(() => {
if (selectedDoctor) {
loadDoctorDisponibilidades(selectedDoctor);
} else {
setDisponibilidades([]);
setAvailableWeekdays([]);
setAvailabilityCounts({});
}
setSelectedDate("");
setSelectedTime("");
setAvailableTimes([]);
}, [selectedDoctor, loadDoctorDisponibilidades]);
// --- Fetch available times for date --- (same logic, but shows toast if none)
const fetchAvailableSlots = useCallback(
async (doctorId: string, date: string) => {
if (!doctorId || !date) return;
setLoadingSlots(true);
setAvailableTimes([]);
try {
const disponibilidades: Disponibilidade[] = await AvailabilityService.listById(doctorId);
const consultas = await appointmentsService.search_appointment(
`doctor_id=eq.${doctorId}&scheduled_at=gte.${date}T00:00:00Z&scheduled_at=lt.${date}T23:59:59Z`
);
const diaJS = new Date(date).getDay(); // 0..6
const diaAPI = diaJS === 0 ? 7 : diaJS;
const disponibilidadeDia = disponibilidades.find(
(d) => getWeekdayNumber(d.weekday) === diaAPI
);
if (!disponibilidadeDia) {
setAvailableTimes([]);
toast({ title: "Nenhuma disponibilidade", description: "Nenhuma disponibilidade cadastrada para este dia." });
setLoadingSlots(false);
return;
}
const [startHour, startMin] = disponibilidadeDia.start_time.split(":").map(Number);
const [endHour, endMin] = disponibilidadeDia.end_time.split(":").map(Number);
const slot = disponibilidadeDia.slot_minutes || 30;
const horariosGerados: string[] = [];
let atual = new Date(date);
atual.setHours(startHour, startMin, 0, 0);
const end = new Date(date);
end.setHours(endHour, endMin, 0, 0);
while (atual <= end) {
horariosGerados.push(atual.toTimeString().slice(0, 5));
atual = new Date(atual.getTime() + slot * 60000);
}
const ocupados = (consultas || []).map((c: any) =>
String(c.scheduled_at).split("T")[1]?.slice(0, 5)
);
const livres = horariosGerados.filter((h) => !ocupados.includes(h));
if (livres.length === 0) {
toast({ title: "Sem horários livres", description: "Todos os horários estão ocupados neste dia." });
}
setAvailableTimes(livres);
} catch (err) {
console.error(err);
setAvailableTimes([]);
toast({ title: "Erro", description: "Falha ao carregar horários." });
} finally {
setLoadingSlots(false);
}
},
[]
);
// run fetchAvailableSlots when date changes
useEffect(() => {
if (selectedDoctor && selectedDate) {
fetchAvailableSlots(selectedDoctor, selectedDate);
} else {
setAvailableTimes([]);
}
setSelectedTime("");
}, [selectedDoctor, selectedDate, fetchAvailableSlots]);
// --- Submit ---
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedDoctor || !selectedDate || !selectedTime) {
toast({ title: "Preencha os campos", description: "Selecione médico, data e horário." });
return;
}
try {
const doctor = doctors.find((d) => d.id === selectedDoctor);
const paciente = await usersService.getMe();
const body = {
doctor_id: doctor?.id,
patient_id: paciente.user.id,
scheduled_at: `${selectedDate}T${selectedTime}:00`, // saving as local-ish string (you chose UTC elsewhere)
duration_minutes: Number(duracao),
notes,
appointment_type: tipoConsulta,
};
await appointmentsService.create(body);
toast({ title: "Agendado", description: "Consulta agendada com sucesso." });
// reset
setSelectedDoctor("");
setSelectedDate("");
setSelectedTime("");
setAvailableTimes([]);
setNotes("");
// refresh counts
if (selectedDoctor) computeAvailabilityCountsPreview(selectedDoctor, disponibilidades);
} catch (err) {
console.error(err);
toast({ title: "Erro", description: "Falha ao agendar consulta." });
}
};
// --- Calendar tooltip via event delegation ---
useEffect(() => {
const cont = calendarRef.current;
if (!cont) return;
const onMove = (ev: MouseEvent) => {
const target = ev.target as HTMLElement | null;
if (!target) return;
// find closest button that likely is a day cell
const btn = target.closest("button");
if (!btn) {
setTooltip(null);
return;
}
// many calendar implementations put the date in aria-label, e.g. "November 13, 2025"
const aria = btn.getAttribute("aria-label") || btn.textContent || "";
// try to parse date from aria-label: new Date(aria) works for many locales
const parsed = new Date(aria);
if (isNaN(parsed.getTime())) {
// sometimes aria-label is like "13" (just day) - try data-day attribute
const dataDay = btn.getAttribute("data-day");
if (dataDay) {
// try parse yyyy-mm-dd
const pd = new Date(dataDay);
if (!isNaN(pd.getTime())) {
const key = format(pd, "yyyy-MM-dd");
const count = availabilityCounts[key] ?? 0;
setTooltip({
x: ev.pageX + 10,
y: ev.pageY + 10,
text: `${count} horário${count !== 1 ? "s" : ""} disponíveis`,
});
return;
}
}
setTooltip(null);
return;
}
// parsed is valid - convert to yyyy-MM-dd
const key = format(getBrazilDate(parsed), "yyyy-MM-dd");
const count = availabilityCounts[key] ?? 0;
setTooltip({
x: ev.pageX + 10,
y: ev.pageY + 10,
text: `${count} horário${count !== 1 ? "s" : ""} disponíveis`,
});
};
const onLeave = () => setTooltip(null);
cont.addEventListener("mousemove", onMove);
cont.addEventListener("mouseleave", onLeave);
return () => {
cont.removeEventListener("mousemove", onMove);
cont.removeEventListener("mouseleave", onLeave);
};
}, [availabilityCounts]);
export default function PatientAppointments() {
return (
<PatientLayout>
<div className="max-w-6xl mx-auto space-y-4 px-4">
<h1 className="text-2xl font-semibold">Agendar Consulta</h1>
<Card className="border rounded-xl shadow-sm">
<CardHeader>
<CardTitle>Dados da Consulta</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
{/* LEFT */}
<div className="space-y-3">
<div>
<Label className="text-sm">Médico</Label>
<Select value={selectedDoctor} onValueChange={setSelectedDoctor}>
<SelectTrigger>
<SelectValue placeholder="Selecione o médico">
{selectedDoctor && doctors.find(d => d.id === selectedDoctor)?.full_name}
</SelectValue>
</SelectTrigger>
<SelectContent>
{loadingDoctors ? (
<SelectItem value="loading" disabled>Carregando...</SelectItem>
) : (
doctors.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.full_name} {d.specialty}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm">Data</Label>
<div ref={calendarRef} className="rounded-lg border p-2">
<CalendarShadcn
mode="single"
disabled={!selectedDoctor}
selected={selectedDate ? new Date(selectedDate + "T12:00:00") : undefined}
onSelect={(date) => {
if (!date) return;
const fixedDate = new Date(date.getTime() + 12 * 60 * 60 * 1000);
const formatted = format(fixedDate, "yyyy-MM-dd");
setSelectedDate(formatted);
}}
className="rounded-md border shadow-sm p-2"
modifiers={{ selected: selectedDate ? new Date(selectedDate + 'T12:00:00') : undefined }}
modifiersClassNames={{
selected:
"bg-blue-600 text-white hover:bg-blue-700 rounded-md",
}}
/>
</div>
</div>
<div>
<Label className="text-sm">Observações</Label>
<Textarea
placeholder="Instruções para o médico..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="mt-2"
/>
</div>
</div>
{/* RIGHT */}
<div className="space-y-3">
<Card className="shadow-md rounded-xl bg-blue-50 border border-blue-200">
<CardHeader>
<CardTitle className="text-blue-700">Resumo</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-gray-900 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-blue-600" />
<div className="text-xs">
{selectedDoctor ? (
<div className="font-medium">
{doctors.find((d) => d.id === selectedDoctor)?.full_name}
</div>
) : (
<div className="text-gray-500">Médico</div>
)}
<div className="text-xs text-gray-500">
{selectedDoctor ? doctors.find(d => d.id === selectedDoctor)?.specialty : ""}
</div>
</div>
</div>
<div className="text-xs text-gray-600">{tipoConsulta} {duracao} min</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-blue-600" />
<div>
{selectedDate ? (
<div className="font-medium">{selectedDate.split("-").reverse().join("/")}</div>
) : (
<div className="text-gray-500">Data</div>
)}
</div>
</div>
<div className="text-xs text-gray-600"></div>
</div>
{/* Horário */}
<div className="space-y-2">
<Label>Horário</Label>
<Select onValueChange={setSelectedTime} disabled={
loadingSlots || availableTimes.length === 0
} >
<SelectTrigger> <SelectValue placeholder={
loadingSlots ? "Carregando horários..." : availableTimes.length === 0 ? "Nenhum horário disponível" : "Selecione o horário"
} />
</SelectTrigger>
<SelectContent> {
availableTimes.map((h) => ( <SelectItem key={h} value={h}> {h} </SelectItem> ))
} </SelectContent>
</Select>
</div>
{notes && (
<div className="flex items-start gap-2 text-sm">
<StickyNote className="h-4 w-4" />
<div className="italic text-gray-700">{notes}</div>
</div>
)}
</CardContent>
</Card>
<div className="flex gap-2">
<Button
type="submit"
className="w-full md:w-auto px-4 py-1.5 text-sm bg-blue-600 text-white hover:bg-blue-700"
disabled={!selectedDoctor || !selectedDate || !selectedTime}
>
Agendar
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setSelectedDoctor("");
setSelectedDate("");
setSelectedTime("");
setAvailableTimes([]);
setNotes("");
}}
className="px-3"
>
Limpar
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
{/* Tooltip element */}
{tooltip && (
<div
ref={tooltipRef}
style={{
position: "absolute",
left: tooltip.x,
top: tooltip.y,
zIndex: 60,
background: "rgba(0,0,0,0.85)",
color: "white",
padding: "6px 8px",
borderRadius: 6,
fontSize: 12,
}}
>
{tooltip.text}
</div>
)}
</div>
</PatientLayout>
<Sidebar>
<ScheduleForm />
</Sidebar>
);
}

View File

@ -1,20 +1,17 @@
"use client";
import { useState, useEffect } from "react";
import SecretaryLayout from "@/components/secretary-layout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog } from "@/components/ui/dialog";
import { Calendar, Clock, MapPin, Phone, User, Trash2, Pencil } from "lucide-react";
import { toast } from "sonner";
import Link from "next/link";
import { appointmentsService } from "@/services/appointmentsApi.mjs";
import { patientsService } from "@/services/patientsApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs";
import Sidebar from "@/components/Sidebar";
export default function SecretaryAppointments() {
const [appointments, setAppointments] = useState<any[]>([]);
@ -144,7 +141,7 @@ export default function SecretaryAppointments() {
const appointmentStatuses = ["requested", "confirmed", "checked_in", "completed", "cancelled", "no_show"];
return (
<SecretaryLayout>
<Sidebar>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
@ -225,6 +222,6 @@ export default function SecretaryAppointments() {
<Dialog open={deleteModal} onOpenChange={setDeleteModal}>
{/* ... (código do modal de deleção) ... */}
</Dialog>
</SecretaryLayout>
</Sidebar>
);
}

View File

@ -1,10 +1,6 @@
"use client";
import SecretaryLayout from "@/components/secretary-layout";
import {
Card,
CardContent,
CardDescription,
import { Card, CardContent, CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
@ -14,6 +10,7 @@ import Link from "next/link";
import React, { useState, useEffect } from "react";
import { patientsService } from "@/services/patientsApi.mjs";
import { appointmentsService } from "@/services/appointmentsApi.mjs";
import Sidebar from "@/components/Sidebar";
export default function SecretaryDashboard() {
// Estados
@ -100,7 +97,7 @@ export default function SecretaryDashboard() {
}, []);
return (
<SecretaryLayout>
<Sidebar>
<div className="space-y-6">
{/* Cabeçalho */}
<div>
@ -299,6 +296,6 @@ export default function SecretaryDashboard() {
</Card>
</div>
</div>
</SecretaryLayout>
</Sidebar>
);
}

View File

@ -13,9 +13,8 @@ import { Checkbox } from "@/components/ui/checkbox";
import { ArrowLeft, Save, Trash2, Paperclip, Upload } from "lucide-react";
import Link from "next/link";
import { useToast } from "@/hooks/use-toast";
import SecretaryLayout from "@/components/secretary-layout";
import { patientsService } from "@/services/patientsApi.mjs";
import { json } from "stream/consumers";
import Sidebar from "@/components/Sidebar";
export default function EditarPacientePage() {
const router = useRouter();
@ -81,6 +80,12 @@ export default function EditarPacientePage() {
heightM?: string;
bmi?: string;
bloodType?: string;
// Adicionei os campos do convênio para o tipo FormData
convenio?: string;
plano?: string;
numeroMatricula?: string;
validadeCarteira?: string;
alergias?: string;
};
@ -133,6 +138,11 @@ export default function EditarPacientePage() {
heightM: "",
bmi: "",
bloodType: "",
convenio: "",
plano: "",
numeroMatricula: "",
validadeCarteira: "",
alergias: "",
});
const [isGuiaConvenio, setIsGuiaConvenio] = useState(false);
@ -141,7 +151,7 @@ export default function EditarPacientePage() {
useEffect(() => {
async function fetchPatient() {
try {
const res = await patientsService.getById(patientId);
const res = await patientsService.getById(patientId);
// Map API snake_case/nested to local camelCase form
setFormData({
id: res[0]?.id ?? "",
@ -192,6 +202,12 @@ export default function EditarPacientePage() {
heightM: res[0]?.height_m ? String(res[0].height_m) : "",
bmi: res[0]?.bmi ? String(res[0].bmi) : "",
bloodType: res[0]?.blood_type ?? "",
// Os campos de convênio e alergias não vêm da API, então os deixamos vazios ou com valores padrão
convenio: "",
plano: "",
numeroMatricula: "",
validadeCarteira: "",
alergias: "",
});
} catch (e: any) {
@ -238,8 +254,8 @@ export default function EditarPacientePage() {
router.push("/secretary/pacientes");
} catch (err: any) {
console.error("Erro ao atualizar paciente:", err);
toast({
title: "Erro",
toast({
title: "Erro",
description: err?.message || "Não foi possível atualizar o paciente",
variant: "destructive"
});
@ -247,67 +263,46 @@ export default function EditarPacientePage() {
};
return (
<SecretaryLayout>
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/secretary/pacientes">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold text-gray-900">Editar Paciente</h1>
<p className="text-gray-600">Atualize as informações do paciente</p>
<Sidebar>
{/* O espaçamento foi reduzido aqui: de `p-4 sm:p-6 lg:p-8` para `p-2 sm:p-4 lg:p-6` */}
<div className="space-y-6 p-2 sm:p-4 lg:p-6 max-w-10xl mx-auto"> {/* Alterado padding responsivo */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<Link href="/secretary/pacientes">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold text-gray-900">Editar Paciente</h1>
<p className="text-gray-600">Atualize as informações do paciente</p>
</div>
</div>
{/* Anexos Section */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-6">Anexos</h2>
<div className="flex items-center gap-3 mb-4">
<input ref={anexoInputRef} type="file" className="hidden" />
<Button type="button" variant="outline" disabled={isUploadingAnexo}>
<Paperclip className="w-4 h-4 mr-2" /> {isUploadingAnexo ? "Enviando..." : "Adicionar anexo"}
</Button>
</div>
{anexos.length === 0 ? (
<p className="text-sm text-gray-500">Nenhum anexo encontrado.</p>
) : (
<ul className="divide-y">
{anexos.map((a) => (
<li key={a.id} className="flex items-center justify-between py-2">
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="w-4 h-4 text-gray-500 shrink-0" />
<span className="text-sm text-gray-800 truncate">{a.nome || a.filename || `Anexo ${a.id}`}</span>
</div>
<Button type="button" variant="ghost" className="text-red-600">
<Trash2 className="w-4 h-4 mr-1" /> Remover
</Button>
</li>
))}
</ul>
)}
</div>
{/* Anexos Section - Movido para fora do cabeçalho para melhor organização e responsividade */}
</div>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Dados Pessoais Section */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-6">Dados Pessoais</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Photo upload */}
<div className="space-y-2">
<div className="space-y-2 col-span-1 md:col-span-2 lg:col-span-1">
<Label>Foto do paciente</Label>
<div className="flex items-center gap-4">
<div className="flex flex-col sm:flex-row items-center gap-4">
<div className="w-20 h-20 rounded-full bg-gray-100 overflow-hidden flex items-center justify-center">
{photoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={photoUrl} alt="Foto do paciente" className="w-full h-full object-cover" />
) : (
<span className="text-gray-400 text-sm">Sem foto</span>
<span className="text-gray-400 text-sm text-center">Sem foto</span>
)}
</div>
<div className="flex gap-2">
<div className="flex flex-col sm:flex-row gap-2 mt-2 sm:mt-0">
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" />
<Button type="button" variant="outline" disabled={isUploadingPhoto}>
{isUploadingPhoto ? "Enviando..." : "Enviar foto"}
@ -337,7 +332,7 @@ export default function EditarPacientePage() {
<div className="space-y-2">
<Label>Sexo *</Label>
<div className="flex gap-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex items-center space-x-2">
<input type="radio" id="Masculino" name="sexo" value="Masculino" checked={formData.sexo === "Masculino"} onChange={(e) => handleInputChange("sexo", e.target.value)} className="w-4 h-4 text-blue-600" />
<Label htmlFor="Masculino">Masculino</Label>
@ -404,7 +399,7 @@ export default function EditarPacientePage() {
<div className="space-y-2">
<Label htmlFor="profissao">Profissão</Label>
<Input id="profissao" value={formData.profession} onChange={(e) => handleInputChange("profession", e.target.value)} />
<Input id="profissao" value={formData.profession} onChange={(e) => handleInputChange("profissao", e.target.value)} />
</div>
<div className="space-y-2">
@ -478,12 +473,12 @@ export default function EditarPacientePage() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="space-y-2">
<Label htmlFor="email">E-mail *</Label>
<Input id="email" type="email" value={formData.email} onChange={(e) => handleInputChange("email", e.target.value)} required/>
<Input id="email" type="email" value={formData.email} onChange={(e) => handleInputChange("email", e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="celular">Celular *</Label>
<Input id="celular" value={formData.phoneMobile} onChange={(e) => handleInputChange("phoneMobile", e.target.value)} placeholder="(00) 00000-0000" required/>
<Input id="celular" value={formData.phoneMobile} onChange={(e) => handleInputChange("phoneMobile", e.target.value)} placeholder="(00) 00000-0000" required />
</div>
<div className="space-y-2">
@ -615,7 +610,7 @@ export default function EditarPacientePage() {
<div className="mt-6">
<Label htmlFor="alergias">Alergias</Label>
<Textarea id="alergias" onChange={(e) => handleInputChange("alergias", e.target.value)} placeholder="Ex: AAS, Dipirona, etc." className="mt-2" />
<Textarea id="alergias" value={formData.alergias} onChange={(e) => handleInputChange("alergias", e.target.value)} placeholder="Ex: AAS, Dipirona, etc." className="mt-2" />
</div>
</div>
@ -626,7 +621,7 @@ export default function EditarPacientePage() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="space-y-2">
<Label htmlFor="convenio">Convênio</Label>
<Select onValueChange={(value) => handleInputChange("convenio", value)}>
<Select value={formData.convenio} onValueChange={(value) => handleInputChange("convenio", value)}>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
@ -642,17 +637,17 @@ export default function EditarPacientePage() {
<div className="space-y-2">
<Label htmlFor="plano">Plano</Label>
<Input id="plano" onChange={(e) => handleInputChange("plano", e.target.value)} />
<Input id="plano" value={formData.plano} onChange={(e) => handleInputChange("plano", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="numeroMatricula"> de matrícula</Label>
<Input id="numeroMatricula" onChange={(e) => handleInputChange("numeroMatricula", e.target.value)} />
<Input id="numeroMatricula" value={formData.numeroMatricula} onChange={(e) => handleInputChange("numeroMatricula", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="validadeCarteira">Validade da Carteira</Label>
<Input id="validadeCarteira" type="date" onChange={(e) => handleInputChange("validadeCarteira", e.target.value)} disabled={validadeIndeterminada} />
<Input id="validadeCarteira" type="date" value={formData.validadeCarteira} onChange={(e) => handleInputChange("validadeCarteira", e.target.value)} disabled={validadeIndeterminada} />
</div>
</div>
@ -664,19 +659,19 @@ export default function EditarPacientePage() {
</div>
</div>
<div className="flex justify-end gap-4">
<div className="flex flex-col sm:flex-row justify-end gap-4">
<Link href="/secretary/pacientes">
<Button type="button" variant="outline">
<Button type="button" variant="outline" className="w-full sm:w-auto">
Cancelar
</Button>
</Link>
<Button type="submit" className="bg-blue-600 hover:bg-blue-700">
<Button type="submit" className="bg-blue-600 hover:bg-blue-700 w-full sm:w-auto">
<Save className="w-4 h-4 mr-2" />
Salvar Alterações
</Button>
</div>
</form>
</div>
</SecretaryLayout>
</Sidebar>
);
}
}

View File

@ -1,4 +1,3 @@
// Caminho: app/(manager)/usuario/novo/page.tsx
"use client";
import { useState } from "react";
@ -7,13 +6,9 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
// O Select foi removido pois não é mais necessário
import { Save, Loader2 } from "lucide-react";
import ManagerLayout from "@/components/manager-layout";
// Os imports originais foram mantidos, como solicitado
import { usersService } from "services/usersApi.mjs";
import { doctorsService } from "services/doctorsApi.mjs";
import { login } from "services/api.mjs";
import Sidebar from "@/components/Sidebar";
// Interface simplificada para refletir apenas os campos necessários
interface UserFormData {
@ -97,19 +92,23 @@ export default function NovoUsuarioPage() {
};
return (
<ManagerLayout>
<div className="w-full h-full p-4 md:p-8 flex justify-center items-start">
<div className="w-full max-w-screen-lg space-y-8">
<div className="flex items-center justify-between border-b pb-4">
<Sidebar>
{/* Container principal com padding responsivo e centralização */}
<div className="w-full h-full p-4 md:p-8 lg:p-12 flex justify-center items-start">
{/* Conteúdo do formulário com largura máxima para telas maiores */}
<div className="w-full max-w-screen-md lg:max-w-screen-lg space-y-8">
{/* Cabeçalho da página */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between border-b pb-4 gap-4"> {/* Ajustado para empilhar em telas pequenas */}
<div>
<h1 className="text-3xl font-extrabold text-gray-900">Novo Usuário</h1>
<p className="text-md text-gray-500">Preencha os dados para cadastrar um novo usuário no sistema.</p>
<h1 className="text-2xl sm:text-3xl font-extrabold text-gray-900">Novo Usuário</h1> {/* Tamanho de texto responsivo */}
<p className="text-sm sm:text-md text-gray-500">Preencha os dados para cadastrar um novo usuário no sistema.</p> {/* Tamanho de texto responsivo */}
</div>
<Link href="/manager/usuario">
<Button variant="outline">Cancelar</Button>
<Button variant="outline" className="w-full sm:w-auto">Cancelar</Button> {/* Botão ocupa largura total em telas pequenas */}
</Link>
</div>
{/* Formulário */}
<form onSubmit={handleSubmit} className="space-y-6 bg-white p-6 md:p-10 border rounded-xl shadow-lg">
{error && (
<div className="p-4 bg-red-50 text-red-700 rounded-lg border border-red-300">
@ -118,8 +117,9 @@ export default function NovoUsuarioPage() {
</div>
)}
{/* Campos do formulário em grid responsivo */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2 md:col-span-2">
<div className="space-y-2 md:col-span-2"> {/* Nome Completo ocupa 2 colunas em telas maiores */}
<Label htmlFor="nomeCompleto">Nome Completo *</Label>
<Input id="nomeCompleto" value={formData.nomeCompleto} onChange={(e) => handleInputChange("nomeCompleto", e.target.value)} placeholder="Nome e Sobrenome" required />
</div>
@ -129,7 +129,10 @@ export default function NovoUsuarioPage() {
<Input id="email" type="email" value={formData.email} onChange={(e) => handleInputChange("email", e.target.value)} placeholder="exemplo@dominio.com" required />
</div>
{/* O seletor de Papel (Função) foi removido */}
<div className="space-y-2">
<Label htmlFor="telefone">Telefone</Label>
<Input id="telefone" value={formData.telefone} onChange={(e) => handleInputChange("telefone", e.target.value)} placeholder="(00) 00000-0000" maxLength={15} />
</div>
<div className="space-y-2">
<Label htmlFor="senha">Senha *</Label>
@ -141,25 +144,21 @@ export default function NovoUsuarioPage() {
<Input id="confirmarSenha" type="password" value={formData.confirmarSenha} onChange={(e) => handleInputChange("confirmarSenha", e.target.value)} placeholder="Repita a senha" required />
{formData.senha && formData.confirmarSenha && formData.senha !== formData.confirmarSenha && <p className="text-xs text-red-500">As senhas não coincidem.</p>}
</div>
<div className="space-y-2">
<Label htmlFor="telefone">Telefone</Label>
<Input id="telefone" value={formData.telefone} onChange={(e) => handleInputChange("telefone", e.target.value)} placeholder="(00) 00000-0000" maxLength={15} />
<div className="space-y-2 md:col-span-2"> {/* CPF ocupa 2 colunas em telas maiores */}
<Label htmlFor="cpf">CPF *</Label>
<Input id="cpf" value={formData.cpf} onChange={(e) => handleInputChange("cpf", e.target.value)} placeholder="xxx.xxx.xxx-xx" required />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="cpf">Cpf *</Label>
<Input id="cpf" value={formData.cpf} onChange={(e) => handleInputChange("cpf", e.target.value)} placeholder="xxx.xxx.xxx-xx" required />
</div>
<div className="flex justify-end gap-4 pt-6 border-t mt-6">
{/* Botões de ação */}
<div className="flex flex-col sm:flex-row justify-end gap-4 pt-6 border-t mt-6"> {/* Botões empilhados em telas pequenas */}
<Link href="/manager/usuario">
<Button type="button" variant="outline" disabled={isSaving}>
<Button type="button" variant="outline" disabled={isSaving} className="w-full sm:w-auto">
Cancelar
</Button>
</Link>
<Button type="submit" className="bg-green-600 hover:bg-green-700" disabled={isSaving}>
<Button type="submit" className="bg-green-600 hover:bg-green-700 w-full sm:w-auto" disabled={isSaving}>
{isSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
{isSaving ? "Salvando..." : "Salvar Usuário"}
</Button>
@ -167,6 +166,6 @@ export default function NovoUsuarioPage() {
</form>
</div>
</div>
</ManagerLayout>
</Sidebar>
);
}

View File

@ -8,8 +8,8 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Edit, Trash2, Eye, Calendar, Filter, Loader2 } from "lucide-react";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import SecretaryLayout from "@/components/secretary-layout";
import { patientsService } from "@/services/patientsApi.mjs";
import Sidebar from "@/components/Sidebar";
// Defina o tamanho da página.
const PAGE_SIZE = 5;
@ -145,7 +145,7 @@ export default function PacientesPage() {
};
return (
<SecretaryLayout>
<Sidebar>
<div className="space-y-6 px-2 sm:px-4 md:px-8">
{/* Header (Responsividade OK) */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
@ -164,7 +164,7 @@ export default function PacientesPage() {
</div>
{/* Bloco de Filtros (Responsividade APLICADA) */}
<div className="flex flex-wrap items-center gap-4 bg-card p-4 rounded-lg border border-border">
<div className="flex flex-wrap items-center gap-4 bg-card p-4 rounded-lg border border-border">
<Filter className="w-5 h-5 text-gray-400" />
{/* Busca - Ocupa 100% no mobile, depois cresce */}
@ -173,14 +173,15 @@ export default function PacientesPage() {
placeholder="Buscar por nome ou telefone..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full sm:flex-grow sm:min-w-[150px] p-2 border rounded-md text-sm"
// w-full no mobile, depois flex-grow para ocupar o espaço disponível
className="w-full sm:flex-grow sm:max-w-[300px] p-2 border rounded-md text-sm"
/>
{/* Convênio - Ocupa metade da linha no mobile */}
<div className="flex items-center gap-2 w-[calc(50%-8px)] sm:w-auto sm:flex-grow sm:max-w-[200px]">
{/* Convênio - Ocupa a largura total em telas pequenas, depois se ajusta */}
<div className="flex items-center gap-2 w-full sm:w-auto sm:flex-grow sm:max-w-[200px]">
<span className="text-sm font-medium text-foreground whitespace-nowrap hidden md:block">Convênio</span>
<Select value={convenioFilter} onValueChange={setConvenioFilter}>
<SelectTrigger className="w-full sm:w-40">
<SelectTrigger className="w-full sm:w-40"> {/* w-full para mobile, w-40 para sm+ */}
<SelectValue placeholder="Convênio" />
</SelectTrigger>
<SelectContent>
@ -193,11 +194,11 @@ export default function PacientesPage() {
</Select>
</div>
{/* VIP - Ocupa a outra metade da linha no mobile */}
<div className="flex items-center gap-2 w-[calc(50%-8px)] sm:w-auto sm:flex-grow sm:max-w-[150px]">
{/* VIP - Ocupa a largura total em telas pequenas, depois se ajusta */}
<div className="flex items-center gap-2 w-full sm:w-auto sm:flex-grow sm:max-w-[150px]">
<span className="text-sm font-medium text-foreground whitespace-nowrap hidden md:block">VIP</span>
<Select value={vipFilter} onValueChange={setVipFilter}>
<SelectTrigger className="w-full sm:w-32">
<SelectTrigger className="w-full sm:w-32"> {/* w-full para mobile, w-32 para sm+ */}
<SelectValue placeholder="VIP" />
</SelectTrigger>
<SelectContent>
@ -208,16 +209,17 @@ export default function PacientesPage() {
</Select>
</div>
{/* Aniversariantes - Vai para a linha de baixo no mobile, ocupando 100% */}
{/* Aniversariantes - Ocupa 100% no mobile, e se alinha à direita no md+ */}
<Button variant="outline" className="w-full md:w-auto md:ml-auto">
<Calendar className="w-4 h-4 mr-2" />
Aniversariantes
</Button>
</div>
{/* Tabela (Responsividade APLICADA) */}
<div className="bg-white rounded-lg border border-gray-200 shadow-md">
<div className="overflow-x-auto">
{/* --- SEÇÃO DE TABELA (VISÍVEL EM TELAS MAIORES OU IGUAIS A MD) --- */}
{/* Garantir que a tabela se esconda em telas menores e apareça em MD+ */}
<div className="bg-white rounded-lg border border-gray-200 shadow-md hidden md:block">
<div className="overflow-x-auto"> {/* Permite rolagem horizontal se a tabela for muito larga */}
{error ? (
<div className="p-6 text-red-600">{`Erro ao carregar pacientes: ${error}`}</div>
) : loading ? (
@ -225,18 +227,14 @@ export default function PacientesPage() {
<Loader2 className="w-6 h-6 mr-2 animate-spin text-green-600" /> Carregando pacientes...
</div>
) : (
// min-w ajustado para responsividade
<table className="w-full min-w-[650px] md:min-w-[900px]">
<table className="w-full min-w-[650px]"> {/* min-w para evitar que a tabela se contraia demais */}
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left p-4 font-medium text-gray-700 w-[20%]">Nome</th>
{/* Coluna oculta em telas muito pequenas */}
{/* Ajustes de visibilidade de colunas para diferentes breakpoints */}
<th className="text-left p-4 font-medium text-gray-700 w-[15%] hidden sm:table-cell">Telefone</th>
{/* Coluna oculta em telas pequenas e muito pequenas */}
<th className="text-left p-4 font-medium text-gray-700 w-[15%] hidden md:table-cell">Cidade / Estado</th>
{/* Coluna oculta em telas muito pequenas */}
<th className="text-left p-4 font-medium text-gray-700 w-[15%] hidden sm:table-cell">Convênio</th>
{/* Colunas ocultas em telas médias, pequenas e muito pequenas */}
<th className="text-left p-4 font-medium text-gray-700 w-[15%] hidden lg:table-cell">Último atendimento</th>
<th className="text-left p-4 font-medium text-gray-700 w-[15%] hidden lg:table-cell">Próximo atendimento</th>
<th className="text-left p-4 font-medium text-gray-700 w-[5%]">Ações</th>
@ -265,7 +263,6 @@ export default function PacientesPage() {
</span>
</div>
</td>
{/* Aplicação das classes de visibilidade */}
<td className="p-4 text-gray-600 hidden sm:table-cell">{patient.telefone}</td>
<td className="p-4 text-gray-600 hidden md:table-cell">{`${patient.cidade} / ${patient.estado}`}</td>
<td className="p-4 text-gray-600 hidden sm:table-cell">{patient.convenio}</td>
@ -308,53 +305,109 @@ export default function PacientesPage() {
</table>
)}
</div>
</div>
{/* Paginação */}
{totalPages > 1 && !loading && (
<div className="flex flex-col sm:flex-row items-center justify-center p-4 border-t border-gray-200">
{/* Renderização dos botões de número de página (Limitando a 5) */}
<div className="flex space-x-2"> {/* Increased space-x for more separation */}
{/* Botão Anterior */}
<Button
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
disabled={page === 1}
variant="outline"
size="lg" // Changed to "lg" for larger buttons
>
&lt; Anterior
</Button>
{/* --- SEÇÃO DE CARDS (VISÍVEL APENAS EM TELAS MENORES QUE MD) --- */}
{/* Garantir que os cards apareçam em telas menores e se escondam em MD+ */}
<div className="bg-white rounded-lg border border-gray-200 shadow-md p-4 block md:hidden">
{error ? (
<div className="p-6 text-red-600">{`Erro ao carregar pacientes: ${error}`}</div>
) : loading ? (
<div className="p-6 text-center text-gray-500 flex items-center justify-center">
<Loader2 className="w-6 h-6 mr-2 animate-spin text-green-600" /> Carregando pacientes...
</div>
) : filteredPatients.length === 0 ? (
<div className="p-8 text-center text-gray-500">
{allPatients.length === 0 ? "Nenhum paciente cadastrado" : "Nenhum paciente encontrado com os filtros aplicados"}
</div>
) : (
<div className="space-y-4">
{currentPatients.map((patient) => (
<div key={patient.id} className="bg-gray-50 rounded-lg p-4 flex flex-col sm:flex-row justify-between items-start sm:items-center border border-gray-200">
<div className="flex-grow mb-2 sm:mb-0">
<div className="font-semibold text-lg text-gray-900 flex items-center">
{patient.nome}
{patient.vip && (
<span className="ml-2 px-2 py-0.5 text-xs font-semibold text-purple-600 bg-purple-100 rounded-full">VIP</span>
)}
</div>
<div className="text-sm text-gray-600">Telefone: {patient.telefone}</div>
<div className="text-sm text-gray-600">Convênio: {patient.convenio}</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="w-full"><Button variant="outline" className="w-full">Ações</Button></div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openDetailsDialog(String(patient.id))}>
<Eye className="w-4 h-4 mr-2" />
Ver detalhes
</DropdownMenuItem>
{Array.from({ length: totalPages }, (_, index) => index + 1)
.slice(Math.max(0, page - 3), Math.min(totalPages, page + 2))
.map((pageNumber) => (
<Button
key={pageNumber}
onClick={() => setPage(pageNumber)}
variant={pageNumber === page ? "default" : "outline"}
size="lg" // Changed to "lg" for larger buttons
className={pageNumber === page ? "bg-green-600 hover:bg-green-700 text-white" : "text-gray-700"}
>
{pageNumber}
</Button>
))}
<DropdownMenuItem asChild>
<Link href={`/secretary/pacientes/${patient.id}/editar`} className="flex items-center w-full">
<Edit className="w-4 h-4 mr-2" />
Editar
</Link>
</DropdownMenuItem>
{/* Botão Próximo */}
<Button
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
disabled={page === totalPages}
variant="outline"
size="lg" // Changed to "lg" for larger buttons
>
Próximo &gt;
</Button>
</div>
<DropdownMenuItem>
<Calendar className="w-4 h-4 mr-2" />
Marcar consulta
</DropdownMenuItem>
<DropdownMenuItem className="text-red-600" onClick={() => openDeleteDialog(String(patient.id))}>
<Trash2 className="w-4 h-4 mr-2" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
</div>
{/* Paginação */}
{totalPages > 1 && !loading && (
<div className="flex flex-col sm:flex-row items-center justify-center p-4 border-t border-gray-200">
<div className="flex space-x-2 flex-wrap justify-center"> {/* Adicionado flex-wrap e justify-center para botões da paginação */}
<Button
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
disabled={page === 1}
variant="outline"
size="lg"
>
&lt; Anterior
</Button>
{Array.from({ length: totalPages }, (_, index) => index + 1)
.slice(Math.max(0, page - 3), Math.min(totalPages, page + 2))
.map((pageNumber) => (
<Button
key={pageNumber}
onClick={() => setPage(pageNumber)}
variant={pageNumber === page ? "default" : "outline"}
size="lg"
className={pageNumber === page ? "bg-green-600 hover:bg-green-700 text-white" : "text-gray-700"}
>
{pageNumber}
</Button>
))}
<Button
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
disabled={page === totalPages}
variant="outline"
size="lg"
>
Próximo &gt;
</Button>
</div>
</div>
)}
{/* AlertDialogs (Permanecem os mesmos) */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
{/* ... (AlertDialog de Exclusão) ... */}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
@ -370,7 +423,6 @@ export default function PacientesPage() {
</AlertDialog>
<AlertDialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
{/* ... (AlertDialog de Detalhes) ... */}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Detalhes do Paciente</AlertDialogTitle>
@ -384,7 +436,7 @@ export default function PacientesPage() {
<div className="text-red-600 p-4">{patientDetails.error}</div>
) : (
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<p className="font-semibold">Nome Completo</p>
<p>{patientDetails.full_name}</p>
@ -420,7 +472,7 @@ export default function PacientesPage() {
</div>
<div className="border-t pt-4 mt-4">
<h3 className="font-semibold mb-2">Endereço</h3>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<p className="font-semibold">Rua</p>
<p>{`${patientDetails.street}, ${patientDetails.number}`}</p>
@ -457,6 +509,6 @@ export default function PacientesPage() {
</AlertDialogContent>
</AlertDialog>
</div>
</SecretaryLayout>
</Sidebar>
);
}

View File

@ -1,355 +1,11 @@
"use client";
import type React from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import SecretaryLayout from "@/components/secretary-layout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
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 { Textarea } from "@/components/ui/textarea";
import { Calendar, Clock, User } from "lucide-react"; // Importações que você já tinha
import { patientsService } from "@/services/patientsApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs";
import { appointmentsService } from "@/services/appointmentsApi.mjs";
import { usersService } from "@/services/usersApi.mjs";
import { toast } from "sonner"; // Para notificações
import Sidebar from "@/components/Sidebar";
import ScheduleForm from "@/components/schedule/schedule-form";
export default function ScheduleAppointment() {
const router = useRouter();
const [patients, setPatients] = useState<any[]>([]);
const [doctors, setDoctors] = useState<any[]>([]);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
export default function SecretaryAppointments() {
return (
<Sidebar>
<ScheduleForm />
</Sidebar>
);
}
// Estados de loading e error para feedback visual e depuração
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Estados do formulário
const [selectedPatient, setSelectedPatient] = useState("");
const [selectedDoctor, setSelectedDoctor] = useState("");
const [selectedDate, setSelectedDate] = useState("");
const [selectedTime, setSelectedTime] = useState("");
const [appointmentType, setAppointmentType] = useState("presencial");
const [durationMinutes, setDurationMinutes] = useState("30");
const [chiefComplaint, setChiefComplaint] = useState("");
const [patientNotes, setPatientNotes] = useState("");
const [internalNotes, setInternalNotes] = useState("");
const [insuranceProvider, setInsuranceProvider] = useState("");
const availableTimes = [
"08:00", "08:30", "09:00", "09:30", "10:00", "10:30", "11:00", "11:30",
"14:00", "14:30", "15:00", "15:30", "16:00", "16:30", "17:00", "17:30"
];
// --- NOVO/ATUALIZADO useEffect COM LOGS PARA DEPURAR ---
useEffect(() => {
const fetchInitialData = async () => {
setLoading(true);
setError(null); // Limpa qualquer erro anterior ao iniciar uma nova busca
const results = await Promise.allSettled([
patientsService.list(),
doctorsService.list(),
usersService.getMe()
]);
const [patientResult, doctorResult, userResult] = results;
let hasFetchError = false; // Flag para saber se houve algum erro geral
// Checar pacientes
if (patientResult.status === 'fulfilled') {
setPatients(patientResult.value || []);
console.log("Pacientes carregados com sucesso:", patientResult.value);
} else {
console.error("ERRO AO CARREGAR PACIENTES:", patientResult.reason);
hasFetchError = true;
toast.error("Erro ao carregar lista de pacientes."); // Notificação para o usuário
}
// Checar médicos
if (doctorResult.status === 'fulfilled') {
setDoctors(doctorResult.value || []);
console.log("Médicos carregados com sucesso:", doctorResult.value); // <-- CRÍTICO PARA DEPURAR
} else {
console.error("ERRO AO CARREGAR MÉDICOS:", doctorResult.reason);
hasFetchError = true;
setError("Falha ao carregar médicos."); // Define o erro para ser exibido no dropdown
toast.error("Erro ao carregar lista de médicos."); // Notificação para o usuário
}
// Checar usuário logado
if (userResult.status === 'fulfilled' && userResult.value?.user?.id) {
setCurrentUserId(userResult.value.user.id);
console.log("ID do usuário logado carregado:", userResult.value.user.id);
} else {
const reason = userResult.status === 'rejected' ? userResult.reason : "API não retornou um ID de usuário.";
console.error("ERRO AO CARREGAR USUÁRIO:", reason);
hasFetchError = true;
toast.error("Não foi possível identificar o usuário logado. Por favor, faça login novamente."); // Notificação
// Não definimos setError aqui, pois um erro no usuário não impede a renderização de médicos/pacientes
}
// Se houve qualquer erro na busca, defina uma mensagem geral de erro se não houver uma mais específica.
if (hasFetchError && !error) { // Se 'error' já foi definido por um problema específico, mantenha-o.
setError("Alguns dados não puderam ser carregados. Verifique o console.");
}
setLoading(false); // Finaliza o estado de carregamento
console.log("Estado de carregamento finalizado:", false);
};
fetchInitialData();
}, []); // O array de dependências vazio significa que ele roda apenas uma vez após a montagem inicial
// --- LOGS PARA VERIFICAR OS ESTADOS ANTES DA RENDERIZAÇÃO ---
console.log("Estado 'loading' no render:", loading);
console.log("Estado 'error' no render:", error);
console.log("Conteúdo de 'doctors' no render:", doctors);
console.log("Número de médicos em 'doctors':", doctors.length);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log("Botão de submit clicado!"); // Log para confirmar que o clique funciona
if (!currentUserId) {
toast.error("Sessão de usuário inválida. Por favor, faça login novamente.");
return;
}
if (!selectedPatient || !selectedDoctor || !selectedDate || !selectedTime) {
toast.error("Paciente, médico, data e horário são obrigatórios.");
return;
}
try {
const scheduledAt = new Date(`${selectedDate}T${selectedTime}:00Z`).toISOString();
const newAppointmentData = {
patient_id: selectedPatient,
doctor_id: selectedDoctor,
scheduled_at: scheduledAt,
duration_minutes: parseInt(durationMinutes, 10),
appointment_type: appointmentType,
status: "requested",
chief_complaint: chiefComplaint || null,
patient_notes: patientNotes || null,
notes: internalNotes || null,
insurance_provider: insuranceProvider || null,
created_by: currentUserId,
};
console.log("🚀 Enviando os seguintes dados para a API:", newAppointmentData);
// A chamada para a API de criação
await appointmentsService.create(newAppointmentData);
toast.success("Consulta agendada com sucesso!");
router.push("/secretary/appointments");
} catch (error) {
console.error("❌ Erro ao criar agendamento:", error);
toast.error("Ocorreu um erro ao agendar a consulta. Verifique o console.");
}
};
return (
<SecretaryLayout>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Agendar Consulta</h1>
<p className="text-gray-600">Preencha os detalhes para criar um novo agendamento</p>
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle>Dados da Consulta</CardTitle>
<CardDescription>Preencha as informações para agendar a consulta</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="patient">Paciente</Label>
<Select value={selectedPatient} onValueChange={setSelectedPatient}>
<SelectTrigger>
<SelectValue placeholder="Selecione um paciente" />
</SelectTrigger>
<SelectContent>
{loading ? (
<SelectItem value="loading-patients" disabled>Carregando pacientes...</SelectItem>
) : error && patients.length === 0 ? ( // Se erro e não há pacientes
<SelectItem value="error-patients" disabled>Erro ao carregar pacientes</SelectItem>
) : patients.length === 0 ? ( // Se não há erro mas a lista está vazia
<SelectItem value="no-patients" disabled>Nenhum paciente encontrado</SelectItem>
) : (
patients.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.full_name}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="doctor">Médico</Label>
<Select value={selectedDoctor} onValueChange={setSelectedDoctor}>
<SelectTrigger>
<SelectValue placeholder="Selecione um médico" />
</SelectTrigger>
<SelectContent>
{/* Lógica condicional para o estado de carregamento, erro ou lista vazia */}
{loading ? (
<SelectItem value="loading" disabled>Carregando médicos...</SelectItem>
) : error && doctors.length === 0 ? ( // Se há erro E a lista de médicos está vazia
<SelectItem value="error" disabled>Erro ao carregar médicos</SelectItem>
) : doctors.length === 0 ? ( // Se não há erro mas a lista está vazia
<SelectItem value="no-doctors" disabled>Nenhum médico encontrado</SelectItem>
) : (
doctors.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.full_name} - {d.specialty}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* O restante do formulário permanece o mesmo */}
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="date">Data</Label>
<Input
id="date"
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
min={new Date().toISOString().split("T")[0]} // Garante que a data mínima é hoje
/>
</div>
<div className="space-y-2">
<Label htmlFor="time">Horário</Label>
<Select value={selectedTime} onValueChange={setSelectedTime}>
<SelectTrigger>
<SelectValue placeholder="Selecione um horário" />
</SelectTrigger>
<SelectContent>
{availableTimes.map((time) => (
<SelectItem key={time} value={time}>
{time}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="appointmentType">Tipo de Consulta</Label>
<Select value={appointmentType} onValueChange={setAppointmentType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="presencial">Presencial</SelectItem>
<SelectItem value="telemedicina">Telemedicina</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="duration">Duração (minutos)</Label>
<Input
id="duration"
type="number"
value={durationMinutes}
onChange={(e) => setDurationMinutes(e.target.value)}
placeholder="Ex: 30"
min="1" // Duração mínima
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="insurance">Convênio (opcional)</Label>
<Input
id="insurance"
placeholder="Nome do convênio do paciente"
value={insuranceProvider}
onChange={(e) => setInsuranceProvider(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="chiefComplaint">Queixa Principal (opcional)</Label>
<Textarea
id="chiefComplaint"
placeholder="Descreva brevemente o motivo da consulta..."
value={chiefComplaint}
onChange={(e) => setChiefComplaint(e.target.value)}
rows={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="patientNotes">Observações do Paciente (opcional)</Label>
<Textarea
id="patientNotes"
placeholder="Anotações relevantes informadas pelo paciente..."
value={patientNotes}
onChange={(e) => setPatientNotes(e.target.value)}
rows={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="internalNotes">Observações Internas (opcional)</Label>
<Textarea
id="internalNotes"
placeholder="Anotações para a equipe da clínica..."
value={internalNotes}
onChange={(e) => setInternalNotes(e.target.value)}
rows={2}
/>
</div>
<Button
type="submit"
className="w-full"
// Remova temporariamente '|| !currentUserId || loading' para testar
disabled={!selectedPatient || !selectedDoctor || !selectedDate || !selectedTime /* || !currentUserId || loading */}
>
Agendar Consulta
</Button>
</form>
</CardContent>
</Card>
</div>
<div className="space-y-6">
{/* Card de Resumo e Informações Importantes (se houver, adicione aqui) */}
<Card>
<CardHeader>
<CardTitle>Informações Rápidas</CardTitle>
<CardDescription>Ajuda e status</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loading && (
<p className="text-sm text-blue-600 flex items-center"><Clock className="mr-2 h-4 w-4" /> Carregando dados iniciais...</p>
)}
{error && (
<p className="text-sm text-red-600 flex items-center">
<User className="mr-2 h-4 w-4" /> {error}
</p>
)}
{!currentUserId && !loading && (
<p className="text-sm text-red-600 flex items-center"><User className="mr-2 h-4 w-4" /> Usuário não identificado. Recarregue a página.</p>
)}
<p className="text-sm text-gray-500 flex items-center">
<Calendar className="mr-2 h-4 w-4" /> Selecione uma data e horário válidos.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
</SecretaryLayout>
);
}

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";
@ -14,6 +13,7 @@ import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
import { Eye, EyeOff, Mail, Lock, Loader2 } from "lucide-react";
import { usersService } from "@/services/usersApi.mjs";
interface LoginFormProps {
children?: React.ReactNode;
@ -31,7 +31,6 @@ 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);
@ -39,34 +38,26 @@ export function LoginForm({ children }: LoginFormProps) {
* --- NOVA FUNÇÃO ---
* Finaliza o login com o perfil de dashboard escolhido e redireciona.
*/
const handleRoleSelection = (selectedDashboardRole: string) => {
const user = authenticatedUser;
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;
}
const completeUserInfo = { ...user, user_metadata: { ...user.user_metadata, role: selectedDashboardRole } };
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));
let redirectPath = "";
switch (selectedDashboardRole) {
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;
case "gestor": redirectPath = "/manager/dashboard"; break;
case "admin": redirectPath = "/manager/dashboard"; break;
case "medico": redirectPath = "/doctor/dashboard"; break;
case "secretaria": redirectPath = "/secretary/dashboard"; break;
case "paciente": redirectPath = "/patient/dashboard"; break;
}
if (redirectPath) {
@ -77,10 +68,6 @@ export function LoginForm({ children }: LoginFormProps) {
}
};
/**
* --- 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);
@ -88,85 +75,42 @@ 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) {
const me = await usersService.getMeSimple()
console.log(me.roles)
if (!me.roles || me.roles.length === 0) {
throw new Error("Nenhum perfil de acesso foi encontrado para este usuário.");
}
const rolesFromApi: string[] = rolesData.map((r: any) => r.role);
handleRoleSelection(me.roles[0], 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", "paciente", "finance"]);
setIsLoading(false); // Para o loading para mostrar a tela de seleção
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 "paciente": // Mapeamento de 'patient' (ou outro nome que você use para 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 {
setUserRoles(finalRoles);
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>
@ -190,22 +134,20 @@ 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" onClick={() => handleRoleSelection(role)}>
<Button key={role} variant="outline" className="h-11 text-base" onClick={() => handleRoleSelection(role, authenticatedUser)}>
Entrar como: {role.charAt(0).toUpperCase() + role.slice(1)}
</Button>
))}
</div>
</div>
)}
{children}
</CardContent>
</Card>
);
}
}

View File

@ -29,24 +29,53 @@ import {
ChevronLeft,
ChevronRight,
Home,
CalendarCheck2,
ClipboardPlus,
SquareUserRound,
CalendarClock,
Users,
SquareUser,
ClipboardList,
Stethoscope,
ClipboardMinus,
} from "lucide-react";
import SidebarUserSection from "@/components/ui/userToolTip";
interface ManagerData {
interface UserData {
id: string;
name: string;
email: string;
phone: string;
cpf: string;
department: string;
permissions: object;
app_metadata: {
user_role: string;
};
user_metadata: {
cpf: string;
email_verified: boolean;
full_name: string;
phone_mobile: string;
role: string;
};
identities: {
identity_id: string;
id: string;
user_id: string;
provider: string;
}[];
is_anonymous: boolean;
}
interface ManagerLayoutProps {
interface MenuItem {
href: string;
icon: React.ElementType;
label: string;
}
interface SidebarProps {
children: React.ReactNode;
}
export default function ManagerLayout({ children }: ManagerLayoutProps) {
const [managerData, setManagerData] = useState<ManagerData | null>(null);
export default function Sidebar({ children }: SidebarProps) {
const [userData, setUserData] = useState<UserData>();
const [role, setRole] = useState<string>();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const router = useRouter();
@ -60,15 +89,29 @@ export default function ManagerLayout({ children }: ManagerLayoutProps) {
if (userInfoString && token) {
const userInfo = JSON.parse(userInfoString);
setManagerData({
id: userInfo.id || "",
name: userInfo.user_metadata?.full_name || "Gestor(a)",
email: userInfo.email || "",
department: userInfo.user_metadata?.role || "Gestão",
phone: userInfo.phone || "",
cpf: "",
permissions: {},
setUserData({
id: userInfo.id ?? "",
email: userInfo.email ?? "",
app_metadata: {
user_role: userInfo.app_metadata?.user_role ?? "patient",
},
user_metadata: {
cpf: userInfo.user_metadata?.cpf ?? "",
email_verified: userInfo.user_metadata?.email_verified ?? false,
full_name: userInfo.user_metadata?.full_name ?? "",
phone_mobile: userInfo.user_metadata?.phone_mobile ?? "",
role: userInfo.user_metadata?.role ?? "",
},
identities:
userInfo.identities?.map((identity: any) => ({
identity_id: identity.identity_id ?? "",
id: identity.id ?? "",
user_id: identity.user_id ?? "",
provider: identity.provider ?? "",
})) ?? [],
is_anonymous: userInfo.is_anonymous ?? false,
});
setRole(userInfo.user_metadata?.role);
} else {
// O redirecionamento para /login já estava correto. Ótimo!
router.push("/login");
@ -110,16 +153,89 @@ export default function ManagerLayout({ children }: ManagerLayoutProps) {
const cancelLogout = () => setShowLogoutDialog(false);
const menuItems = [
{ href: "/manager/dashboard", icon: Home, label: "Dashboard" },
{ href: "#", icon: Calendar, label: "Relatórios gerenciais" },
{ href: "/manager/usuario", icon: User, label: "Gestão de Usuários" },
{ href: "/manager/home", icon: User, label: "Gestão de Médicos" },
{ href: "/manager/pacientes", icon: User, label: "Gestão de Pacientes" },
{ href: "#", icon: Calendar, label: "Configurações" },
];
const SetMenuItems = (role: any) => {
const patientItems: MenuItem[] = [
{ href: "/patient/dashboard", icon: Home, label: "Dashboard" },
{
href: "/patient/schedule",
icon: CalendarClock,
label: "Agendar Consulta",
},
{
href: "/patient/appointments",
icon: CalendarCheck2,
label: "Minhas Consultas",
},
{ href: "/patient/reports", icon: ClipboardPlus, label: "Meus Laudos" },
{ href: "/patient/profile", icon: SquareUser, label: "Meus Dados" },
];
if (!managerData) {
const doctorItems: MenuItem[] = [
{ href: "/doctor/dashboard", icon: Home, label: "Dashboard" },
{ href: "/doctor/medicos", icon: Users, label: "Gestão de Pacientes" },
{ href: "/doctor/consultas", icon: CalendarCheck2, label: "Consultas" },
{
href: "/doctor/disponibilidade",
icon: ClipboardList,
label: "Disponibilidade",
},
];
const secretaryItems: MenuItem[] = [
{ href: "/secretary/dashboard", icon: Home, label: "Dashboard" },
{
href: "/secretary/appointments",
icon: CalendarCheck2,
label: "Consultas",
},
{
href: "/secretary/schedule",
icon: CalendarClock,
label: "Agendar Consulta",
},
{
href: "/secretary/pacientes",
icon: Users,
label: "Gestão de Pacientes",
},
];
const managerItems: MenuItem[] = [
{ href: "/manager/dashboard", icon: Home, label: "Dashboard" },
{ href: "#", icon: ClipboardMinus, label: "Relatórios gerenciais" },
{ href: "/manager/usuario", icon: Users, label: "Gestão de Usuários" },
{ href: "/manager/home", icon: Stethoscope, label: "Gestão de Médicos" },
{ href: "/manager/pacientes", icon: Users, label: "Gestão de Pacientes" },
{ href: "/doctor/consultas", icon: CalendarCheck2, label: "Consultas" }, //adicionar botão de voltar pra pagina anterior
];
let menuItems: MenuItem[];
switch (role) {
case "gestor":
menuItems = managerItems;
break;
case "admin":
menuItems = managerItems;
break;
case "medico":
menuItems = doctorItems;
break;
case "secretaria":
menuItems = secretaryItems;
break;
case "paciente":
menuItems = patientItems;
break;
default:
menuItems = patientItems;
break;
}
return menuItems;
};
const menuItems = SetMenuItems(role);
if (!userData) {
return (
<div className="flex h-screen w-full items-center justify-center">
Carregando...
@ -137,10 +253,13 @@ export default function ManagerLayout({ children }: ManagerLayoutProps) {
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
{!sidebarCollapsed && (
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<div className="w-4 h-4 bg-white rounded-sm"></div>
</div>
<span className="font-semibold text-gray-900">MediConnect</span>
{/* 🛑 SUBSTITUIÇÃO: Usando a tag <img> com o caminho da logo */}
<img
src="/Logo MedConnect.png" // Use o arquivo da logo (ou /android-chrome-512x512.png)
alt="Logo MediConnect"
className="w-12 h-12 object-contain" // Define o tamanho para w-8 h-8 (32px)
/>
<span className="font-semibold text-gray-900">MedConnect</span>
</div>
)}
<Button
@ -179,43 +298,12 @@ export default function ManagerLayout({ children }: ManagerLayoutProps) {
);
})}
</nav>
<div className="border-t p-4 mt-auto">
<div className="flex items-center space-x-3 mb-4">
<Avatar>
<AvatarImage src="/placeholder.svg?height=40&width=40" />
<AvatarFallback>
{managerData.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
{!sidebarCollapsed && (
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{managerData.name}
</p>
<p className="text-xs text-gray-500 truncate">
{managerData.department}
</p>
</div>
)}
</div>
<Button
variant="outline"
size="sm"
className={
sidebarCollapsed
? "w-full bg-transparent flex justify-center items-center p-2"
: "w-full bg-transparent"
}
onClick={handleLogout}
>
<LogOut className={sidebarCollapsed ? "h-5 w-5" : "mr-2 h-4 w-4"} />
{!sidebarCollapsed && "Sair"}
</Button>
</div>
<SidebarUserSection
userData={userData}
sidebarCollapsed={false}
handleLogout={handleLogout}
isActive={role === "paciente" ? false : true}
></SidebarUserSection>
</div>
<div
@ -223,17 +311,7 @@ export default function ManagerLayout({ children }: ManagerLayoutProps) {
sidebarCollapsed ? "ml-16" : "ml-64"
}`}
>
<header className="bg-white border-b border-gray-200 px-4 md:px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4 flex-1 max-w-md"></div>
<div className="flex items-center gap-4 ml-auto">
<Button variant="ghost" size="sm" className="relative">
<Bell className="w-5 h-5" />
<Badge className="absolute -top-1 -right-1 w-5 h-5 p-0 flex items-center justify-center bg-red-500 text-white text-xs">
1
</Badge>
</Button>
</div>
</header>
<header className="bg-gray-50 px-4 md:px-6 py-4 flex items-center justify-between"></header>
<main className="flex-1 p-4 md:p-6">{children}</main>
</div>

View File

@ -1,472 +0,0 @@
"use client";
import type React from "react";
import { useState, useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import Cookies from "js-cookie"; // Manteremos para o logout, se necessário
import { api } from "@/services/api.mjs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Search,
Bell,
Calendar,
Clock,
User,
LogOut,
Menu,
X,
Home,
FileText,
ChevronLeft,
ChevronRight,
} from "lucide-react";
interface DoctorData {
id: string;
name: string;
email: string;
phone: string;
cpf: string;
crm: string;
specialty: string;
department: string;
permissions: object;
role: string
}
interface PatientLayoutProps {
children: React.ReactNode;
}
export default function DoctorLayout({ children }: PatientLayoutProps) {
const [doctorData, setDoctorData] = useState<DoctorData | null>(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [windowWidth, setWindowWidth] = useState(0);
const isMobile = windowWidth < 1024;
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
const userInfoString = localStorage.getItem("user_info");
// --- ALTERAÇÃO PRINCIPAL AQUI ---
// Procurando o token no localStorage, onde ele foi realmente salvo.
const token = localStorage.getItem("token");
if (userInfoString && token) {
const userInfo = JSON.parse(userInfoString);
setDoctorData({
id: userInfo.id || "",
name: userInfo.user_metadata?.full_name || "Doutor(a)",
email: userInfo.email || "",
specialty: userInfo.user_metadata?.specialty || "Especialidade",
phone: userInfo.phone || "",
cpf: "",
crm: "",
department: "",
permissions: {},
role: userInfo.role
});
} else {
// Se não encontrar, aí sim redireciona.
router.push("/login");
}
}, [router]);
// O restante do seu código permanece exatamente o mesmo...
useEffect(() => {
const handleResize = () => setWindowWidth(window.innerWidth);
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
useEffect(() => {
if (isMobile) {
setSidebarCollapsed(true);
} else {
setSidebarCollapsed(false);
}
}, [isMobile]);
const handleLogout = () => {
setShowLogoutDialog(true);
};
// --- ALTERAÇÃO 2: A função de logout agora é MUITO mais simples ---
const confirmLogout = async () => {
try {
// Chama a função centralizada para fazer o logout no servidor
await api.logout();
} catch (error) {
// O erro já é logado dentro da função api.logout, não precisamos fazer nada aqui
} finally {
// A responsabilidade do componente é apenas limpar o estado local e redirecionar
localStorage.removeItem("user_info");
localStorage.removeItem("token");
Cookies.remove("access_token"); // Limpeza de segurança
setShowLogoutDialog(false);
router.push("/"); // Redireciona para a home
}
};
const cancelLogout = () => {
setShowLogoutDialog(false);
};
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
const menuItems = [
{
href: "/doctor/dashboard",
icon: Home,
label: "Dashboard",
// Botão para o dashboard do médico
},
{
href: "/doctor/consultas",
icon: Calendar,
label: "Consultas",
// Botão para página de consultas marcadas do médico atual
},
{
href: "/doctor/medicos/editorlaudo",
icon: Clock,
label: "Editor de Laudo",
// Botão para página do editor de laudo
},
{
href: "/doctor/medicos",
icon: User,
label: "Pacientes",
// Botão para a página de visualização de todos os pacientes
},
{
href: "/doctor/disponibilidade",
icon: Calendar,
label: "Disponibilidade",
// Botão para o dashboard do médico
},
];
if (!doctorData) {
return <div>Carregando...</div>;
}
return (
// O restante do seu código JSX permanece exatamente o mesmo
<div className="min-h-screen bg-background flex">
<div
className={`bg-card border-r border transition-all duration-300 ${
sidebarCollapsed ? "w-16" : "w-64"
} fixed left-0 top-0 h-screen flex flex-col z-50`}
>
<div className="p-4 border-b border">
<div className="flex items-center justify-between">
{!sidebarCollapsed && (
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<div className="w-4 h-4 bg-white rounded-sm"></div>
</div>
<span className="font-semibold text-gray-900">MediConnect</span>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-1"
>
{sidebarCollapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</Button>
</div>
</div>
<nav className="flex-1 p-2 overflow-y-auto">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive =
pathname === item.href ||
(item.href !== "/" && pathname.startsWith(item.href));
return (
<Link key={item.href} href={item.href}>
<div
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${
isActive
? "bg-blue-50 text-blue-600 border-r-2 border-blue-600"
: "text-gray-600 hover:bg-gray-50"
}`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && (
<span className="font-medium">{item.label}</span>
)}
</div>
</Link>
);
})}
</nav>
// ... (seu código anterior)
{/* Sidebar para desktop */}
<div
className={`bg-white border-r border-gray-200 transition-all duration-300 ${
sidebarCollapsed ? "w-16" : "w-64"
} fixed left-0 top-0 h-screen flex flex-col z-50`}
>
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between">
{!sidebarCollapsed && (
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<div className="w-4 h-4 bg-white rounded-sm"></div>
</div>
<span className="font-semibold text-gray-900">
MediConnect
</span>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-1"
>
{sidebarCollapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</Button>
</div>
</div>
<nav className="flex-1 p-2 overflow-y-auto">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive =
pathname === item.href ||
(item.href !== "/" && pathname.startsWith(item.href));
return (
<Link key={item.href} href={item.href}>
<div
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${
isActive
? "bg-blue-50 text-blue-600 border-r-2 border-blue-600"
: "text-gray-600 hover:bg-gray-50"
}`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && (
<span className="font-medium">{item.label}</span>
)}
</div>
</Link>
);
})}
</nav>
<div className="border-t p-4 mt-auto">
<div className="flex items-center space-x-3 mb-4">
{!sidebarCollapsed && (
<>
<Avatar>
<AvatarImage src="/placeholder.svg?height=40&width=40" />
<AvatarFallback>
{doctorData.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{doctorData.name}
</p>
<p className="text-xs text-gray-500 truncate">
{doctorData.specialty}
</p>
</div>
</>
)}
{sidebarCollapsed && (
<Avatar className="mx-auto">
<AvatarImage src="/placeholder.svg?height=40&width=40" />
<AvatarFallback>
{doctorData.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
)}
</div>
<div
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors text-muted-foreground hover:bg-accent cursor-pointer ${
sidebarCollapsed ? "justify-center" : ""
}`}
onClick={handleLogout}
>
<LogOut className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && <span className="font-medium">Sair</span>}
</div>
</div>
</div>
</div>
{isMobileMenuOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden"
onClick={toggleMobileMenu}
></div>
)}
<div
className={`bg-white border-r border-gray-200 fixed left-0 top-0 h-screen flex flex-col z-50 transition-transform duration-300 md:hidden ${
isMobileMenuOpen ? "translate-x-0 w-64" : "-translate-x-full w-64"
}`}
>
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<div className="w-4 h-4 bg-white rounded-sm"></div>
</div>
<span className="font-semibold text-gray-900">Hospital System</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={toggleMobileMenu}
className="p-1"
>
<X className="w-4 h-4" />
</Button>
</div>
<nav className="flex-1 p-2 overflow-y-auto">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive =
pathname === item.href ||
(item.href !== "/" && pathname.startsWith(item.href));
return (
<Link key={item.href} href={item.href} onClick={toggleMobileMenu}>
<div
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${
isActive
? "bg-accent text-accent-foreground border-r-2 border-primary"
: "text-muted-foreground hover:bg-accent"
}`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="font-medium">{item.label}</span>
</div>
</Link>
);
})}
</nav>
<div className="border-t p-4 mt-auto">
<div className="flex items-center space-x-3 mb-4">
<Avatar>
<AvatarImage src="/placeholder.svg?height=40&width=40" />
<AvatarFallback>
{doctorData.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{doctorData.name}
</p>
<p className="text-xs text-gray-500 truncate">
{doctorData.specialty}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-full bg-transparent"
onClick={() => {
handleLogout();
toggleMobileMenu();
}}
>
<LogOut className="mr-2 h-4 w-4" />
Sair
</Button>
</div>
</div>
<div
className={`flex-1 flex flex-col transition-all duration-300 ${
sidebarCollapsed ? "ml-16" : "ml-64"
}`}
>
<header className="bg-card border-b border px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1"></div>
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" className="relative">
<Bell className="w-5 h-5" />
<Badge className="absolute -top-1 -right-1 w-5 h-5 p-0 flex items-center justify-center bg-red-500 text-white text-xs">
1
</Badge>
</Button>
</div>
</div>
</header>
<main className="flex-1 p-6">{children}</main>
</div>
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Confirmar Saída</DialogTitle>
<DialogDescription>
Deseja realmente sair do sistema? Você precisará fazer login
novamente para acessar sua conta.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={cancelLogout}>
Cancelar
</Button>
<Button variant="destructive" onClick={confirmLogout}>
<LogOut className="mr-2 h-4 w-4" />
Sair
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,283 +0,0 @@
// Caminho: [seu-caminho]/FinancierLayout.tsx
"use client";
import Cookies from "js-cookie";
import type React from "react";
import { useState, useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import { api } from "@/services/api.mjs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Search,
Bell,
Calendar,
Clock,
User,
LogOut,
Menu,
X,
Home,
FileText,
ChevronLeft,
ChevronRight,
} from "lucide-react";
interface FinancierData {
id: string;
name: string;
email: string;
phone: string;
cpf: string;
department: string;
permissions: object;
}
interface PatientLayoutProps {
children: React.ReactNode;
}
export default function FinancierLayout({ children }: PatientLayoutProps) {
const [financierData, setFinancierData] = useState<FinancierData | null>(
null
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
const userInfoString = localStorage.getItem("user_info");
// --- ALTERAÇÃO 1: Buscando o token no localStorage ---
const token = localStorage.getItem("token");
if (userInfoString && token) {
const userInfo = JSON.parse(userInfoString);
setFinancierData({
id: userInfo.id || "",
name: userInfo.user_metadata?.full_name || "Financeiro",
email: userInfo.email || "",
department:
userInfo.user_metadata?.department || "Departamento Financeiro",
phone: userInfo.phone || "",
cpf: "",
permissions: {},
});
} else {
// --- ALTERAÇÃO 2: Redirecionando para o login central ---
router.push("/login");
}
}, [router]);
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 1024) {
setSidebarCollapsed(true);
} else {
setSidebarCollapsed(false);
}
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const handleLogout = () => {
setShowLogoutDialog(true);
};
// --- ALTERAÇÃO 2: A função de logout agora é MUITO mais simples ---
const confirmLogout = async () => {
try {
// Chama a função centralizada para fazer o logout no servidor
await api.logout();
} catch (error) {
// O erro já é logado dentro da função api.logout, não precisamos fazer nada aqui
} finally {
// A responsabilidade do componente é apenas limpar o estado local e redirecionar
localStorage.removeItem("user_info");
localStorage.removeItem("token");
Cookies.remove("access_token"); // Limpeza de segurança
setShowLogoutDialog(false);
router.push("/"); // Redireciona para a home
}
};
const cancelLogout = () => {
setShowLogoutDialog(false);
};
const menuItems = [
{ href: "#", icon: Home, label: "Dashboard" },
{ href: "#", icon: Calendar, label: "Relatórios financeiros" },
{ href: "#", icon: User, label: "Finanças Gerais" },
{ href: "#", icon: Calendar, label: "Configurações" },
];
if (!financierData) {
return (
<div className="flex h-screen w-full items-center justify-center">
Carregando...
</div>
);
}
return (
// O restante do seu código JSX permanece inalterado
<div className="min-h-screen bg-background flex">
<div
className={`bg-card border-r border-border transition-all duration-300 ${
sidebarCollapsed ? "w-16" : "w-64"
} fixed left-0 top-0 h-screen flex flex-col z-10`}
>
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
{!sidebarCollapsed && (
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<div className="w-4 h-4 bg-primary-foreground rounded-sm"></div>
</div>
<span className="font-semibold text-foreground">
MediConnect
</span>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-1"
>
{sidebarCollapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</Button>
</div>
</div>
<nav className="flex-1 p-2 overflow-y-auto">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive =
pathname === item.href ||
(item.href !== "/" && pathname.startsWith(item.href));
return (
<Link key={item.href} href={item.href}>
<div
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && (
<span className="font-medium">{item.label}</span>
)}
</div>
</Link>
);
})}
</nav>
<div className="border-t p-4 mt-auto">
<div className="flex items-center space-x-3 mb-4">
<Avatar>
<AvatarImage src="/placeholder.svg?height=40&width=40" />
<AvatarFallback>
{financierData.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
{!sidebarCollapsed && (
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{financierData.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{financierData.department}
</p>
</div>
)}
</div>
<Button
variant="outline"
size="sm"
className={
sidebarCollapsed
? "w-full bg-transparent flex justify-center items-center p-2"
: "w-full bg-transparent"
}
onClick={handleLogout}
>
<LogOut className={sidebarCollapsed ? "h-5 w-5" : "mr-2 h-4 w-4"} />
{!sidebarCollapsed && "Sair"}
</Button>
</div>
</div>
<div
className={`flex-1 flex flex-col transition-all duration-300 ${
sidebarCollapsed ? "ml-16" : "ml-64"
}`}
>
<header className="bg-card border-b border-border px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1 max-w-md"></div>
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" className="relative">
<Bell className="w-5 h-5" />
<Badge className="absolute -top-1 -right-1 w-5 h-5 p-0 flex items-center justify-center bg-destructive text-destructive-foreground text-xs">
1
</Badge>
</Button>
</div>
</div>
</header>
<main className="flex-1 p-6">{children}</main>
</div>
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Confirmar Saída</DialogTitle>
<DialogDescription>
Deseja realmente sair do sistema? Você precisará fazer login
novamente para acessar sua conta.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={cancelLogout}>
Cancelar
</Button>
<Button variant="destructive" onClick={confirmLogout}>
<LogOut className="mr-2 h-4 w-4" />
Sair
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,227 +0,0 @@
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import Link from "next/link"
import { useRouter, usePathname } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
Search,
Bell,
Settings,
Users,
UserCheck,
Calendar,
Clock,
User,
LogOut,
FileText,
BarChart3,
Home,
ChevronLeft,
ChevronRight,
} from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
interface PatientData {
name: string
email: string
phone: string
cpf: string
birthDate: string
address: string
}
interface HospitalLayoutProps {
children: React.ReactNode
}
export default function HospitalLayout({ children }: HospitalLayoutProps) {
const [patientData, setPatientData] = useState<PatientData | null>(null)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [showLogoutDialog, setShowLogoutDialog] = useState(false)
const router = useRouter()
const pathname = usePathname()
useEffect(() => {
const data = localStorage.getItem("patientData")
if (data) {
setPatientData(JSON.parse(data))
} else {
router.push("/patient/login")
}
}, [router])
const handleLogout = () => {
setShowLogoutDialog(true)
}
const confirmLogout = () => {
localStorage.removeItem("patientData")
setShowLogoutDialog(false)
router.push("/")
}
const cancelLogout = () => {
setShowLogoutDialog(false)
}
const menuItems = [
{
href: "/patient/dashboard",
icon: Home,
label: "Dashboard",
},
{
href: "/patient/appointments",
icon: Calendar,
label: "Minhas Consultas",
},
{
href: "/patient/schedule",
icon: Clock,
label: "Agendar Consulta",
},
{
href: "/patient/reports",
icon: FileText,
label: "Meus Laudos",
},
{
href: "/patient/profile",
icon: User,
label: "Meus Dados",
},
]
if (!patientData) {
return <div>Carregando...</div>
}
return (
<div className="min-h-screen bg-background flex">
{/* Sidebar */}
<div
className={`bg-card border-r border-border transition-all duration-300 ${sidebarCollapsed ? "w-16" : "w-64"} h-screen flex flex-col`}
>
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
{!sidebarCollapsed && (
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<div className="w-4 h-4 bg-primary-foreground rounded-sm"></div>
</div>
<span className="font-semibold text-foreground">MediConnect</span>
</div>
)}
<Button variant="ghost" size="sm" onClick={() => setSidebarCollapsed(!sidebarCollapsed)} className="p-1">
{sidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</Button>
</div>
</div>
<nav className="flex-1 p-2">
{menuItems.map((item) => {
const Icon = item.icon
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))
return (
<Link key={item.href} href={item.href}>
<div
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${
isActive ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && <span className="font-medium">{item.label}</span>}
</div>
</Link>
)
})}
</nav>
<div className="border-t p-4">
<div className="flex items-center space-x-3 mb-4">
<Avatar>
<AvatarImage src="/placeholder.svg?height=40&width=40" />
<AvatarFallback>
{patientData.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{patientData.name}</p>
<p className="text-xs text-muted-foreground truncate">{patientData.email}</p>
</div>
</div>
<Button variant="outline" size="sm" className="w-full bg-transparent" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Sair
</Button>
</div>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col">
{/* Header */}
<header className="bg-card border-b border-border px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input placeholder="Buscar paciente" className="pl-10 bg-background border-border" />
</div>
</div>
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" className="relative">
<Bell className="w-5 h-5" />
<Badge className="absolute -top-1 -right-1 w-5 h-5 p-0 flex items-center justify-center bg-destructive text-destructive-foreground text-xs">
1
</Badge>
</Button>
</div>
</div>
</header>
{/* Page Content */}
<main className="flex-1 p-6">{children}</main>
</div>
{/* Logout confirmation dialog */}
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Confirmar Saída</DialogTitle>
<DialogDescription>
Deseja realmente sair do sistema? Você precisará fazer login novamente para acessar sua conta.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={cancelLogout}>
Cancelar
</Button>
<Button variant="destructive" onClick={confirmLogout}>
<LogOut className="mr-2 h-4 w-4" />
Sair
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -1,287 +0,0 @@
"use client";
import Cookies from "js-cookie";
import type React from "react";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { api } from "@/services/api.mjs"; // Importando nosso cliente de API
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Search,
Bell,
User,
LogOut,
FileText,
Clock,
Calendar,
Home,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface PatientData {
name: string;
email: string;
phone: string;
cpf: string;
birthDate: string;
address: string;
}
interface PatientLayoutProps {
children: React.ReactNode;
}
// --- ALTERAÇÃO 1: Renomeando o componente para maior clareza ---
export default function PatientLayout({ children }: PatientLayoutProps) {
const [patientData, setPatientData] = useState<PatientData | null>(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 1024) {
setSidebarCollapsed(true);
} else {
setSidebarCollapsed(false);
}
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
useEffect(() => {
const userInfoString = localStorage.getItem("user_info");
// --- ALTERAÇÃO 2: Buscando o token no localStorage ---
const token = localStorage.getItem("token");
if (userInfoString && token) {
const userInfo = JSON.parse(userInfoString);
setPatientData({
name: userInfo.user_metadata?.full_name || "Paciente",
email: userInfo.email || "",
phone: userInfo.phone || "",
cpf: "",
birthDate: "",
address: "",
});
} else {
// --- ALTERAÇÃO 3: Redirecionando para o login central ---
router.push("/login");
}
}, [router]);
const handleLogout = () => setShowLogoutDialog(true);
// --- ALTERAÇÃO 4: Função de logout completa e padronizada ---
const confirmLogout = async () => {
try {
// Chama a função centralizada para fazer o logout no servidor
await api.logout();
} catch (error) {
console.error("Erro ao tentar fazer logout no servidor:", error);
} finally {
// Limpeza completa e consistente do estado local
localStorage.removeItem("user_info");
localStorage.removeItem("token");
Cookies.remove("access_token"); // Limpeza de segurança
setShowLogoutDialog(false);
router.push("/"); // Redireciona para a página inicial
}
};
const cancelLogout = () => setShowLogoutDialog(false);
const menuItems = [
{ href: "/patient/dashboard", icon: Home, label: "Dashboard" },
{
href: "/patient/appointments",
icon: Calendar,
label: "Minhas Consultas",
},
{ href: "/patient/schedule", icon: Clock, label: "Agendar Consulta" },
{ href: "/patient/reports", icon: FileText, label: "Meus Laudos" },
{ href: "/patient/profile", icon: User, label: "Meus Dados" },
];
if (!patientData) {
return (
<div className="flex h-screen w-full items-center justify-center">
Carregando...
</div>
);
}
return (
<div className="min-h-screen bg-background flex">
{/* Sidebar */}
<div
className={`bg-card border-r border-border transition-all duration-300 ${
sidebarCollapsed ? "w-16" : "w-64"
} fixed left-0 top-0 h-screen flex flex-col z-10`}
>
{/* Header da Sidebar */}
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
{!sidebarCollapsed && (
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<div className="w-4 h-4 bg-primary-foreground rounded-sm"></div>
</div>
<span className="font-semibold text-foreground">
MediConnect
</span>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-1"
>
{sidebarCollapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</Button>
</div>
</div>
{/* Menu */}
<nav className="flex-1 p-2 overflow-y-auto">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive =
pathname === item.href ||
(item.href !== "/" && pathname.startsWith(item.href));
return (
<Link key={item.href} href={item.href}>
<div
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && (
<span className="font-medium">{item.label}</span>
)}
</div>
</Link>
);
})}
</nav>
{/* Rodapé com Avatar e Logout */}
<div className="border-t p-4 mt-auto">
<div className="flex items-center space-x-3 mb-4">
<Avatar>
<AvatarImage src="/placeholder.svg?height=40&width=40" />
<AvatarFallback>
{patientData.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
{!sidebarCollapsed && (
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{patientData.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{patientData.email}
</p>
</div>
)}
</div>
{/* Botão Sair - ajustado para responsividade */}
<Button
variant="outline"
size="sm"
className={
sidebarCollapsed
? "w-full bg-transparent flex justify-center items-center p-2" // Centraliza o ícone quando colapsado
: "w-full bg-transparent"
}
onClick={handleLogout}
>
<LogOut className={sidebarCollapsed ? "h-5 w-5" : "mr-2 h-4 w-4"} />{" "}
{/* Remove margem quando colapsado */}
{!sidebarCollapsed && "Sair"}{" "}
{/* Mostra o texto apenas quando não está colapsado */}
</Button>
</div>
</div>
{/* Main Content */}
<div
className={`flex-1 flex flex-col transition-all duration-300 ${
sidebarCollapsed ? "ml-16" : "ml-64"
}`}
>
{/* Header */}
<header className="bg-card border-b border-border px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1 max-w-md"></div>
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" className="relative">
<Bell className="w-5 h-5" />
<Badge className="absolute -top-1 -right-1 w-5 h-5 p-0 flex items-center justify-center bg-destructive text-destructive-foreground text-xs">
1
</Badge>
</Button>
</div>
</div>
</header>
{/* Page Content */}
<main className="flex-1 p-6">{children}</main>
</div>
{/* Logout confirmation dialog */}
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Confirmar Saída</DialogTitle>
<DialogDescription>
Deseja realmente sair do sistema? Você precisará fazer login
novamente para acessar sua conta.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={cancelLogout}>
Cancelar
</Button>
<Button variant="destructive" onClick={confirmLogout}>
<LogOut className="mr-2 h-4 w-4" />
Sair
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,526 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { usersService } from "@/services/usersApi.mjs";
import { patientsService } from "@/services/patientsApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs";
import { appointmentsService } from "@/services/appointmentsApi.mjs";
import { AvailabilityService } from "@/services/availabilityApi.mjs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Calendar as CalendarShadcn } from "@/components/ui/calendar";
import { format, addDays } from "date-fns";
import { User, StickyNote, Calendar } from "lucide-react";
import {smsService } from "@/services/Sms.mjs"
import { toast } from "@/hooks/use-toast";
export default function ScheduleForm() {
// Estado do usuário e role
const [role, setRole] = useState<string>("paciente");
const [userId, setUserId] = useState<string | null>(null);
// Listas e seleções
const [patients, setPatients] = useState<any[]>([]);
const [selectedPatient, setSelectedPatient] = useState("");
const [doctors, setDoctors] = useState<any[]>([]);
const [selectedDoctor, setSelectedDoctor] = useState("");
const [selectedDate, setSelectedDate] = useState("");
const [selectedTime, setSelectedTime] = useState("");
const [notes, setNotes] = useState("");
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
const [loadingDoctors, setLoadingDoctors] = useState(true);
const [loadingSlots, setLoadingSlots] = useState(false);
// Outras configs
const [tipoConsulta] = useState("presencial");
const [duracao] = useState("30");
const [disponibilidades, setDisponibilidades] = useState<any[]>([]);
const [availabilityCounts, setAvailabilityCounts] = useState<Record<string, number>>({});
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
const calendarRef = useRef<HTMLDivElement | null>(null);
// Funções auxiliares
const getWeekdayNumber = (weekday: string) =>
["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
.indexOf(weekday.toLowerCase()) + 1;
const getBrazilDate = (date: Date) =>
new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0));
// 🔹 Buscar dados do usuário e role
useEffect(() => {
(async () => {
try {
const me = await usersService.getMe();
const currentRole = me?.roles?.[0] || "paciente";
setRole(currentRole);
setUserId(me?.user?.id || null);
if (["secretaria", "gestor", "admin"].includes(currentRole)) {
const pats = await patientsService.list();
setPatients(pats || []);
}
} catch (err) {
console.error("Erro ao carregar usuário:", err);
}
})();
}, []);
// 🔹 Buscar médicos
const fetchDoctors = useCallback(async () => {
setLoadingDoctors(true);
try {
const data = await doctorsService.list();
setDoctors(data || []);
} catch (err) {
console.error("Erro ao buscar médicos:", err);
toast({ title: "Erro", description: "Não foi possível carregar médicos." });
} finally {
setLoadingDoctors(false);
}
}, []);
useEffect(() => {
fetchDoctors();
}, [fetchDoctors]);
// 🔹 Buscar disponibilidades
const loadDoctorDisponibilidades = useCallback(async (doctorId?: string) => {
if (!doctorId) return;
try {
const disp = await AvailabilityService.listById(doctorId);
setDisponibilidades(disp || []);
await computeAvailabilityCountsPreview(doctorId, disp || []);
} catch (err) {
console.error("Erro ao buscar disponibilidades:", err);
setDisponibilidades([]);
}
}, []);
const computeAvailabilityCountsPreview = async (doctorId: string, dispList: any[]) => {
try {
const today = new Date();
const start = format(today, "yyyy-MM-dd");
const endDate = addDays(today, 90);
const end = format(endDate, "yyyy-MM-dd");
const appointments = await appointmentsService.search_appointment(
`doctor_id=eq.${doctorId}&scheduled_at=gte.${start}T00:00:00Z&scheduled_at=lt.${end}T23:59:59Z`
);
const apptsByDate: Record<string, number> = {};
(appointments || []).forEach((a: any) => {
const d = String(a.scheduled_at).split("T")[0];
apptsByDate[d] = (apptsByDate[d] || 0) + 1;
});
const counts: Record<string, number> = {};
for (let i = 0; i <= 90; i++) {
const d = addDays(today, i);
const key = format(d, "yyyy-MM-dd");
const dayOfWeek = d.getDay() === 0 ? 7 : d.getDay();
const dailyDisp = dispList.filter((p) => getWeekdayNumber(p.weekday) === dayOfWeek);
if (dailyDisp.length === 0) {
counts[key] = 0;
continue;
}
let possible = 0;
dailyDisp.forEach((p) => {
const [sh, sm] = p.start_time.split(":").map(Number);
const [eh, em] = p.end_time.split(":").map(Number);
const startMin = sh * 60 + sm;
const endMin = eh * 60 + em;
const slot = p.slot_minutes || 30;
if (endMin >= startMin) possible += Math.floor((endMin - startMin) / slot) + 1;
});
const occupied = apptsByDate[key] || 0;
counts[key] = Math.max(0, possible - occupied);
}
setAvailabilityCounts(counts);
} catch (err) {
console.error("Erro ao calcular contagens:", err);
setAvailabilityCounts({});
}
};
// 🔹 Quando médico muda
useEffect(() => {
if (selectedDoctor) {
loadDoctorDisponibilidades(selectedDoctor);
} else {
setDisponibilidades([]);
setAvailabilityCounts({});
}
setSelectedDate("");
setSelectedTime("");
setAvailableTimes([]);
}, [selectedDoctor, loadDoctorDisponibilidades]);
// 🔹 Buscar horários disponíveis
const fetchAvailableSlots = useCallback(async (doctorId: string, date: string) => {
if (!doctorId || !date) return;
setLoadingSlots(true);
setAvailableTimes([]);
try {
const disponibilidades = await AvailabilityService.listById(doctorId);
const consultas = await appointmentsService.search_appointment(
`doctor_id=eq.${doctorId}&scheduled_at=gte.${date}T00:00:00Z&scheduled_at=lt.${date}T23:59:59Z`
);
const diaJS = new Date(date).getDay();
const diaAPI = diaJS === 0 ? 7 : diaJS;
const disponibilidadeDia = disponibilidades.find(
(d: any) => getWeekdayNumber(d.weekday) === diaAPI
);
if (!disponibilidadeDia) {
toast({ title: "Nenhuma disponibilidade", description: "Nenhum horário para este dia." });
return setAvailableTimes([]);
}
const [startHour, startMin] = disponibilidadeDia.start_time.split(":").map(Number);
const [endHour, endMin] = disponibilidadeDia.end_time.split(":").map(Number);
const slot = disponibilidadeDia.slot_minutes || 30;
const horariosGerados: string[] = [];
let atual = new Date(date);
atual.setHours(startHour, startMin, 0, 0);
const end = new Date(date);
end.setHours(endHour, endMin, 0, 0);
while (atual <= end) {
horariosGerados.push(atual.toTimeString().slice(0, 5));
atual = new Date(atual.getTime() + slot * 60000);
}
const ocupados = (consultas || []).map((c: any) =>
String(c.scheduled_at).split("T")[1]?.slice(0, 5)
);
const livres = horariosGerados.filter((h) => !ocupados.includes(h));
setAvailableTimes(livres);
} catch (err) {
console.error(err);
toast({ title: "Erro", description: "Falha ao carregar horários." });
} finally {
setLoadingSlots(false);
}
}, []);
useEffect(() => {
if (selectedDoctor && selectedDate) fetchAvailableSlots(selectedDoctor, selectedDate);
}, [selectedDoctor, selectedDate, fetchAvailableSlots]);
// 🔹 Submeter agendamento
// 🔹 Submeter agendamento
// 🔹 Submeter agendamento
// 🔹 Submeter agendamento
// 🔹 Submeter agendamento
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const isSecretaryLike = ["secretaria", "admin", "gestor"].includes(role);
const patientId = isSecretaryLike ? selectedPatient : userId;
if (!patientId || !selectedDoctor || !selectedDate || !selectedTime) {
toast({ title: "Campos obrigatórios", description: "Preencha todos os campos." });
return;
}
try {
const body = {
doctor_id: selectedDoctor,
patient_id: patientId,
scheduled_at: `${selectedDate}T${selectedTime}:00`,
duration_minutes: Number(duracao),
notes,
appointment_type: tipoConsulta,
};
// ✅ mantém o fluxo original de criação (funcional)
await appointmentsService.create(body);
const dateFormatted = selectedDate.split("-").reverse().join("/");
toast({
title: "Consulta agendada!",
description: `Consulta marcada para ${dateFormatted} às ${selectedTime} com o(a) médico(a) ${
doctors.find((d) => d.id === selectedDoctor)?.full_name || ""
}.`,
});
let phoneNumber = "+5511999999999"; // fallback
try {
if (isSecretaryLike) {
// Secretária/admin → telefone do paciente selecionado
const patient = patients.find((p: any) => p.id === patientId);
// Pacientes criados no sistema podem ter phone ou phone_mobile
const rawPhone = patient?.phone || patient?.phone_mobile || null;
if (rawPhone) phoneNumber = rawPhone;
} else {
// Paciente → telefone vem do perfil do próprio usuário logado
const me = await usersService.getMe();
const rawPhone =
me?.profile?.phone ||
(typeof me?.profile === "object" && "phone_mobile" in me.profile ? (me.profile as any).phone_mobile : null) ||
(typeof me === "object" && "user_metadata" in me ? (me as any).user_metadata?.phone : null) ||
null;
if (rawPhone) phoneNumber = rawPhone;
}
// 🔹 Normaliza para formato internacional (+55)
if (phoneNumber) {
phoneNumber = phoneNumber.replace(/\D/g, "");
if (!phoneNumber.startsWith("55")) phoneNumber = `55${phoneNumber}`;
phoneNumber = `+${phoneNumber}`;
}
console.log("📞 Telefone usado:", phoneNumber);
} catch (err) {
console.warn("⚠️ Não foi possível obter telefone do paciente:", err);
}
// 💬 envia o SMS de confirmação
// 💬 Envia o SMS de lembrete (sem mostrar nada ao paciente)
// 💬 Envia o SMS de lembrete (somente loga no console, não mostra no sistema)
try {
const smsRes = await smsService.sendSms({
phone_number: phoneNumber,
message: `Lembrete: sua consulta é em ${dateFormatted} às ${selectedTime} na Clínica MediConnect.`,
patient_id: patientId,
});
if (smsRes?.success) {
console.log("✅ SMS enviado com sucesso:", smsRes.message_sid);
} else {
console.warn("⚠️ Falha no envio do SMS:", smsRes);
}
} catch (smsErr) {
console.error("❌ Erro ao enviar SMS:", smsErr);
}
// 🧹 limpa os campos
setSelectedDoctor("");
setSelectedDate("");
setSelectedTime("");
setNotes("");
setSelectedPatient("");
} catch (err) {
console.error("❌ Erro ao agendar consulta:", err);
toast({ title: "Erro", description: "Falha ao agendar consulta." });
}
};
// 🔹 Tooltip no calendário
useEffect(() => {
const cont = calendarRef.current;
if (!cont) return;
const onMove = (ev: MouseEvent) => {
const target = ev.target as HTMLElement | null;
const btn = target?.closest("button");
if (!btn) return setTooltip(null);
const aria = btn.getAttribute("aria-label") || btn.textContent || "";
const parsed = new Date(aria);
if (isNaN(parsed.getTime())) return setTooltip(null);
const key = format(getBrazilDate(parsed), "yyyy-MM-dd");
const count = availabilityCounts[key] ?? 0;
setTooltip({
x: ev.pageX + 10,
y: ev.pageY + 10,
text: `${count} horário${count !== 1 ? "s" : ""} disponíveis`,
});
};
const onLeave = () => setTooltip(null);
cont.addEventListener("mousemove", onMove);
cont.addEventListener("mouseleave", onLeave);
return () => {
cont.removeEventListener("mousemove", onMove);
cont.removeEventListener("mouseleave", onLeave);
};
}, [availabilityCounts]);
return (
<div className="max-w-6xl mx-auto space-y-4 px-4">
<h1 className="text-2xl font-semibold">Agendar Consulta</h1>
<Card className="border rounded-xl shadow-sm">
<CardHeader>
<CardTitle>Dados da Consulta</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-3">
{/* Se secretária/gestor/admin → mostrar campo Paciente */}
{["secretaria", "gestor", "admin"].includes(role) && (
<div>
<Label>Paciente</Label>
<Select value={selectedPatient} onValueChange={setSelectedPatient}>
<SelectTrigger>
<SelectValue placeholder="Selecione o paciente" />
</SelectTrigger>
<SelectContent>
{patients.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.full_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label>Médico</Label>
<Select value={selectedDoctor} onValueChange={setSelectedDoctor}>
<SelectTrigger>
<SelectValue placeholder="Selecione o médico" />
</SelectTrigger>
<SelectContent>
{loadingDoctors ? (
<SelectItem value="loading" disabled>Carregando...</SelectItem>
) : (
doctors.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.full_name} {d.specialty}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div>
<Label>Data</Label>
<div ref={calendarRef} className="rounded-lg border p-2">
<CalendarShadcn
mode="single"
disabled={!selectedDoctor}
selected={selectedDate ? new Date(selectedDate + "T12:00:00") : undefined}
onSelect={(date) => {
if (!date) return;
const formatted = format(new Date(date.getTime() + 12 * 60 * 60 * 1000), "yyyy-MM-dd");
setSelectedDate(formatted);
}}
/>
</div>
</div>
<div>
<Label>Observações</Label>
<Textarea
placeholder="Instruções para o médico..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="mt-2"
/>
</div>
</div>
<div className="space-y-3">
<Card className="shadow-md rounded-xl bg-blue-50 border border-blue-200">
<CardHeader>
<CardTitle className="text-blue-700">Resumo</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-gray-900 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-blue-600" />
<div className="text-xs">
{selectedDoctor
? doctors.find((d) => d.id === selectedDoctor)?.full_name
: "Médico"}
</div>
</div>
<div className="text-xs text-gray-600">
{tipoConsulta} {duracao} min
</div>
</div>
<div className="space-y-2">
<Label>Horário</Label>
<Select onValueChange={setSelectedTime} disabled={loadingSlots || availableTimes.length === 0}>
<SelectTrigger>
<SelectValue
placeholder={
loadingSlots
? "Carregando horários..."
: availableTimes.length === 0
? "Nenhum horário disponível"
: "Selecione o horário"
}
/>
</SelectTrigger>
<SelectContent>
{availableTimes.map((h) => (
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{notes && (
<div className="flex items-start gap-2 text-sm">
<StickyNote className="h-4 w-4" />
<div className="italic text-gray-700">{notes}</div>
</div>
)}
</CardContent>
</Card>
<div className="flex gap-2">
<Button
type="submit"
className="w-full md:w-auto px-4 py-1.5 text-sm bg-blue-600 text-white hover:bg-blue-700"
disabled={!selectedDoctor || !selectedDate || !selectedTime}
>
Agendar
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setSelectedDoctor("");
setSelectedDate("");
setSelectedTime("");
setNotes("");
setSelectedPatient("");
}}
className="px-3"
>
Limpar
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
{tooltip && (
<div
style={{
position: "absolute",
left: tooltip.x,
top: tooltip.y,
zIndex: 60,
background: "rgba(0,0,0,0.85)",
color: "white",
padding: "6px 8px",
borderRadius: 6,
fontSize: 12,
}}
>
{tooltip.text}
</div>
)}
</div>
);
}

View File

@ -1,279 +0,0 @@
// Caminho: app/(secretary)/layout.tsx (ou o caminho do seu arquivo)
"use client";
import type React from "react";
import { useState, useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import Cookies from "js-cookie";
import { api } from "@/services/api.mjs"; // Importando nosso cliente de API central
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Search,
Bell,
Calendar,
Clock,
User,
LogOut,
Home,
ChevronLeft,
ChevronRight,
} from "lucide-react";
interface SecretaryData {
id: string;
name: string;
email: string;
phone: string;
cpf: string;
employeeId: string;
department: string;
permissions: object;
}
interface SecretaryLayoutProps {
children: React.ReactNode;
}
export default function SecretaryLayout({ children }: SecretaryLayoutProps) {
const [secretaryData, setSecretaryData] = useState<SecretaryData | null>(
null
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
const userInfoString = localStorage.getItem("user_info");
// --- ALTERAÇÃO 1: Buscando o token no localStorage ---
const token = localStorage.getItem("token");
if (userInfoString && token) {
const userInfo = JSON.parse(userInfoString);
setSecretaryData({
id: userInfo.id || "",
name: userInfo.user_metadata?.full_name || "Secretária",
email: userInfo.email || "",
department: userInfo.user_metadata?.department || "Atendimento",
phone: userInfo.phone || "",
cpf: "",
employeeId: "",
permissions: {},
});
} else {
// --- ALTERAÇÃO 2: Redirecionando para o login central ---
router.push("/login");
}
}, [router]);
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 1024) {
setSidebarCollapsed(true);
} else {
setSidebarCollapsed(false);
}
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const handleLogout = () => setShowLogoutDialog(true);
// --- ALTERAÇÃO 3: Função de logout completa e padronizada ---
const confirmLogout = async () => {
try {
// Chama a função centralizada para fazer o logout no servidor
await api.logout();
} catch (error) {
console.error("Erro ao tentar fazer logout no servidor:", error);
} finally {
// Limpeza completa e consistente do estado local
localStorage.removeItem("user_info");
localStorage.removeItem("token");
Cookies.remove("access_token"); // Limpeza de segurança
setShowLogoutDialog(false);
router.push("/"); // Redireciona para a página inicial
}
};
const cancelLogout = () => setShowLogoutDialog(false);
const menuItems = [
{ href: "/secretary/dashboard", icon: Home, label: "Dashboard" },
{ href: "/secretary/appointments", icon: Calendar, label: "Consultas" },
{ href: "/secretary/schedule", icon: Clock, label: "Agendar Consulta" },
{ href: "/secretary/pacientes", icon: User, label: "Pacientes" },
];
if (!secretaryData) {
return (
<div className="flex h-screen w-full items-center justify-center">
Carregando...
</div>
);
}
return (
<div className="min-h-screen bg-background flex">
{/* Sidebar */}
<div
className={`bg-card border-r border-border transition-all duration-300
${sidebarCollapsed ? "w-16" : "w-64"}
fixed left-0 top-0 h-screen flex flex-col z-10`}
>
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
{!sidebarCollapsed && (
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<div className="w-4 h-4 bg-primary-foreground rounded-sm"></div>
</div>
<span className="font-semibold text-foreground">
MediConnect
</span>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-1"
>
{sidebarCollapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</Button>
</div>
</div>
<nav className="flex-1 p-2 overflow-y-auto">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive =
pathname === item.href ||
(item.href !== "/" && pathname.startsWith(item.href));
return (
<Link key={item.href} href={item.href}>
<div
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && (
<span className="font-medium">{item.label}</span>
)}
</div>
</Link>
);
})}
</nav>
<div className="border-t p-4 mt-auto">
<div className="flex items-center space-x-3 mb-4">
<Avatar>
<AvatarImage src="/placeholder.svg?height=40&width=40" />
<AvatarFallback>
{secretaryData.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
{!sidebarCollapsed && (
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{secretaryData.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{secretaryData.email}
</p>
</div>
)}
</div>
<Button
variant="outline"
size="sm"
className={
sidebarCollapsed
? "w-full bg-transparent flex justify-center items-center p-2"
: "w-full bg-transparent"
}
onClick={handleLogout}
>
<LogOut className={sidebarCollapsed ? "h-5 w-5" : "mr-2 h-4 w-4"} />
{!sidebarCollapsed && "Sair"}
</Button>
</div>
</div>
{/* Main Content */}
<div
className={`flex-1 flex flex-col transition-all duration-300 ${
sidebarCollapsed ? "ml-16" : "ml-64"
}`}
>
<header className="bg-card border-b border-border px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1 max-w-md"></div>
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" className="relative">
<Bell className="w-5 h-5" />
<Badge className="absolute -top-1 -right-1 w-5 h-5 p-0 flex items-center justify-center bg-destructive text-destructive-foreground text-xs">
1
</Badge>
</Button>
</div>
</div>
</header>
<main className="flex-1 p-6">{children}</main>
</div>
{/* Logout confirmation dialog */}
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Confirmar Saída</DialogTitle>
<DialogDescription>
Deseja realmente sair do sistema? Você precisará fazer login
novamente para acessar sua conta.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={cancelLogout}>
Cancelar
</Button>
<Button variant="destructive" onClick={confirmLogout}>
<LogOut className="mr-2 h-4 w-4" />
Sair
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,3 +1,5 @@
// CÓDIGO CORRIGIDO PARA: components/ui/button.tsx
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
@ -9,16 +11,11 @@ const buttonVariants = cva(
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive: 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
@ -35,25 +32,24 @@ const buttonVariants = cva(
},
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
export { Button, buttonVariants }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@ -0,0 +1,125 @@
"use client";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { CalendarCheck2, CalendarClock, ClipboardPlus, Home, LogOut, SquareUser } from "lucide-react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { usePathname } from "next/navigation";
import Link from "next/link";
interface UserData {
user_metadata: {
full_name: string;
};
app_metadata: {
user_role: string;
};
email: string;
}
interface Props {
userData: UserData;
sidebarCollapsed: boolean;
handleLogout: () => void;
isActive: boolean;
}
export default function SidebarUserSection({
userData,
sidebarCollapsed,
handleLogout,
isActive,
}: Props) {
const pathname = usePathname();
const menuItems: any[] = [
{ href: "/patient/schedule", icon: CalendarClock, label: "Agendar Consulta" },
{ href: "/patient/appointments", icon: CalendarCheck2, label: "Minhas Consultas" },
{ href: "/patient/reports", icon: ClipboardPlus, label: "Meus Laudos" },
{ href: "/patient/profile", icon: SquareUser, label: "Meus Dados" },
]
return (
<div className="border-t p-4 mt-auto">
{/* POPUP DE INFORMAÇÕES DO USUÁRIO */}
<Popover>
<PopoverTrigger asChild>
<div
className={`flex items-center space-x-3 mb-4 p-2 rounded-md transition-colors ${
isActive
? "cursor-pointer hover:bg-gray-100"
: "cursor-default pointer-events-none"
}`}>
<Avatar>
<AvatarImage src="/placeholder.svg?height=40&width=40" />
<AvatarFallback>
{userData.user_metadata.full_name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
{!sidebarCollapsed && (
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{userData.user_metadata.full_name}
</p>
<p className="text-xs text-gray-500 truncate">
{userData.app_metadata.user_role}
</p>
</div>
)}
</div>
</PopoverTrigger>
{/* Card flutuante */}
<PopoverContent
align="center"
side="top"
className="w-64 p-4 shadow-lg border bg-white"
>
<nav>
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link key={item.label} href={item.href}>
<div
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${
isActive
? "bg-blue-50 text-blue-600 border-r-2 border-blue-600"
: "text-gray-600 hover:bg-gray-50"
}`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && (
<span className="font-medium">{item.label}</span>
)}
</div>
</Link>
);
})}
</nav>
</PopoverContent>
</Popover>
{/* Botão de sair */}
<Button
variant="outline"
size="sm"
className={
sidebarCollapsed
? "w-full bg-transparent flex justify-center items-center p-2"
: "w-full bg-transparent"
}
onClick={handleLogout}
>
<LogOut className={sidebarCollapsed ? "h-5 w-5" : "mr-2 h-4 w-4"} />
{sidebarCollapsed && "Sair"}
</Button>
</div>
);
}

75
hooks/useAuthLayout.ts Normal file
View File

@ -0,0 +1,75 @@
// ARQUIVO COMPLETO PARA: hooks/useAuthLayout.ts
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { usersService } from '@/services/usersApi.mjs';
import { toast } from "@/hooks/use-toast";
interface UserLayoutData {
id: string;
name: string;
email: string;
roles: string[];
avatar_url?: string;
avatarFullUrl?: string;
}
interface UseAuthLayoutOptions {
requiredRole?: string;
}
export function useAuthLayout({ requiredRole }: UseAuthLayoutOptions = {}) {
const [user, setUser] = useState<UserLayoutData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
useEffect(() => {
const fetchUserData = async () => {
try {
const fullUserData = await usersService.getMe();
if (
requiredRole &&
!fullUserData.roles.includes(requiredRole) &&
!fullUserData.roles.includes('admin')
) {
console.error(`Acesso negado. Requer perfil '${requiredRole}', mas o usuário tem '${fullUserData.roles.join(', ')}'.`);
toast({
title: "Acesso Negado",
description: "Você não tem permissão para acessar esta página.",
variant: "destructive",
});
router.push('/');
return;
}
const avatarPath = fullUserData.profile.avatar_url;
// *** A CORREÇÃO ESTÁ AQUI ***
// Adicionamos o nome do bucket 'avatars' na URL final.
const avatarFullUrl = avatarPath
? `https://yuanqfswhberkoevtmfr.supabase.co/storage/v1/object/public/avatars/${avatarPath}`
: undefined;
setUser({
id: fullUserData.user.id,
name: fullUserData.profile.full_name || 'Usuário',
email: fullUserData.user.email,
roles: fullUserData.roles,
avatar_url: avatarPath,
avatarFullUrl: avatarFullUrl,
});
} catch (error) {
console.error("Falha na autenticação do layout:", error);
router.push("/login");
} finally {
setIsLoading(false);
}
};
fetchUserData();
}, [router, requiredRole]);
return { user, isLoading };
}

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;
}

BIN
public/Logo MedConnect.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

58
services/Sms.mjs Normal file
View File

@ -0,0 +1,58 @@
/**
* Serviço de SMS via Supabase Edge Function (sem backend)
* Usa o token JWT salvo no localStorage (chave: "token")
*/
const SUPABASE_FUNCTION_URL =
"https://yuanqfswhberkoevtmfr.supabase.co/functions/v1/send-sms";
export const smsService = {
/**
* Envia um SMS de lembrete via Twilio
* @param {Object} params
* @param {string} params.phone_number - Ex: +5511999999999
* @param {string} params.message - Mensagem de texto
* @param {string} [params.patient_id] - ID opcional do paciente
*/
async sendSms({ phone_number, message, patient_id }) {
try {
// 🔹 Busca o token salvo pelo login
const token = localStorage.getItem("token");
if (!token) {
console.error("❌ Nenhum token JWT encontrado no localStorage (chave: 'token').");
return { success: false, error: "Token JWT não encontrado." };
}
const body = JSON.stringify({
phone_number,
message,
patient_id,
});
console.log("[smsService] Enviando SMS para:", phone_number);
const response = await fetch(SUPABASE_FUNCTION_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`, // 🔑 autenticação Supabase
},
body,
});
const result = await response.json();
if (!response.ok) {
console.error("❌ Falha no envio do SMS:", result);
return { success: false, error: result };
}
console.log("✅ SMS enviado com sucesso:", result);
return result;
} catch (err) {
console.error("❌ Erro inesperado ao enviar SMS:", err);
return { success: false, error: err.message };
}
},
};

View File

@ -89,11 +89,20 @@ async function request(endpoint, options = {}) {
// --- CORREÇÃO 1: PARA O SUBMIT DO AGENDAMENTO ---
// Se a resposta for um sucesso de criação (201) ou sem conteúdo (204), não quebra.
if (response.status === 201 || response.status === 204) {
return null;
// --- CORREÇÃO: funções do Supabase retornam 200 ou 201, nunca queremos perder o body ---
if (response.status === 204) {
return null;
}
const text = await response.text();
try {
return JSON.parse(text);
} catch {
return text || null;
}
return response.json();
}
// Exportamos o objeto 'api' com os métodos que os componentes vão usar.
@ -106,4 +115,25 @@ export const api = {
patch: (endpoint, data, options) => request(endpoint, { method: "PATCH", body: JSON.stringify(data), ...options }),
delete: (endpoint, options) => request(endpoint, { method: "DELETE", ...options }),
logout: logout,
storage: {
async upload(bucket, path, file) {
const token = localStorage.getItem("token");
const response = await fetch(`${BASE_URL}/storage/v1/object/${bucket}/${path}`, {
method: 'POST',
headers: {
'Content-Type': file.type,
'apikey': API_KEY,
'Authorization': `Bearer ${token}`,
'x-upsert': 'true' // Isso faz com que o arquivo seja substituído se já existir
},
body: file,
});
if (!response.ok) {
const errorBody = await response.json();
throw new Error(`Erro no upload: ${errorBody.message}`);
}
return response.json();
}
},
};

View File

@ -45,4 +45,7 @@ export const appointmentsService = {
* @returns {Promise<object>} - Uma promessa que resolve com a resposta da API.
*/
delete: (id) => api.delete(`/rest/v1/appointments?id=eq.${id}`),
};

View File

@ -21,6 +21,10 @@ export const usersService = {
return await api.post(`/functions/v1/create-user-with-password`, data);
},
async getMeSimple() {
return await api.post(`/functions/v1/user-info`);
},
async full_data(user_id) {
if (!user_id) throw new Error("user_id é obrigatório");
@ -57,4 +61,40 @@ export const usersService = {
permissions,
};
},
};
async resetPassword(email) {
if (!email) throw new Error("Email é obrigatório para resetar a senha.");
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/recover`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
},
body: JSON.stringify({ email }),
}
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
console.error("Erro no resetPassword:", res.status, data);
throw new Error(`Erro ${res.status}: ${data.message || "Falha ao resetar senha."}`);
}
console.log("✅ Reset de senha:", data);
return data;
} catch (err) {
console.error("❌ Erro na chamada resetPassword:", err);
throw new Error(err.message || "Erro inesperado na recuperação de senha.");
}
},
};