Compare commits

..

No commits in common. "main" and "Users" have entirely different histories.
main ... Users

87 changed files with 7295 additions and 10763 deletions

View File

@ -1,2 +0,0 @@
{
}

View File

@ -10,7 +10,7 @@ export default function HomePage() {
<div className="text-center mb-12"> <div className="text-center mb-12">
<h1 className="text-4xl font-bold text-foreground mb-4">Central de Operações <br> <h1 className="text-4xl font-bold text-foreground mb-4">Central de Operações <br>
</br> </br>
MediConnect MedConnect
</h1> </h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto"> <p className="text-xl text-muted-foreground max-w-2xl mx-auto">

View File

@ -20,23 +20,17 @@ const AccessibilityContext = createContext<AccessibilityContextProps | undefined
export const AccessibilityProvider = ({ children }: { children: ReactNode }) => { export const AccessibilityProvider = ({ children }: { children: ReactNode }) => {
const [theme, setThemeState] = useState<Theme>(() => { const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window !== 'undefined') { if (typeof window === 'undefined') return 'light';
return (localStorage.getItem('accessibility-theme') as Theme) || 'light'; return (localStorage.getItem('accessibility-theme') as Theme) || 'light';
}
return 'light';
}); });
const [contrast, setContrastState] = useState<Contrast>(() => { const [contrast, setContrastState] = useState<Contrast>(() => {
if (typeof window !== 'undefined') { if (typeof window === 'undefined') return 'normal';
return (localStorage.getItem('accessibility-contrast') as Contrast) || 'normal'; return (localStorage.getItem('accessibility-contrast') as Contrast) || 'normal';
}
return 'normal';
}); });
const [fontSize, setFontSize] = useState<number>(() => { const [fontSize, setFontSize] = useState<number>(() => {
if (typeof window !== 'undefined') { if (typeof window === 'undefined') return 16;
const storedSize = localStorage.getItem('accessibility-font-size'); const storedSize = localStorage.getItem('accessibility-font-size');
return storedSize ? parseFloat(storedSize) : 16; return storedSize ? parseFloat(storedSize) : 16;
}
return 16;
}); });
useEffect(() => { useEffect(() => {

View File

@ -1,238 +0,0 @@
// ARQUIVO COMPLETO COM A INTERFACE CORRIGIDA: app/doctor/consultas/page.tsx
"use client";
import type React from "react";
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 { 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";
// 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;
}
export default function DoctorAppointmentsPage() {
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());
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;
const [appointmentsList, patientsList] = await Promise.all([
appointmentsService.search_appointment(`doctor_id=eq.${doctorId}&order=scheduled_at.asc`),
patientsService.list()
]);
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",
}));
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);
}
};
useEffect(() => {
if (user?.id) {
fetchAppointments(user.id);
}
}, [user]);
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);
});
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 bookedDays = useMemo(() => {
return allAppointments
.map(app => app.scheduled_at ? new Date(app.scheduled_at) : null)
.filter((date): date is Date => date !== null);
}, [allAppointments]);
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 });
};
const statusPT: Record<string, string> = {
confirmed: "Confirmada",
completed: "Concluída",
cancelled: "Cancelada",
requested: "Solicitada",
no_show: "oculta",
checked_in: "Aguardando",
};
const getStatusVariant = (status: EnrichedAppointment['status']) => {
switch (status) {
case "confirmed": case "checked_in": return "text-foreground bg-blue-100 hover:bg-blue-150";
case "completed": return "text-foreground bg-green-100 hover:bg-green-150";
case "cancelled": case "no_show": return "text-foreground bg-red-200 hover:bg-red-250";
case "requested": return "text-foreground bg-yellow-100 hover:bg-yellow-150";
default: return "border-gray bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90";
}
};
const handleCancel = async (id: string) => {
// ... (função sem alteração)
};
const handleReSchedule = (id: string) => {
// ... (função sem alteração)
};
if (isAuthLoading) {
return <Sidebar><div>Carregando...</div></Sidebar>;
}
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="outline" className={getStatusVariant(appointment.status)}>{statusPT[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>
{/* 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>
// *** FIM DA MUDANÇA NO CARD ***
);
})}
</div>
<Separator className="my-6" />
</div>
))
)}
</div>
</div>
</div>
</Sidebar>
);
}

View File

@ -1,274 +1,16 @@
"use client"; import DoctorLayout from "@/components/doctor-layout"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { import { Button } from "@/components/ui/button"
Card, import { Calendar, Clock, User, Plus } from "lucide-react"
CardContent, import Link from "next/link"
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Calendar, Clock, User, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import Link from "next/link";
import { useEffect, useState, useMemo } from "react"; // Adicionado useMemo
import { toast } from "@/hooks/use-toast";
import { useAuthLayout } from "@/hooks/useAuthLayout";
import { patientsService } from "@/services/patientsApi.mjs";
import { appointmentsService } from "@/services/appointmentsApi.mjs";
import { format, parseISO, isAfter, isSameMonth, startOfToday } from "date-fns";
import { ptBR } from "date-fns/locale";
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";
import WeeklyScheduleCard from "@/components/ui/WeeklyScheduleCard";
type Appointment = {
id: string;
doctor_id: string;
patient_id: string;
scheduled_at: string;
status: string;
};
type EnrichedAppointment = Appointment & {
patientName: string;
};
type Availability = {
id: string;
doctor_id: string;
weekday: string;
start_time: string;
end_time: string;
slot_minutes: number;
appointment_type: string;
active: boolean;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string | null;
};
type Schedule = {
weekday: object;
};
type Doctor = {
id: string;
user_id: string | null;
crm: string;
crm_uf: string;
specialty: string;
full_name: string;
cpf: string;
email: string;
phone_mobile: string | null;
phone2: string | null;
cep: string | null;
street: string | null;
number: string | null;
complement: string | null;
neighborhood: string | null;
city: string | null;
state: string | null;
birth_date: string | null;
rg: string | null;
active: boolean;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string | null;
max_days_in_advance: number;
rating: number | null;
};
interface UserPermissions {
isAdmin: boolean;
isManager: boolean;
isDoctor: boolean;
isSecretary: boolean;
isAdminOrManager: boolean;
}
interface UserData {
user: {
id: string;
email: string;
email_confirmed_at: string | null;
created_at: string | null;
last_sign_in_at: string | null;
};
profile: {
id: string;
full_name: string;
email: string;
phone: string;
avatar_url: string | null;
disabled: boolean;
created_at: string | null;
updated_at: string | null;
};
roles: string[];
permissions: UserPermissions;
}
interface Exception {
id: string;
doctor_id: string;
date: string;
start_time: string | null;
end_time: string | null;
kind: "bloqueio" | "disponibilidade";
reason: string | null;
created_at: string;
created_by: string;
}
type Patient = {
id: string;
full_name: string;
};
export default function DoctorDashboard() {
// --- CORREÇÃO CRÍTICA DO LOOP ---
// Usamos useMemo para garantir que o array de roles seja uma referência estável
// e não dispare o useEffect do useAuthLayout infinitamente.
const requiredRoles = useMemo(() => ['medico'], []);
const { user } = useAuthLayout({ requiredRole: requiredRoles });
const [loggedDoctor, setLoggedDoctor] = useState<Doctor | null>(null);
const [userData, setUserData] = useState<UserData>();
const [availability, setAvailability] = useState<any | null>(null);
const [exceptions, setExceptions] = useState<Exception[]>([]);
const [schedule, setSchedule] = useState<Record<string, { start: string; end: string }[]>>({});
const formatTime = (time?: string | null) => time?.split(":")?.slice(0, 2).join(":") ?? "";
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [exceptionToDelete, setExceptionToDelete] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [nextAppointment, setNextAppointment] = useState<EnrichedAppointment | null>(null);
const [monthlyCount, setMonthlyCount] = useState<number>(0);
const weekdaysPT: Record<string, string> = { sunday: "Domingo", monday: "Segunda", tuesday: "Terça", wednesday: "Quarta", thursday: "Quinta", friday: "Sexta", saturday: "Sábado" };
useEffect(() => {
const fetchData = async () => {
if (!user?.id) return;
try {
const doctorsList: Doctor[] = await doctorsService.list();
const currentDoctor = doctorsList.find(doc => doc.user_id === user.id);
if (!currentDoctor) {
setError("Perfil de médico não encontrado para este usuário.");
return;
}
setLoggedDoctor(currentDoctor);
const [appointmentsList, patientsList, availabilityList, exceptionsList] = await Promise.all([
appointmentsService.list(),
patientsService.list(),
AvailabilityService.list(),
exceptionsService.list()
]);
const patientsMap = new Map(patientsList.map((p: Patient) => [p.id, p.full_name]));
const doctorAppointments = appointmentsList
.filter((apt: Appointment) => apt.doctor_id === currentDoctor.id)
.map((apt: Appointment): EnrichedAppointment => ({
...apt,
patientName: String(patientsMap.get(apt.patient_id) || "Paciente Desconhecido"),
}));
const today = startOfToday();
const upcomingAppointments = doctorAppointments
.filter(apt => isAfter(parseISO(apt.scheduled_at), today))
.sort((a, b) => new Date(a.scheduled_at).getTime() - new Date(b.scheduled_at).getTime());
setNextAppointment(upcomingAppointments[0] || null);
const activeStatuses = ['confirmed', 'requested', 'checked_in'];
const currentMonthAppointments = doctorAppointments.filter(apt =>
isSameMonth(parseISO(apt.scheduled_at), new Date()) && activeStatuses.includes(apt.status)
);
setMonthlyCount(currentMonthAppointments.length);
setAvailability(availabilityList.filter((d: any) => d.doctor_id === currentDoctor.id));
setExceptions(exceptionsList.filter((e: any) => e.doctor_id === currentDoctor.id));
} catch (e: any) {
setError(e?.message || "Erro ao buscar dados do dashboard");
console.error("Erro no dashboard:", e);
}
};
fetchData();
}, [user?.id]);
function findDoctorById(id: string, doctors: Doctor[]) {
return doctors.find((doctor) => doctor.user_id === id);
}
const openDeleteDialog = (exceptionId: string) => {
setExceptionToDelete(exceptionId);
setDeleteDialogOpen(true);
};
const handleDeleteException = async (ExceptionId: string) => {
try {
const res = await exceptionsService.delete(ExceptionId);
if (res && res.error) { throw new Error(res.message || "A API retornou um erro"); }
toast({ title: "Sucesso", description: "Exceção deletada com sucesso" });
setExceptions((prev: Exception[]) => prev.filter((p) => String(p.id) !== String(ExceptionId)));
} catch (e: any) {
toast({ title: "Erro", description: e?.message || "Não foi possível deletar a exceção" });
}
setDeleteDialogOpen(false);
setExceptionToDelete(null);
};
function formatAvailability(data: Availability[]) {
if (!data) return {};
const schedule = data.reduce((acc: any, item) => {
const { weekday, start_time, end_time } = item;
if (!acc[weekday]) acc[weekday] = [];
acc[weekday].push({ start: start_time, end: end_time });
return acc;
}, {} as Record<string, { start: string; end: string }[]>);
return schedule;
}
useEffect(() => {
if (availability) {
const formatted = formatAvailability(availability);
setSchedule(formatted);
}
}, [availability]);
export default function PatientDashboard() {
return ( return (
<Sidebar> <DoctorLayout>
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold">Dashboard</h1> <h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-muted-foreground"> <p className="text-gray-600">Bem-vindo ao seu portal de consultas médicas</p>
Bem-vindo ao seu portal de consultas médicas
</p>
</div> </div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
@ -278,21 +20,8 @@ export default function DoctorDashboard() {
<Calendar className="h-4 w-4 text-muted-foreground" /> <Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{nextAppointment ? ( <div className="text-2xl font-bold">02 out</div>
<> <p className="text-xs text-muted-foreground">Dr. Silva - 14:30</p>
<p className="text-2xl font-bold capitalize">
{nextAppointment.patientName} - {format(parseISO(nextAppointment.scheduled_at), "HH:mm")}
</p>
<div className="text-x text-muted-foreground">
{format(parseISO(nextAppointment.scheduled_at), "dd MMM", { locale: ptBR })}
</div>
</>
) : (
<>
<div className="text-2xl font-bold">Nenhuma</div>
<p className="text-xs text-muted-foreground">Sem próximas consultas</p>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
@ -302,8 +31,8 @@ export default function DoctorDashboard() {
<Clock className="h-4 w-4 text-muted-foreground" /> <Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{monthlyCount}</div> <div className="text-2xl font-bold">4</div>
<p className="text-xs text-muted-foreground">{monthlyCount === 1 ? '1 agendada' : `${monthlyCount} agendadas`}</p> <p className="text-xs text-muted-foreground">4 agendadas</p>
</CardContent> </CardContent>
</Card> </Card>
@ -326,7 +55,7 @@ export default function DoctorDashboard() {
<CardDescription>Acesse rapidamente as principais funcionalidades</CardDescription> <CardDescription>Acesse rapidamente as principais funcionalidades</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Link href="/doctor/consultas"> <Link href="/doctor/medicos/consultas">
<Button className="w-full justify-start"> <Button className="w-full justify-start">
<Calendar className="mr-2 h-4 w-4" /> <Calendar className="mr-2 h-4 w-4" />
Ver Minhas Consultas Ver Minhas Consultas
@ -335,82 +64,28 @@ export default function DoctorDashboard() {
</CardContent> </CardContent>
</Card> </Card>
</div>
<div className="grid md:grid-cols-1 gap-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Horário Semanal</CardTitle> <CardTitle>Próximas Consultas</CardTitle>
<CardDescription>Confira rapidamente a sua disponibilidade da semana</CardDescription> <CardDescription>Suas consultas agendadas</CardDescription>
</CardHeader> </CardHeader>
<CardContent>{loggedDoctor && <WeeklyScheduleCard doctorId={loggedDoctor.id} />}</CardContent> <CardContent>
</Card> <div className="space-y-4">
</div> <div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<div className="grid md:grid-cols-1 gap-6">
<Card>
<CardHeader>
<CardTitle>Exceções</CardTitle>
<CardDescription>Bloqueios e liberações eventuais de agenda</CardDescription>
</CardHeader>
<CardContent className="space-y-4 grid md:grid-cols-7 gap-2">
{exceptions && exceptions.length > 0 ? (
exceptions.map((ex: Exception) => {
const date = new Date(ex.date).toLocaleDateString("pt-BR", {
weekday: "long",
day: "2-digit",
month: "long",
timeZone: "UTC"
});
const startTime = formatTime(ex.start_time);
const endTime = formatTime(ex.end_time);
return (
<div key={ex.id} className="space-y-4">
<div className="flex flex-col items-center justify-between p-3 bg-primary/10 rounded-lg shadow-sm">
<div className="text-center">
<p className="font-semibold capitalize">{date}</p>
<p className="text-sm text-muted-foreground">
{startTime && endTime
? `${startTime} - ${endTime}`
: "Dia todo"}
</p>
</div>
<div className="text-center mt-2">
<p className={`text-sm font-medium ${ex.kind === "bloqueio" ? "text-destructive" : "text-primary"}`}>{ex.kind === "bloqueio" ? "Bloqueio" : "Liberação"}</p>
<p className="text-xs text-muted-foreground italic">{ex.reason || "Sem motivo especificado"}</p>
</div>
<div> <div>
<Button className="text-destructive" variant="outline" onClick={() => openDeleteDialog(String(ex.id))}> <p className="font-medium">Dr. João Santos</p>
<Trash2></Trash2> <p className="text-sm text-gray-600">Cardiologia</p>
</Button> </div>
<div className="text-right">
<p className="font-medium">02 out</p>
<p className="text-sm text-gray-600">14:30</p>
</div> </div>
</div> </div>
</div> </div>
);
})
) : (
<p className="text-sm text-muted-foreground italic col-span-7 text-center">Nenhuma exceção registrada.</p>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
<AlertDialogDescription>Tem certeza que deseja excluir esta exceção? Esta ação não pode ser desfeita.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={() => exceptionToDelete && handleDeleteException(exceptionToDelete)} className="bg-destructive hover:bg-destructive/90">
Excluir
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
</Sidebar> </DoctorLayout>
); )
} }

View File

@ -1,258 +0,0 @@
"use client";
import type React from "react";
import Link from "next/link";
import { useState, useEffect } from "react";
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 { Calendar as CalendarIcon, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "@/hooks/use-toast";
import { exceptionsService } from "@/services/exceptionApi.mjs";
// 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
import { doctorsService } from "@/services/doctorsApi.mjs";
import Sidebar from "@/components/Sidebar";
type Doctor = {
id: string;
user_id: string | null;
crm: string;
crm_uf: string;
specialty: string;
full_name: string;
cpf: string;
email: string;
phone_mobile: string | null;
phone2: string | null;
cep: string | null;
street: string | null;
number: string | null;
complement: string | null;
neighborhood: string | null;
city: string | null;
state: string | null;
birth_date: string | null;
rg: string | null;
active: boolean;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string | null;
max_days_in_advance: number;
rating: number | null;
}
// --- 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;
}
// 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 ExceptionPage() {
const [allAppointments, setAllAppointments] = useState<LocalStorageAppointment[]>([]);
const router = useRouter();
const [filteredAppointments, setFilteredAppointments] = useState<LocalStorageAppointment[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [loggedDoctor, setLoggedDoctor] = useState<Doctor>();
const [tipo, setTipo] = useState<string>("");
useEffect(() => {
const fetchData = async () => {
try {
const doctorsList: Doctor[] = await doctorsService.list();
const doctor = doctorsList[0];
// Salva no estado
setLoggedDoctor(doctor);
} catch (e: any) {
alert(`${e?.error} ${e?.message}`);
}
};
fetchData();
}, []);
// NOVO ESTADO 1: Armazena os dias com consultas (para o calendário)
const [bookedDays, setBookedDays] = useState<Date[]>([]);
// NOVO ESTADO 2: Armazena a data selecionada no calendário
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | undefined>(new Date());
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isLoading) return;
//setIsLoading(true);
const form = e.currentTarget;
const formData = new FormData(form);
const apiPayload = {
doctor_id: loggedDoctor?.id,
created_by: loggedDoctor?.user_id,
date: selectedCalendarDate ? format(selectedCalendarDate, "yyyy-MM-dd") : "",
start_time: ((formData.get("horarioEntrada")?formData.get("horarioEntrada") + ":00":null) as string) || null,
end_time: ((formData.get("horarioSaida")?formData.get("horarioSaida") + ":00":null) as string) || null,
kind: tipo || undefined,
reason: formData.get("reason"),
};
console.log(apiPayload);
try {
const res = await exceptionsService.create(apiPayload);
console.log(res);
let message = "Exceção cadastrada com sucesso";
try {
if (!res[0].id) {
throw new Error(`${res.error} ${res.message}` || "A API retornou erro");
} else {
console.log(message);
}
} catch {}
toast({
title: "Sucesso",
description: message,
});
router.push("/doctor/dashboard"); // adicionar página para listar a disponibilidade
} catch (err: any) {
toast({
title: "Erro",
description: err?.message || "Não foi possível cadastrar a exceção",
});
} finally {
setIsLoading(false);
}
};
const displayDate = selectedCalendarDate ? new Date(selectedCalendarDate).toLocaleDateString("pt-BR", { weekday: "long", day: "2-digit", month: "long" }) : "Selecione uma data";
return (
<Sidebar>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Adicione exceções</h1>
<p className="text-muted-foreground">Altere a disponibilidade em casos especiais para o Dr. João Silva</p>
</div>
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold text-foreground">Consultas para: {displayDate}</h2>
<Button 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-muted-foreground">Selecione a data desejada.</p>
</CardHeader>
<CardContent className="flex justify-center p-2">
<Calendar
mode="single"
selected={selectedCalendarDate}
onSelect={setSelectedCalendarDate}
autoFocus
// 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>
</Card>
</div>
{/* COLUNA 2: FORM PARA ADICIONAR EXCEÇÃO */}
<div className="lg:col-span-2 space-y-4">
{isLoading ? (
<p className="text-center text-lg text-muted-foreground">Carregando a agenda...</p>
) : !selectedCalendarDate ? (
<p className="text-center text-lg text-muted-foreground">Selecione uma data.</p>
) : (
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="bg-card rounded-lg border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-6">Dados </h2>
<div className="space-y-6">
<div className="grid md:grid-cols-5 gap-6">
<div>
<Label htmlFor="horarioEntrada" className="text-sm font-medium text-foreground">
Horario De Entrada
</Label>
<Input type="time" id="horarioEntrada" name="horarioEntrada" className="mt-1" />
</div>
<div>
<Label htmlFor="horarioSaida" className="text-sm font-medium text-foreground">
Horario De Saida
</Label>
<Input type="time" id="horarioSaida" name="horarioSaida" className="mt-1" />
</div>
</div>
<div>
<Label htmlFor="tipo" className="text-sm font-medium text-foreground">
Tipo
</Label>
<Select onValueChange={(value) => setTipo(value)} value={tipo}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="bloqueio">Bloqueio </SelectItem>
<SelectItem value="disponibilidade_extra">Disponibilidade extra</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="reason" className="text-sm font-medium text-foreground">
Motivo
</Label>
<Input type="textarea" id="reason" name="reason" required className="mt-1" />
</div>
</div>
</div>
<div className="flex justify-end gap-4">
<Link href="/doctor/disponibilidade">
<Button variant="outline">Cancelar</Button>
</Link>
<Button type="submit" className="bg-green-600 hover:bg-green-700 text-white">
Salvar Exceção
</Button>
</div>
</form>
)}
</div>
</div>
</div>
</Sidebar>
);
}

View File

@ -1,638 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
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 { AvailabilityService } from "@/services/availabilityApi.mjs";
import { usersService } from "@/services/usersApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs";
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 { 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)
interface UserPermissions {
isAdmin: boolean;
isManager: boolean;
isDoctor: boolean;
isSecretary: boolean;
isAdminOrManager: boolean;
}
interface UserData {
user: {
id: string;
email: string;
email_confirmed_at: string | null;
created_at: string | null;
last_sign_in_at: string | null;
};
profile: {
id: string;
full_name: string;
email: string;
phone: string;
avatar_url: string | null;
disabled: boolean;
created_at: string | null;
updated_at: string | null;
};
roles: string[];
permissions: UserPermissions;
}
type Doctor = {
id: string;
user_id: string | null;
crm: string;
crm_uf: string;
specialty: string;
full_name: string;
cpf: string;
email: string;
phone_mobile: string | null;
phone2: string | null;
cep: string | null;
street: string | null;
number: string | null;
complement: string | null;
neighborhood: string | null;
city: string | null;
state: string | null;
birth_date: string | null;
rg: string | null;
active: boolean;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string | null;
max_days_in_advance: number;
rating: number | null;
};
type Availability = {
id: string;
doctor_id: string;
weekday: string;
start_time: string;
end_time: string;
slot_minutes: number;
appointment_type: string;
active: boolean;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string | null;
};
export default function AvailabilityPage() {
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [schedule, setSchedule] = useState<
Record<string, { start: string; end: string }[]>
>({});
const formatTime = (time?: string | null) =>
time?.split(":")?.slice(0, 2).join(":") ?? "";
const [userData, setUserData] = useState<UserData>();
const [availability, setAvailability] = useState<any | null>(null);
const [doctorId, setDoctorId] = useState<string>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [modalidadeConsulta, setModalidadeConsulta] = useState<string>("");
const [selectedAvailability, setSelectedAvailability] =
useState<Availability | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const selectAvailability = (
schedule: { start: string; end: string },
day: string
) => {
const selected = availability.filter(
(a: Availability) =>
a.start_time === schedule.start &&
a.end_time === schedule.end &&
a.weekday === day
);
setSelectedAvailability(selected[0]);
};
const handleOpenModal = (
schedule: { start: string; end: string },
day: string
) => {
selectAvailability(schedule, day);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setSelectedAvailability(null);
setIsModalOpen(false);
};
const handleEdit = async (formData: {
start_time: "";
end_time: "";
slot_minutes: "";
appointment_type: "";
id: "";
}) => {
if (isLoading) return;
setIsLoading(true);
const apiPayload = {
start_time: formData.start_time,
end_time: formData.end_time,
slot_minutes: formData.slot_minutes,
appointment_type: formData.appointment_type,
};
console.log(apiPayload);
try {
const res = await AvailabilityService.update(formData.id, apiPayload);
console.log(res);
let message = "disponibilidade editada com sucesso";
try {
if (!res[0].id) {
throw new Error(
`${res.error} ${res.message}` || "A API retornou erro"
);
} else {
console.log(message);
}
} catch {}
toast({
title: "Sucesso",
description: message,
});
router.push("#");
} catch (err: any) {
toast({
title: "Erro",
description:
err?.message || "Não foi possível editar a disponibilidade",
});
} finally {
setIsLoading(false);
handleCloseModal();
fetchData();
}
};
// Mapa de tradução
const weekdaysPT: Record<string, string> = {
sunday: "Domingo",
monday: "Segunda",
tuesday: "Terça",
wednesday: "Quarta",
thursday: "Quinta",
friday: "Sexta",
saturday: "Sábado",
};
const fetchData = async () => {
try {
const loggedUser = await usersService.getMe();
const doctorList = await doctorsService.list();
setUserData(loggedUser);
const doctor = findDoctorById(loggedUser.user.id, doctorList);
setDoctorId(doctor?.id);
console.log(doctor);
// Busca disponibilidade
const availabilityList = await AvailabilityService.list();
// Filtra já com a variável local
const filteredAvail = availabilityList.filter(
(disp: { doctor_id: string }) => disp.doctor_id === doctor?.id
);
setAvailability(filteredAvail);
} catch (e: any) {
alert(`${e?.error} ${e?.message}`);
}
};
useEffect(() => {
fetchData();
}, []);
// Função auxiliar para filtrar o id do doctor correspondente ao user logado
function findDoctorById(id: string, doctors: Doctor[]) {
return doctors.find((doctor) => doctor.user_id === id);
}
function formatAvailability(data: Availability[]) {
// Agrupar os horários por dia da semana
const schedule = data.reduce((acc: any, item) => {
const { weekday, start_time, end_time } = item;
// Se o dia ainda não existe, cria o array
if (!acc[weekday]) {
acc[weekday] = [];
}
// Adiciona o horário do dia
acc[weekday].push({
start: start_time,
end: end_time,
});
return acc;
}, {} as Record<string, { start: string; end: string }[]>);
return schedule;
}
useEffect(() => {
if (availability) {
const formatted = formatAvailability(availability);
setSchedule(formatted);
}
}, [availability]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isLoading) return;
setIsLoading(true);
const form = e.currentTarget;
const formData = new FormData(form);
const apiPayload = {
doctor_id: doctorId,
weekday: (formData.get("weekday") as string) || undefined,
start_time: (formData.get("horarioEntrada") as string) || undefined,
end_time: (formData.get("horarioSaida") as string) || undefined,
slot_minutes: Number(formData.get("duracaoConsulta")) || undefined,
appointment_type: modalidadeConsulta || undefined,
active: true,
};
console.log(apiPayload);
try {
const res = await AvailabilityService.create(apiPayload);
console.log(res);
let message = "disponibilidade cadastrada com sucesso";
try {
if (!res[0].id) {
throw new Error(
`${res.error} ${res.message}` || "A API retornou erro"
);
} else {
console.log(message);
}
} catch {}
toast({
title: "Sucesso",
description: message,
});
router.push("#"); // adicionar página para listar a disponibilidade
} catch (err: any) {
toast({
title: "Erro",
description: err?.message || "Não foi possível criar a disponibilidade",
});
} finally {
fetchData()
setIsLoading(false);
}
};
const openDeleteDialog = (
schedule: { start: string; end: string },
day: string
) => {
selectAvailability(schedule, day);
setDeleteDialogOpen(true);
};
const handleDeleteAvailability = async (AvailabilityId: string) => {
try {
const res = await AvailabilityService.delete(AvailabilityId);
let message = "Disponibilidade deletada com sucesso";
try {
if (res) {
throw new Error(`${res.error} ${res.message}` || "A API retornou erro");
} else {
console.log(message);
}
} catch {}
toast({
title: "Sucesso",
description: message,
});
setAvailability((prev: Availability[]) => prev.filter((p) => String(p.id) !== String(AvailabilityId)));
} catch (e: any) {
toast({
title: "Erro",
description: e?.message || "Não foi possível deletar a disponibilidade",
});
}
fetchData()
setDeleteDialogOpen(false);
setSelectedAvailability(null);
};
return (
<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">
Definir Disponibilidade
</h1>
<p className="text-muted-foreground">
Defina sua disponibilidade para consultas{" "}
</p>
</div>
</div>
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="bg-card rounded-lg border p-6">
<h2 className="text-lg font-semibold mb-6">Dados </h2>
<div className="space-y-6">
{/* **AJUSTE DE RESPONSIVIDADE: DIAS DA SEMANA** */}
<div>
<Label className="text-sm font-medium">
Dia Da Semana
</Label>
{/* O antigo 'flex gap-4 mt-2 flex-nowrap' foi substituído por um grid responsivo: */}
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-x-4 gap-y-2 mt-2">
<label className="flex items-center gap-1">
<input
type="radio"
name="weekday"
value="monday"
className="text-primary"
/>
<span className="whitespace-nowrap text-sm">Segunda</span>
</label>
<label className="flex items-center gap-1">
<input
type="radio"
name="weekday"
value="tuesday"
className="text-primary"
/>
<span className="whitespace-nowrap text-sm">Terça</span>
</label>
<label className="flex items-center gap-1">
<input
type="radio"
name="weekday"
value="wednesday"
className="text-primary"
/>
<span className="whitespace-nowrap text-sm">Quarta</span>
</label>
<label className="flex items-center gap-1">
<input
type="radio"
name="weekday"
value="thursday"
className="text-primary"
/>
<span className="whitespace-nowrap text-sm">Quinta</span>
</label>
<label className="flex items-center gap-1">
<input
type="radio"
name="weekday"
value="friday"
className="text-primary"
/>
<span className="whitespace-nowrap text-sm">Sexta</span>
</label>
<label className="flex items-center gap-1">
<input
type="radio"
name="weekday"
value="saturday"
className="text-primary"
/>
<span className="whitespace-nowrap text-sm">Sábado</span>
</label>
<label className="flex items-center gap-1">
<input
type="radio"
name="weekday"
value="sunday"
className="text-primary"
/>
<span className="whitespace-nowrap text-sm">Domingo</span>
</label>
</div>
</div>
{/* **AJUSTE DE RESPONSIVIDADE: HORÁRIO E DURAÇÃO** */}
{/* Ajustado para 1 coluna em móvel, 2 em tablet e 5 em desktop (mantendo o que já existia com ajustes) */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-6">
<div>
<Label
htmlFor="horarioEntrada"
className="text-sm font-medium"
>
Horario De Entrada
</Label>
<Input
type="time"
id="horarioEntrada"
name="horarioEntrada"
required
className="mt-1"
/>
</div>
<div>
<Label
htmlFor="horarioSaida"
className="text-sm font-medium"
>
Horario De Saida
</Label>
<Input
type="time"
id="horarioSaida"
name="horarioSaida"
required
className="mt-1"
/>
</div>
<div>
<Label
htmlFor="duracaoConsulta"
className="text-sm font-medium whitespace-nowrap"
>
Duração da Consulta(min)
</Label>
<Input
type="number"
id="duracaoConsulta"
name="duracaoConsulta"
required
className="mt-1"
/>
</div>
{/* O Select de modalidade fica fora deste grid para ocupar uma linha inteira em telas menores, como no original, garantindo clareza */}
</div>
<div>
<Label
htmlFor="modalidadeConsulta"
className="text-sm font-medium"
>
Modalidade De Consulta
</Label>
<Select
onValueChange={(value) => setModalidadeConsulta(value)}
value={modalidadeConsulta}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="presencial">Presencial </SelectItem>
<SelectItem value="telemedicina">Telemedicina</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* **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 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>
<Button type="submit" className="bg-primary hover:bg-primary/90 w-full sm:w-auto">
Salvar Disponibilidade
</Button>
</div>
</div>
</form>
{/* **AJUSTE DE RESPONSIVIDADE: CARD DE HORÁRIO SEMANAL** */}
<div>
<Card>
<CardHeader>
<CardTitle>Horário Semanal</CardTitle>
<CardDescription>Confira ou altere a sua disponibilidade da semana</CardDescription>
</CardHeader>
{/* Define um grid responsivo para os dias da semana (1 coluna em móvel, 2 em pequeno, 3 em médio e 7 em telas grandes) */}
<CardContent className="space-y-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-2">
{["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"].map((day) => {
const times = schedule[day] || [];
return (
<div key={day} className="space-y-4">
<div className="flex flex-col items-center justify-start p-3 bg-primary/10 rounded-lg min-h-[76px] ">
<p className="font-medium capitalize text-center ">{weekdaysPT[day]}</p>
<div className="text-center w-full mt-2">
{times.length > 0 ? (
times.map((t, i) => (
<div key={i}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<p className="text-sm text-muted-foreground cursor-pointer rounded hover:text-accent-foreground hover:bg-muted transition-colors duration-150">
{formatTime(t.start)} - {formatTime(t.end)}
</p>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleOpenModal(t, day)}>
<Edit className="w-4 h-4 mr-2" />
Editar
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openDeleteDialog(t, day)}
className="text-destructive focus:bg-destructive/10 focus:text-destructive">
<Trash2 className="w-4 h-4 mr-2" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))
) : (
<p className="text-sm text-muted-foreground italic">Sem horário</p>
)}
</div>
</div>
</div>
);
})}
</CardContent>
</Card>
</div>
{/* AlertDialog e Modal de Edição (não precisam de grandes ajustes de layout, apenas garantindo que os componentes sejam responsivos internamente) */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
<AlertDialogDescription>Tem certeza que deseja excluir esta disponibilidade? Esta ação não pode ser desfeita.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={() => selectedAvailability && handleDeleteAvailability(selectedAvailability.id)} className="bg-destructive hover:bg-destructive/90">
Excluir
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<AvailabilityEditModal
availability={selectedAvailability}
isOpen={isModalOpen}
onClose={handleCloseModal}
onSubmit={handleEdit}
/>
</Sidebar>
);
}

View File

@ -1,31 +1,11 @@
// Caminho: app/(doctor)/login/page.tsx // Caminho: app/(doctor)/login/page.tsx
import { LoginForm } from "@/components/LoginForm"; import { LoginForm } from "@/components/LoginForm";
import Link from "next/link"; // Adicionado para o link de "Voltar"
export default function DoctorLoginPage() { export default function DoctorLoginPage() {
// NOTA: Esta página se tornou obsoleta com a criação do /login central.
// O ideal no futuro é deletar esta página e redirecionar os usuários.
return ( return (
<div className="min-h-screen bg-gradient-to-br from-green-50 via-white to-green-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-green-50 via-white to-green-50 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center"> <LoginForm title="Área do Médico" description="Acesse o sistema médico" role="doctor" themeColor="green" redirectPath="/doctor/medicos" />
<h1 className="text-3xl font-bold text-foreground mb-2">Área do Médico</h1>
<p className="text-muted-foreground mb-8">Acesse o sistema médico</p>
{/* --- ALTERAÇÃO PRINCIPAL AQUI --- */}
{/* Chamando o LoginForm unificado sem props desnecessárias */}
<LoginForm>
{/* Adicionamos um link de "Voltar" como filho (children) */}
<div className="mt-6 text-center text-sm">
<Link href="/">
<span className="font-semibold text-primary hover:underline cursor-pointer">
Voltar à página inicial
</span>
</Link>
</div>
</LoginForm>
</div>
</div> </div>
); );
} }

View File

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

View File

@ -1,233 +0,0 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Calendar } from "@/components/ui/calendar";
import { CalendarIcon } from "lucide-react";
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();
const params = useParams();
const patientId = params.id as string;
const laudoId = params.laudoId as string;
const [formData, setFormData] = useState<any>({});
const [loading, setLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
useEffect(() => {
if (laudoId) {
setLoading(true);
reportsApi.getReportById(laudoId)
.then((data: any) => {
console.log("Fetched report data:", data);
// The API now returns an array, get the first element
const reportData = Array.isArray(data) && data.length > 0 ? data[0] : null;
if (reportData) {
setFormData({
...reportData,
due_at: reportData.due_at ? new Date(reportData.due_at) : null,
});
}
})
.catch(error => {
console.error("Failed to fetch report details:", error);
// Here you could add a toast notification to inform the user
})
.finally(() => {
setLoading(false);
});
} else {
// If there's no laudoId, we shouldn't be in a loading state.
setLoading(false);
}
}, [laudoId]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { id, value } = e.target;
setFormData((prev: any) => ({ ...prev, [id]: value }));
};
const handleSelectChange = (id: string, value: string) => {
setFormData((prev: any) => ({ ...prev, [id]: value }));
};
const handleCheckboxChange = (id: string, checked: boolean) => {
setFormData((prev: any) => ({ ...prev, [id]: checked }));
};
const handleDateChange = (date: Date | undefined) => {
console.log("Date selected:", date);
if (date) {
setFormData((prev: any) => ({ ...prev, due_at: date }));
}
};
const handleDateSelect = (date: Date | undefined) => {
handleDateChange(date);
setIsDatePickerOpen(false); // Close the dialog after selection
};
const handleEditorChange = (html: string, json: object) => {
setFormData((prev: any) => ({
...prev,
content_html: html,
content_json: json
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const { id, patient_id, created_at, updated_at, created_by, updated_by, ...updateData } = formData;
await reportsApi.updateReport(laudoId, updateData);
// toast({ title: "Laudo atualizado com sucesso!" });
router.push(`/doctor/medicos/${patientId}/laudos`);
} catch (error) {
console.error("Failed to update laudo", error);
// toast({ title: "Erro ao atualizar laudo", variant: "destructive" });
} finally {
setIsSubmitting(false);
}
};
if (loading) {
return (
<Sidebar>
<div className="container mx-auto p-4">
<Card>
<CardHeader>
<Skeleton className="h-8 w-1/4" />
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2"><Skeleton className="h-4 w-1/6" /><Skeleton className="h-10 w-full" /></div>
<div className="space-y-2"><Skeleton className="h-4 w-1/6" /><Skeleton className="h-10 w-full" /></div>
<div className="space-y-2"><Skeleton className="h-4 w-1/6" /><Skeleton className="h-10 w-full" /></div>
<div className="space-y-2"><Skeleton className="h-4 w-1/6" /><Skeleton className="h-10 w-full" /></div>
</div>
<div className="space-y-2"><Skeleton className="h-4 w-1/6" /><Skeleton className="h-24 w-full" /></div>
<div className="space-y-2"><Skeleton className="h-4 w-1/6" /><Skeleton className="h-40 w-full" /></div>
<div className="flex justify-end space-x-2">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-24" />
</div>
</CardContent>
</Card>
</div>
</Sidebar>
)
}
return (
<Sidebar>
<div className="container mx-auto p-4">
<Card>
<CardHeader>
<CardTitle>Editar Laudo - {formData.order_number}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="exam">Exame</Label>
<Input id="exam" value={formData.exam || ''} onChange={handleInputChange} />
</div>
<div className="space-y-2">
<Label htmlFor="diagnosis">Diagnóstico</Label>
<Input id="diagnosis" value={formData.diagnosis || ''} onChange={handleInputChange} />
</div>
<div className="space-y-2">
<Label htmlFor="cid_code">Código CID</Label>
<Input id="cid_code" value={formData.cid_code || ''} onChange={handleInputChange} />
</div>
<div className="space-y-2">
<Label htmlFor="requested_by">Solicitado Por</Label>
<Input id="requested_by" value={formData.requested_by || ''} onChange={handleInputChange} />
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select onValueChange={(value) => handleSelectChange("status", value)} value={formData.status}>
<SelectTrigger>
<SelectValue placeholder="Selecione o status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Rascunho</SelectItem>
<SelectItem value="final">Finalizado</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="due_at">Data de Vencimento</Label>
<Dialog open={isDatePickerOpen} onOpenChange={setIsDatePickerOpen}>
<DialogTrigger asChild>
<Button variant={"outline"} className="w-full justify-start text-left font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{formData.due_at ? format(new Date(formData.due_at), "PPP") : <span>Escolha uma data</span>}
</Button>
</DialogTrigger>
<DialogContent className="w-auto p-0">
<Calendar
mode="single"
selected={formData.due_at ? new Date(formData.due_at) : undefined}
onSelect={handleDateSelect}
initialFocus
/>
</DialogContent>
</Dialog>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="conclusion">Conclusão</Label>
<Textarea id="conclusion" value={formData.conclusion || ''} onChange={handleInputChange} />
</div>
<div className="space-y-2">
<Label>Conteúdo do Laudo</Label>
<div className="rounded-md border border-input">
<TiptapEditor content={formData.content_html || ''} onChange={handleEditorChange} />
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Checkbox id="hide_date" checked={formData.hide_date} onCheckedChange={(checked) => handleCheckboxChange("hide_date", !!checked)} />
<Label htmlFor="hide_date">Ocultar Data</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="hide_signature" checked={formData.hide_signature} onCheckedChange={(checked) => handleCheckboxChange("hide_signature", !!checked)} />
<Label htmlFor="hide_signature">Ocultar Assinatura</Label>
</div>
</div>
<div className="flex justify-end space-x-2">
<Button type="button" variant="outline" onClick={() => router.back()} disabled={isSubmitting}>
Cancelar
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Salvando..." : "Salvar Alterações"}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</Sidebar>
);
}

View File

@ -1,189 +0,0 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { CalendarIcon } from "lucide-react";
import { format } from "date-fns";
import TiptapEditor from "@/components/ui/tiptap-editor";
import { reportsApi } from "@/services/reportsApi.mjs";
import Sidebar from "@/components/Sidebar";
export default function NovoLaudoPage() {
const router = useRouter();
const params = useParams();
const patientId = params.id as string;
const [formData, setFormData] = useState({
order_number: "",
exam: "",
diagnosis: "",
conclusion: "",
cid_code: "",
content_html: "",
content_json: {}, // Added for the JSON content from the editor
status: "draft",
requested_by: "",
due_at: new Date(),
hide_date: false,
hide_signature: false,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { id, value } = e.target;
setFormData(prev => ({ ...prev, [id]: value }));
};
const handleSelectChange = (id: string, value: string) => {
setFormData(prev => ({ ...prev, [id]: value }));
};
const handleCheckboxChange = (id: string, checked: boolean) => {
setFormData(prev => ({ ...prev, [id]: checked }));
};
const handleDateChange = (date: Date | undefined) => {
if (date) {
setFormData(prev => ({ ...prev, due_at: date }));
}
};
// Updated to handle both HTML and JSON from the editor
const handleEditorChange = (html: string, json: object) => {
setFormData(prev => ({
...prev,
content_html: html,
content_json: json
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const laudoData = {
...formData,
patient_id: patientId,
due_at: formData.due_at.toISOString(), // Ensure date is in ISO format for the API
};
await reportsApi.createReport(laudoData);
// You can use a toast notification here for better user feedback
// toast({ title: "Laudo criado com sucesso!" });
router.push(`/doctor/medicos/${patientId}/laudos`);
} catch (error: any) {
console.error("Failed to create laudo", error);
// You can use a toast notification for errors
// toast({ title: "Erro ao criar laudo", description: error.message, variant: "destructive" });
} finally {
setIsSubmitting(false);
}
};
return (
<Sidebar>
<div className="container mx-auto p-4">
<Card>
<CardHeader>
<CardTitle>Criar Novo Laudo</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="exam">Exame</Label>
<Input id="exam" value={formData.exam} onChange={handleInputChange} />
</div>
<div className="space-y-2">
<Label htmlFor="diagnosis">Diagnóstico</Label>
<Input id="diagnosis" value={formData.diagnosis} onChange={handleInputChange} />
</div>
<div className="space-y-2">
<Label htmlFor="cid_code">Código CID</Label>
<Input id="cid_code" value={formData.cid_code} onChange={handleInputChange} />
</div>
<div className="space-y-2">
<Label htmlFor="requested_by">Solicitado Por</Label>
<Input id="requested_by" value={formData.requested_by} onChange={handleInputChange} />
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select onValueChange={(value) => handleSelectChange("status", value)} defaultValue={formData.status}>
<SelectTrigger>
<SelectValue placeholder="Selecione o status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Rascunho</SelectItem>
<SelectItem value="final">Finalizado</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="due_at">Data de Vencimento</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant={"outline"} className="w-full justify-start text-left font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{formData.due_at ? format(formData.due_at, "PPP") : <span>Escolha uma data</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar mode="single" selected={formData.due_at} onSelect={handleDateChange} initialFocus />
</PopoverContent>
</Popover>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="conclusion">Conclusão</Label>
<Textarea id="conclusion" value={formData.conclusion} onChange={handleInputChange} />
</div>
<div className="space-y-2">
<Label>Conteúdo do Laudo</Label>
<div className="rounded-md border border-input">
<TiptapEditor content={formData.content_html} onChange={handleEditorChange} />
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Checkbox id="hide_date" checked={formData.hide_date} onCheckedChange={(checked) => handleCheckboxChange("hide_date", !!checked)} />
<Label htmlFor="hide_date">Ocultar Data</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="hide_signature" checked={formData.hide_signature} onCheckedChange={(checked) => handleCheckboxChange("hide_signature", !!checked)} />
<Label htmlFor="hide_signature">Ocultar Assinatura</Label>
</div>
</div>
<div className="flex justify-end space-x-2">
<Button type="button" variant="outline" onClick={() => router.back()} disabled={isSubmitting}>
Cancelar
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Salvando..." : "Salvar Laudo"}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</Sidebar>
);
}

View File

@ -1,128 +1,60 @@
'use client'; "use client";
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { Button } from '@/components/ui/button'; import { useParams, useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import DoctorLayout from "@/components/doctor-layout";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Button } from "@/components/ui/button";
import Link from 'next/link'; import dynamic from "next/dynamic";
import { useParams } from 'next/navigation';
import { api } from '@/services/api.mjs';
import { reportsApi } from '@/services/reportsApi.mjs';
import Sidebar from '@/components/Sidebar';
export default function LaudosPage() { const Tiptap = dynamic(() => import("@/components/ui/tiptap-editor"), { ssr: false });
const [patient, setPatient] = useState(null);
const [laudos, setLaudos] = useState([]); export default function LaudoEditorPage() {
const [loading, setLoading] = useState(true); const [laudoContent, setLaudoContent] = useState("");
const [paciente, setPaciente] = useState<{ id: string; nome: string } | null>(null);
const params = useParams(); const params = useParams();
const patientId = params.id as string; const router = useRouter();
const pacienteId = params.id;
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage] = useState(5);
useEffect(() => { useEffect(() => {
if (patientId) { if (pacienteId) {
const fetchPatientAndLaudos = async () => { // Em um caso real, você faria uma chamada de API para buscar os dados do paciente
setLoading(true); setPaciente({ id: pacienteId as string, nome: `Paciente ${pacienteId}` });
try { setLaudoContent(`<p>Laudo para o paciente ${paciente?.nome || ""}</p>`);
const patientData = await api.get(`/rest/v1/patients?id=eq.${patientId}&select=*`).then(r => r?.[0]);
setPatient(patientData);
const laudosData = await reportsApi.getReports(patientId);
setLaudos(laudosData);
} catch (error) {
console.error("Failed to fetch data:", error);
} finally {
setLoading(false);
} }
}, [pacienteId, paciente?.nome]);
const handleSave = () => {
console.log("Salvando laudo para o paciente ID:", pacienteId);
console.log("Conteúdo:", laudoContent);
// Aqui você implementaria a lógica para salvar o laudo no backend
alert("Laudo salvo com sucesso!");
}; };
fetchPatientAndLaudos(); const handleContentChange = (richText: string) => {
} setLaudoContent(richText);
}, [patientId]); };
const indexOfLastItem = currentPage * itemsPerPage; const handleCancel = () => {
const indexOfFirstItem = indexOfLastItem - itemsPerPage; router.back();
const currentItems = laudos.slice(indexOfFirstItem, indexOfLastItem); };
const totalPages = Math.ceil(laudos.length / itemsPerPage);
const paginate = (pageNumber) => setCurrentPage(pageNumber);
return ( return (
<Sidebar> <DoctorLayout>
<div className="container mx-auto p-4"> <div className="space-y-6">
{loading ? ( <div>
<p>Carregando...</p> <h1 className="text-2xl font-bold text-gray-900">Editor de Laudo</h1>
) : ( {paciente && <p className="text-gray-600">Editando laudo de: {paciente.nome}</p>}
<>
{patient && (
<Card className="mb-4">
<CardHeader>
<CardTitle>Informações do Paciente</CardTitle>
</CardHeader>
<CardContent>
<p><strong>Nome:</strong> {patient.full_name}</p>
<p><strong>Email:</strong> {patient.email}</p>
<p><strong>Telefone:</strong> {patient.phone_mobile}</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Laudos do Paciente</CardTitle>
<Link href={`/doctor/medicos/${patientId}/laudos/novo`}>
<Button>Criar Novo Laudo</Button>
</Link>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead> do Pedido</TableHead>
<TableHead>Exame</TableHead>
<TableHead>Diagnóstico</TableHead>
<TableHead>Status</TableHead>
<TableHead>Data de Criação</TableHead>
<TableHead>Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentItems.length > 0 ? (
currentItems.map((laudo) => (
<TableRow key={laudo.id}>
<TableCell>{laudo.order_number}</TableCell>
<TableCell>{laudo.exam}</TableCell>
<TableCell>{laudo.diagnosis}</TableCell>
<TableCell>{laudo.status}</TableCell>
<TableCell>{new Date(laudo.created_at).toLocaleDateString()}</TableCell>
<TableCell>
<Link href={`/doctor/medicos/${patientId}/laudos/${laudo.id}/editar`}>
<Button variant="outline" size="sm">Editar</Button>
</Link>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="text-center">Nenhum laudo encontrado.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="flex justify-center space-x-2 mt-4 p-4">
{Array.from({ length: totalPages }, (_, i) => (
<Button key={i} onClick={() => paginate(i + 1)} variant={currentPage === i + 1 ? 'default' : 'outline'}>
{i + 1}
</Button>
))}
</div> </div>
)}
</CardContent> <div className="bg-white rounded-lg border border-gray-200 p-6">
</Card> <Tiptap content={laudoContent} onChange={handleContentChange} />
</>
)}
</div> </div>
</Sidebar>
<div className="flex justify-end gap-4">
<Button variant="outline" onClick={handleCancel}>Cancelar</Button>
<Button onClick={handleSave}>Salvar Laudo</Button>
</div>
</div>
</DoctorLayout>
); );
} }

View File

@ -0,0 +1,272 @@
"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 { 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 { toast } from "sonner";
// IMPORTAR O COMPONENTE CALENDÁRIO DA SHADCN
import { Calendar } from "@/components/ui/calendar";
import { format } from "date-fns"; // Usaremos o date-fns para formatação e comparação de datas
const APPOINTMENTS_STORAGE_KEY = "clinic-appointments";
// --- TIPAGEM DA CONSULTA SALVA NO LOCALSTORAGE ---
interface LocalStorageAppointment {
id: number;
patientName: string;
doctor: string;
specialty: string;
date: string; // Data no formato YYYY-MM-DD
time: string; // Hora no formato HH:MM
status: "agendada" | "confirmada" | "cancelada" | "realizada";
location: string;
phone: string;
}
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);
// NOVO ESTADO 1: Armazena os dias com consultas (para o calendário)
const [bookedDays, setBookedDays] = useState<Date[]>([]);
// NOVO ESTADO 2: Armazena a data selecionada no calendário
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | undefined>(new Date());
useEffect(() => {
loadAppointments();
}, []);
// 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');
// 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
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));
setFilteredAppointments(todayAppointments);
}
}, [allAppointments, selectedCalendarDate]);
const loadAppointments = () => {
setIsLoading(true);
try {
const storedAppointmentsRaw = localStorage.getItem(APPOINTMENTS_STORAGE_KEY);
const allAppts: LocalStorageAppointment[] = storedAppointmentsRaw ? JSON.parse(storedAppointmentsRaw) : [];
// ***** NENHUM FILTRO POR MÉDICO AQUI (Como solicitado) *****
const appointmentsToShow = allAppts;
// 1. EXTRAI E PREPARA AS DATAS PARA O CALENDÁRIO
const uniqueBookedDates = Array.from(new Set(appointmentsToShow.map(app => app.date)));
// 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'));
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);
}
};
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";
}
};
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>
</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>
</div>
</div>
</DoctorLayout>
);
}

View File

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

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState } from "react";
import DoctorLayout from "@/components/doctor-layout";
import Link from "next/link"; import Link from "next/link";
import { import {
DropdownMenu, DropdownMenu,
@ -8,19 +9,9 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Eye, Edit, Calendar, Trash2, Loader2, MoreVertical, Filter } from "lucide-react"; import { Eye, Edit, Calendar, Trash2 } from "lucide-react";
import { api } from "@/services/api.mjs"; import { api } from "@/services/api.mjs";
import { PatientDetailsModal } from "@/components/ui/patient-details-modal"; import { PatientDetailsModal } from "@/components/ui/patient-details-modal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import Sidebar from "@/components/Sidebar";
interface Paciente { interface Paciente {
id: string; id: string;
@ -41,9 +32,6 @@ interface Paciente {
complement?: string; complement?: string;
neighborhood?: string; neighborhood?: string;
cep?: string; cep?: string;
// NOVOS CAMPOS PARA O FILTRO
convenio?: string;
vip?: string;
} }
export default function PacientesPage() { export default function PacientesPage() {
@ -53,81 +41,6 @@ export default function PacientesPage() {
const [selectedPatient, setSelectedPatient] = useState<Paciente | null>(null); const [selectedPatient, setSelectedPatient] = useState<Paciente | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
// --- ESTADOS DOS FILTROS ---
const [searchTerm, setSearchTerm] = useState("");
const [convenioFilter, setConvenioFilter] = useState("todos");
const [vipFilter, setVipFilter] = useState("todos");
// --- Lógica de Filtragem ---
const filteredPacientes = pacientes.filter((p) => {
// 1. Filtro de Texto (Nome ou Telefone)
const searchLower = searchTerm.toLowerCase();
const matchesSearch = p.nome?.toLowerCase().includes(searchLower) || p.telefone?.includes(searchLower);
// 2. Filtro de Convênio
// Se for "todos", passa. Se não, verifica se o convênio do paciente é igual ao selecionado.
const matchesConvenio = convenioFilter === "todos" || (p.convenio?.toLowerCase() === convenioFilter);
// 3. Filtro VIP
// Se for "todos", passa. Se não, verifica se o status VIP é igual ao selecionado.
const matchesVip = vipFilter === "todos" || (p.vip?.toLowerCase() === vipFilter);
return matchesSearch && matchesConvenio && matchesVip;
});
// --- Lógica de Paginação ---
const [itemsPerPage, setItemsPerPage] = useState(10);
const [currentPage, setCurrentPage] = useState(1);
// Resetar página quando qualquer filtro mudar
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, convenioFilter, vipFilter, itemsPerPage]);
const totalPages = Math.ceil(filteredPacientes.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredPacientes.slice(indexOfFirstItem, indexOfLastItem);
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
const goToPrevPage = () => {
setCurrentPage((prev) => Math.max(1, prev - 1));
};
const goToNextPage = () => {
setCurrentPage((prev) => Math.min(totalPages, prev + 1));
};
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);
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);
const handleItemsPerPageChange = (value: string) => {
setItemsPerPage(Number(value));
setCurrentPage(1);
};
const handleOpenModal = (patient: Paciente) => { const handleOpenModal = (patient: Paciente) => {
setSelectedPatient(patient); setSelectedPatient(patient);
setIsModalOpen(true); setIsModalOpen(true);
@ -138,196 +51,112 @@ export default function PacientesPage() {
setIsModalOpen(false); setIsModalOpen(false);
}; };
const formatDate = (dateString: string | null | undefined) => { const formatDate = (dateString: string) => {
if (!dateString) return "N/A"; if (!dateString) return "";
try {
const date = new Date(dateString); const date = new Date(dateString);
return new Intl.DateTimeFormat("pt-BR").format(date); return new Intl.DateTimeFormat('pt-BR').format(date);
} catch (e) {
return dateString;
}
}; };
const fetchPacientes = useCallback(async () => { const [itemsPerPage, setItemsPerPage] = useState(5);
const [currentPage, setCurrentPage] = useState(1);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = pacientes.slice(indexOfFirstItem, indexOfLastItem);
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
useEffect(() => {
async function fetchPacientes() {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const json = await api.get("/rest/v1/patients"); const json = await api.get("/rest/v1/patients");
const items = Array.isArray(json) const items = Array.isArray(json) ? json : (Array.isArray(json?.data) ? json.data : []);
? json
: Array.isArray(json?.data)
? json.data
: [];
const mapped: Paciente[] = items.map((p: any) => ({ const mapped = items.map((p: any) => ({
id: String(p.id ?? ""), id: String(p.id ?? ""),
nome: p.full_name ?? "", nome: p.full_name ?? "",
telefone: p.phone_mobile ?? "N/A", telefone: p.phone_mobile ?? "",
cidade: p.city ?? "N/A", cidade: p.city ?? "",
estado: p.state ?? "N/A", estado: p.state ?? "",
ultimoAtendimento: formatDate(p.created_at), ultimoAtendimento: formatDate(p.created_at) ?? "",
proximoAtendimento: "N/A", proximoAtendimento: "",
email: p.email ?? "N/A", email: p.email ?? "",
birth_date: p.birth_date ?? "N/A", birth_date: p.birth_date ?? "",
cpf: p.cpf ?? "N/A", cpf: p.cpf ?? "",
blood_type: p.blood_type ?? "N/A", blood_type: p.blood_type ?? "",
weight_kg: p.weight_kg ?? 0, weight_kg: p.weight_kg ?? 0,
height_m: p.height_m ?? 0, height_m: p.height_m ?? 0,
street: p.street ?? "N/A", street: p.street ?? "",
number: p.number ?? "N/A", number: p.number ?? "",
complement: p.complement ?? "N/A", complement: p.complement ?? "",
neighborhood: p.neighborhood ?? "N/A", neighborhood: p.neighborhood ?? "",
cep: p.cep ?? "N/A", cep: p.cep ?? "",
// ⚠️ ATENÇÃO: Verifique o nome real desses campos na sua API
// Se a API não retorna, estou colocando valores padrão para teste
convenio: p.insurance_plan || p.convenio || "Unimed", // Exemplo: mapeie o campo correto
vip: p.is_vip ? "Sim" : "Não", // Exemplo: se for booleano converta para string
})); }));
setPacientes(mapped); setPacientes(mapped);
} catch (e: any) { } catch (e: any) {
console.error("Erro ao carregar pacientes:", e);
setError(e?.message || "Erro ao carregar pacientes"); setError(e?.message || "Erro ao carregar pacientes");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}
fetchPacientes();
}, []); }, []);
useEffect(() => {
fetchPacientes();
}, [fetchPacientes]);
return ( return (
<Sidebar> <DoctorLayout>
<div className="space-y-6 px-2 sm:px-4 md:px-6"> <div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Pacientes</h1> <h1 className="text-2xl font-bold text-foreground">Pacientes</h1>
<p className="text-muted-foreground text-sm sm:text-base"> <p className="text-muted-foreground">Lista de pacientes vinculados</p>
Lista de pacientes vinculados
</p>
</div>
</div> </div>
{/* --- BARRA DE PESQUISA COM FILTROS ATIVOS --- */} <div className="bg-card rounded-lg border border-border">
<div className="flex flex-col md:flex-row gap-4 items-center p-2 border border-border rounded-lg bg-card shadow-sm"> <div className="overflow-x-auto">
<table className="w-full">
{/* Input de Busca */}
<div className="flex items-center gap-3 flex-1 w-full px-2">
<Filter className="w-5 h-5 text-muted-foreground flex-shrink-0" />
<Input
type="text"
placeholder="Buscar por nome ou telefone..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="border-0 focus-visible:ring-0 shadow-none bg-transparent px-0 h-auto text-base placeholder:text-muted-foreground"
/>
</div>
{/* Filtros e Paginação */}
<div className="flex flex-wrap items-center gap-4 w-full md:w-auto px-2 border-t md:border-t-0 md:border-l border-border pt-2 md:pt-0 justify-end">
{/* FILTRO CONVÊNIO */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium whitespace-nowrap text-muted-foreground hidden lg:inline">Convênio</span>
<Select value={convenioFilter} onValueChange={setConvenioFilter}>
<SelectTrigger className="w-[100px] h-8 border-border bg-transparent focus:ring-0">
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem>
{/* Certifique-se que o 'value' aqui seja minúsculo para bater com a lógica do filtro */}
<SelectItem value="unimed">Unimed</SelectItem>
<SelectItem value="bradesco">Bradesco</SelectItem>
<SelectItem value="particular">Particular</SelectItem>
</SelectContent>
</Select>
</div>
{/* FILTRO VIP */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium whitespace-nowrap text-muted-foreground hidden lg:inline">VIP</span>
<Select value={vipFilter} onValueChange={setVipFilter}>
<SelectTrigger className="w-[90px] h-8 border-border bg-transparent focus:ring-0">
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem>
<SelectItem value="sim">Sim</SelectItem>
<SelectItem value="não">Não</SelectItem>
</SelectContent>
</Select>
</div>
{/* PAGINAÇÃO */}
<div className="flex items-center gap-2 pl-2 md:border-l border-border">
<Select
onValueChange={handleItemsPerPageChange}
defaultValue={String(itemsPerPage)}
>
<SelectTrigger className="w-[130px] h-8 border-border bg-transparent focus:ring-0">
<SelectValue placeholder="Paginação" />
</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>
</div>
</div>
</div>
{/* Tabela de Dados */}
<div className="bg-card rounded-lg border border-border overflow-hidden shadow-md">
<div className="overflow-x-auto hidden md:block">
<table className="min-w-[600px] w-full">
<thead className="bg-muted border-b border-border"> <thead className="bg-muted border-b border-border">
<tr> <tr>
<th className="text-left p-3 sm:p-4 font-medium text-foreground">Nome</th> <th className="text-left 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-4 font-medium text-foreground">Telefone</th>
{/* Coluna Convênio visível para teste */} <th className="text-left p-4 font-medium text-foreground">Cidade</th>
<th className="text-left p-3 sm:p-4 font-medium text-foreground hidden lg:table-cell">Convênio</th> <th className="text-left p-4 font-medium text-foreground">Estado</th>
<th className="text-left p-3 sm:p-4 font-medium text-foreground hidden lg:table-cell">VIP</th> <th className="text-left p-4 font-medium text-foreground">Último atendimento</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-4 font-medium text-foreground">Próximo atendimento</th>
<th className="text-left p-3 sm:p-4 font-medium text-foreground">Ações</th> <th className="text-left p-4 font-medium text-foreground">Ações</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading ? ( {loading ? (
<tr> <tr>
<td colSpan={7} className="p-6 text-muted-foreground text-center"> <td colSpan={7} className="p-6 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-primary" />
Carregando pacientes... Carregando pacientes...
</td> </td>
</tr> </tr>
) : error ? ( ) : error ? (
<tr> <tr>
<td colSpan={7} className="p-6 text-red-600 text-center">{`Erro: ${error}`}</td> <td colSpan={7} className="p-6 text-red-600">{`Erro: ${error}`}</td>
</tr> </tr>
) : filteredPacientes.length === 0 ? ( ) : pacientes.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="p-8 text-center text-muted-foreground"> <td colSpan={7} className="p-8 text-center text-muted-foreground">
Nenhum paciente encontrado com esses filtros. Nenhum paciente encontrado
</td> </td>
</tr> </tr>
) : ( ) : (
currentItems.map((p) => ( currentItems.map((p) => (
<tr key={p.id} className="border-b border-border hover:bg-accent/40 transition-colors"> <tr key={p.id} className="border-b border-border hover:bg-accent">
<td className="p-3 sm:p-4">{p.nome}</td> <td className="p-4">{p.nome}</td>
<td className="p-3 sm:p-4 text-muted-foreground">{p.telefone}</td> <td className="p-4 text-muted-foreground">{p.telefone}</td>
<td className="p-3 sm:p-4 text-muted-foreground hidden lg:table-cell">{p.convenio}</td> <td className="p-4 text-muted-foreground">{p.cidade}</td>
<td className="p-3 sm:p-4 text-muted-foreground hidden lg:table-cell">{p.vip}</td> <td className="p-4 text-muted-foreground">{p.estado}</td>
<td className="p-3 sm:p-4 text-muted-foreground hidden xl:table-cell">{p.ultimoAtendimento}</td> <td className="p-4 text-muted-foreground">{p.ultimoAtendimento}</td>
<td className="p-3 sm:p-4"> <td className="p-4 text-muted-foreground">{p.proximoAtendimento}</td>
<td className="p-4">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0"> <button className="text-primary hover:underline">Ações</button>
<span className="sr-only">Abrir menu</span>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleOpenModal(p)}> <DropdownMenuItem onClick={() => handleOpenModal(p)}>
@ -340,6 +169,20 @@ export default function PacientesPage() {
Laudos Laudos
</Link> </Link>
</DropdownMenuItem> </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">
<Trash2 className="w-4 h-4 mr-2" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</td> </td>
@ -349,97 +192,24 @@ export default function PacientesPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="flex justify-center space-x-2 mt-4 p-4">
{/* Cards para Mobile */} {Array.from({ length: Math.ceil(pacientes.length / itemsPerPage) }, (_, i) => (
<div className="md:hidden divide-y divide-border">
{loading ? (
<div className="p-6 text-muted-foreground text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-primary" />
Carregando...
</div>
) : filteredPacientes.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">
<div className="text-base font-semibold text-foreground break-words">
{p.nome || "—"}
</div>
<div className="text-sm text-muted-foreground">
{p.telefone} | {p.convenio} | VIP: {p.vip}
</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>
</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">
<button <button
onClick={goToPrevPage} key={i}
disabled={currentPage === 1} onClick={() => paginate(i + 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" className={`px-4 py-2 rounded-md ${currentPage === i + 1 ? 'bg-primary text-primary-foreground' : 'bg-secondary text-secondary-foreground'}`}
> >
{"< Anterior"} {i + 1}
</button>
{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-blue-600 text-primary-foreground shadow-md border-blue-600"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
>
{number}
</button> </button>
))} ))}
<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>
</div> </div>
</div>
<PatientDetailsModal <PatientDetailsModal
patient={selectedPatient} patient={selectedPatient}
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={handleCloseModal} onClose={handleCloseModal}
/> />
</Sidebar> </DoctorLayout>
); );
} }

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Sidebar from "@/components/Sidebar"; import FinancierLayout from "@/components/finance-layout";
interface Paciente { interface Paciente {
id: string; id: string;
@ -14,10 +14,43 @@ interface Paciente {
} }
export default function PacientesPage() { 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 ( return (
<Sidebar> <FinancierLayout>
<div></div> <div></div>
</Sidebar> </FinancierLayout>
); );
} }

View File

@ -1,31 +1,12 @@
// Caminho: app/(finance)/login/page.tsx // Caminho: app/(finance)/login/page.tsx
import { LoginForm } from "@/components/LoginForm"; import { LoginForm } from "@/components/LoginForm";
import Link from "next/link"; // Adicionado para o link de "Voltar"
export default function FinanceLoginPage() { export default function FinanceLoginPage() {
// NOTA: Esta página se tornou obsoleta com a criação do /login central.
// O ideal no futuro é deletar esta página e redirecionar os usuários.
return ( return (
// Fundo com gradiente laranja, como no seu código original
<div className="min-h-screen bg-gradient-to-br from-orange-50 via-white to-orange-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-orange-50 via-white to-orange-50 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center"> <LoginForm title="Área Financeira" description="Acesse o sistema de faturamento" role="finance" themeColor="orange" redirectPath="/finance/home" />
<h1 className="text-3xl font-bold text-foreground mb-2">Área Financeira</h1>
<p className="text-muted-foreground mb-8">Acesse o sistema de faturamento</p>
{/* --- ALTERAÇÃO PRINCIPAL AQUI --- */}
{/* Chamando o LoginForm unificado sem props desnecessárias */}
<LoginForm>
{/* Adicionamos um link de "Voltar" como filho (children) */}
<div className="mt-6 text-center text-sm">
<Link href="/">
<span className="font-semibold text-primary hover:underline cursor-pointer">
Voltar à página inicial
</span>
</Link>
</div>
</LoginForm>
</div>
</div> </div>
); );
} }

View File

@ -1,6 +1,8 @@
@import "tailwindcss"; @import 'tailwindcss';
@import "tw-animate-css"; @import 'tw-animate-css';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
@ -16,8 +18,8 @@
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.637 0.237 25.331); --destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0); --destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
@ -52,8 +54,8 @@
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.7 0.25 25); --destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.985 0 0); --destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0); --border: oklch(0.269 0 0);
--input: oklch(0.269 0 0); --input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0); --ring: oklch(0.439 0 0);
@ -87,7 +89,7 @@
--muted-foreground: oklch(1 0.5 100); --muted-foreground: oklch(1 0.5 100);
--accent: oklch(0 0 0); --accent: oklch(0 0 0);
--accent-foreground: oklch(1 0.5 100); --accent-foreground: oklch(1 0.5 100);
--destructive: oklch(0.8 0.5 25); --destructive: oklch(0.5 0.3 30);
--destructive-foreground: oklch(0 0 0); --destructive-foreground: oklch(0 0 0);
--border: oklch(1 0.5 100); --border: oklch(1 0.5 100);
--input: oklch(0 0 0); --input: oklch(0 0 0);

View File

@ -4,7 +4,11 @@ import { GeistMono } from "geist/font/mono";
import { Analytics } from "@vercel/analytics/next"; import { Analytics } from "@vercel/analytics/next";
import "./globals.css"; import "./globals.css";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { Providers } from "./providers"; // [PASSO 1.2] - Importando o nosso provider
import { AppointmentsProvider } from "./context/AppointmentsContext";
import { AccessibilityProvider } from "./context/AccessibilityContext";
import { AccessibilityModal } from "@/components/accessibility-modal";
import { ThemeInitializer } from "@/components/theme-initializer";
export default function RootLayout({ export default function RootLayout({
children, children,
@ -14,7 +18,12 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={`font-sans ${GeistSans.variable} ${GeistMono.variable}`}> <body className={`font-sans ${GeistSans.variable} ${GeistMono.variable}`}>
<Providers>{children}</Providers> {/* [PASSO 1.2] - Envolvendo a aplicação com o provider */}
<ThemeInitializer />
<AccessibilityProvider>
<AppointmentsProvider>{children}</AppointmentsProvider>
<AccessibilityModal />
</AccessibilityProvider>
<Analytics /> <Analytics />
<Toaster /> <Toaster />
</body> </body>

View File

@ -1,261 +0,0 @@
// 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 { 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() {
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);
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>
{/* 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 border-2 border-border mt-8">
{/* 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-blue-600 hover:text-blue-700 hover:underline cursor-pointer">
Crie uma agora
</span>
</Link>
</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"
alt="Médica utilizando um tablet na clínica MedConnect"
fill
style={{ objectFit: "cover" }}
priority
className="dark:opacity-80"
/>
{/* 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>
{/* 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>
{/* 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">
{/* Botão Cancelar Azul contornado */}
<Button
variant="outline"
onClick={closeModal}
disabled={isLoading}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
>
Cancelar
</Button>
{/* Botão Resetar Senha Azul sólido */}
<Button
onClick={handleResetPassword}
disabled={isLoading}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
>
{isLoading ? "Enviando..." : "Resetar Senha"}
</Button>
</div>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -1,140 +1,41 @@
"use client"; import ManagerLayout from "@/components/manager-layout"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { import { Button } from "@/components/ui/button"
Card, import { Calendar, Clock, User, Plus } from "lucide-react"
CardContent, import Link from "next/link"
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Clock, Plus, User } from "lucide-react"; // Removi 'Calendar' que não estava sendo usado
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";
import { api } from "services/api.mjs"; // <-- ADICIONEI ESTE IMPORT
export default function ManagerDashboard() { export default function ManagerDashboard() {
// 🔹 Estados para usuários
const [firstUser, setFirstUser] = useState<any>(null);
const [loadingUser, setLoadingUser] = useState(true);
// 🔹 Estados para médicos
const [doctors, setDoctors] = useState<any[]>([]);
const [loadingDoctors, setLoadingDoctors] = useState(true);
// 🔹 Buscar primeiro usuário (LÓGICA ATUALIZADA)
useEffect(() => {
async function fetchFirstUser() {
setLoadingUser(true); // Garante que o estado de loading inicie como true
try {
// 1. Busca a lista de usuários com seus cargos (roles)
const rolesData = await usersService.list_roles();
// 2. Verifica se a lista não está vazia
if (Array.isArray(rolesData) && rolesData.length > 0) {
const firstUserRole = rolesData[0];
const firstUserId = firstUserRole.user_id;
if (!firstUserId) {
throw new Error("O primeiro usuário da lista não possui um ID válido.");
}
// 3. Usa o ID para buscar o perfil (com nome e email) do usuário
const profileData = await api.get(
`/rest/v1/profiles?select=full_name,email&id=eq.${firstUserId}`
);
// 4. Verifica se o perfil foi encontrado
if (Array.isArray(profileData) && profileData.length > 0) {
const userProfile = profileData[0];
// 5. Combina os dados do cargo e do perfil e atualiza o estado
setFirstUser({
...firstUserRole,
...userProfile
});
} else {
// Se não encontrar o perfil, exibe os dados que temos
setFirstUser(firstUserRole);
}
}
} catch (error) {
console.error("Erro ao carregar usuário:", error);
setFirstUser(null); // Limpa o usuário em caso de erro
} finally {
setLoadingUser(false);
}
}
fetchFirstUser();
}, []);
// 🔹 Buscar 3 primeiros médicos
useEffect(() => {
async function fetchDoctors() {
try {
const data = await doctorsService.list(); // ajuste se seu service tiver outro método
if (Array.isArray(data)) {
setDoctors(data.slice(0, 3)); // pega os 3 primeiros
}
} catch (error) {
console.error("Erro ao carregar médicos:", error);
} finally {
setLoadingDoctors(false);
}
}
fetchDoctors();
}, []);
return ( return (
<Sidebar> <ManagerLayout>
<div className="space-y-6"> <div className="space-y-6">
{/* Cabeçalho */}
<div> <div>
<h1 className="text-3xl font-bold">Dashboard</h1> <h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-muted-foreground"> <p className="text-gray-600">Bem-vindo ao seu portal de consultas médicas</p>
Bem-vindo ao seu portal de consultas médicas
</p>
</div> </div>
{/* Cards principais */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Card 2 — Gestão de usuários */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium">Relatórios gerenciais</CardTitle>
Gestão de usuários <Calendar className="h-4 w-4 text-muted-foreground" />
</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loadingUser ? ( <div className="text-2xl font-bold">3</div>
<div className="text-muted-foreground text-sm"> <p className="text-xs text-muted-foreground">2 não lidos, 1 lido</p>
Carregando usuário... </CardContent>
</div> </Card>
) : firstUser ? (
<> <Card>
<div className="text-2xl font-bold"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
{firstUser.full_name || "Sem nome"} <CardTitle className="text-sm font-medium">Gestão de usuários</CardTitle>
</div> <Clock className="h-4 w-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground"> </CardHeader>
{firstUser.email || "Sem e-mail cadastrado"} <CardContent>
</p> <div className="text-2xl font-bold">João Marques</div>
</> <p className="text-xs text-muted-foreground">fez login a 13min</p>
) : (
<div className="text-sm text-muted-foreground">
Nenhum usuário encontrado
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Card 3 — Perfil */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Perfil</CardTitle> <CardTitle className="text-sm font-medium">Perfil</CardTitle>
@ -147,96 +48,66 @@ export default function ManagerDashboard() {
</Card> </Card>
</div> </div>
{/* Cards secundários */}
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
{/* Card — Ações rápidas */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Ações Rápidas</CardTitle> <CardTitle>Ações Rápidas</CardTitle>
<CardDescription> <CardDescription>Acesse rapidamente as principais funcionalidades</CardDescription>
Acesse rapidamente as principais funcionalidades
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Link href="/manager/home"> <Link href="##">
<Button className="w-full justify-start"> <Button className="w-full justify-start">
<User className="mr-2 h-4 w-4" />
Gestão de Médicos
</Button>
</Link>
<Link href="/manager/usuario">
<Button
variant="outline"
className="w-full justify-start"
>
<User className="mr-2 h-4 w-4" />
Usuários Cadastrados
</Button>
</Link>
<Link href="/manager/home/novo">
<Button
variant="outline"
className="w-full justify-start"
>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Adicionar Novo Médico #
</Button> </Button>
</Link> </Link>
<Link href="/manager/usuario/novo"> <Link href="##">
<Button <Button variant="outline" className="w-full justify-start bg-transparent">
variant="outline" <Calendar className="mr-2 h-4 w-4" />
className="w-full justify-start" #
> </Button>
<Plus className="mr-2 h-4 w-4" /> </Link>
Criar novo Usuário <Link href="##">
<Button variant="outline" className="w-full justify-start bg-transparent">
<User className="mr-2 h-4 w-4" />
#
</Button> </Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
{/* Card — Gestão de Médicos */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Gestão de Médicos</CardTitle> <CardTitle>Gestão de Médicos</CardTitle>
<CardDescription> <CardDescription>Médicos online</CardDescription>
Médicos cadastrados recentemente
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loadingDoctors ? (
<p className="text-sm text-muted-foreground">Carregando médicos...</p>
) : doctors.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nenhum médico cadastrado.
</p>
) : (
<div className="space-y-4"> <div className="space-y-4">
{doctors.map((doc, index) => ( <div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<div
key={index}
className="flex items-center justify-between p-3 bg-secondary rounded-lg border"
>
<div> <div>
<p className="font-medium"> <p className="font-medium">Dr. Silva</p>
{doc.full_name || "Sem nome"} <p className="text-sm text-gray-600">Cardiologia</p>
</p>
<p className="text-sm text-muted-foreground">
{doc.specialty || "Sem especialidade"}
</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="font-medium text-primary"> <p className="font-medium">On-line</p>
{doc.active ? "Ativo" : "Inativo"} <p className="text-sm text-gray-600"></p>
</p> </div>
</div>
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div>
<p className="font-medium">Dra. Santos</p>
<p className="text-sm text-gray-600">Dermatologia</p>
</div>
<div className="text-right">
<p className="font-medium">Off-line</p>
<p className="text-sm text-gray-600">Visto as 8:33</p>
</div> </div>
</div> </div>
))}
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
</Sidebar> </ManagerLayout>
); )
} }

View File

@ -1,185 +0,0 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import Sidebar from "@/components/Sidebar";
import WeeklyScheduleCard from "@/components/ui/WeeklyScheduleCard";
import { useEffect, useState, useMemo } from "react";
import { AvailabilityService } from "@/services/availabilityApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Filter } from "lucide-react";
type Doctor = {
id: string;
full_name: string;
specialty: string;
active: boolean;
};
type Availability = {
id: string;
doctor_id: string;
weekday: string;
start_time: string;
end_time: string;
};
export default function AllAvailabilities() {
const [availabilities, setAvailabilities] = useState<Availability[] | null>(null);
const [doctors, setDoctors] = useState<Doctor[] | null>(null);
// 🔎 Filtros
const [search, setSearch] = useState("");
const [specialty, setSpecialty] = useState("all");
// 🔄 Paginação
const ITEMS_PER_PAGE = 6;
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const doctorsList = await doctorsService.list();
setDoctors(doctorsList);
const availabilityList = await AvailabilityService.list();
setAvailabilities(availabilityList);
} catch (e: any) {
alert(`${e?.error} ${e?.message}`);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
// 🎯 Obter todas as especialidades existentes
const specialties = useMemo(() => {
if (!doctors) return [];
const unique = Array.from(new Set(doctors.map((d) => d.specialty)));
return unique;
}, [doctors]);
// 🔍 Filtrar médicos por especialidade + nome
const filteredDoctors = useMemo(() => {
if (!doctors) return [];
return doctors.filter((doctor) => (specialty === "all" ? true : doctor.specialty === specialty)).filter((doctor) => doctor.full_name.toLowerCase().includes(search.toLowerCase()));
}, [doctors, search, specialty]);
// 📄 Paginação (após filtros!)
const totalPages = Math.ceil(filteredDoctors.length / ITEMS_PER_PAGE);
const paginatedDoctors = filteredDoctors.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE);
const goNext = () => setPage((p) => Math.min(p + 1, totalPages));
const goPrev = () => setPage((p) => Math.max(p - 1, 1));
if (loading) {
return (
<Sidebar>
<div className="p-6 text-muted-foreground">Carregando dados...</div>
</Sidebar>
);
}
if (!doctors || !availabilities) {
return (
<Sidebar>
<div className="p-6 text-destructive font-medium">Não foi possível carregar médicos ou disponibilidades.</div>
</Sidebar>
);
}
return (
<Sidebar>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Disponibilidade dos Médicos</h1>
<p className="text-muted-foreground">Visualize a agenda semanal individual de cada médico.</p>
</div>
<Card>
<CardContent>
{/* 🔎 Filtros */}
<div className="flex flex-col md:flex-row gap-4 items-center">
{/* Filtro por nome */}
<Filter className="w-4 h-4 mr-2" />
<Input
placeholder="Buscar por nome do médico..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="w-full md:w-1/3"
/>
{/* Filtro por especialidade */}
<Select
onValueChange={(value) => {
setSpecialty(value);
setPage(1);
}}
defaultValue="all"
>
<SelectTrigger className="w-full md:w-64">
<SelectValue placeholder="Especialidade" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as especialidades</SelectItem>
{specialties.map((sp) => (
<SelectItem key={sp} value={sp}>
{sp}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* GRID de cards */}
<div className="grid md:grid-cols-1 lg:grid-cols-1 gap-6">
{paginatedDoctors.map((doctor) => {
const doctorAvailabilities = availabilities.filter((a) => a.doctor_id === doctor.id);
return (
<Card key={doctor.id}>
<CardHeader>
<CardTitle className="text-xl font-semibold">{doctor.full_name}</CardTitle>
</CardHeader>
<CardContent>
<WeeklyScheduleCard doctorId={doctor.id} />
</CardContent>
</Card>
);
})}
</div>
{/* 📄 Paginação */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-4 pt-4">
<Button variant="outline" onClick={goPrev} disabled={page === 1}>
Anterior
</Button>
<span className="text-muted-foreground font-medium">
Página {page} de {totalPages}
</span>
<Button variant="outline" onClick={goNext} disabled={page === totalPages}>
Próxima
</Button>
</div>
)}
</div>
</Sidebar>
);
}

View File

@ -10,7 +10,7 @@ import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Save, Loader2, ArrowLeft } from "lucide-react" import { Save, Loader2, ArrowLeft } from "lucide-react"
import Sidebar from "@/components/Sidebar" import ManagerLayout from "@/components/manager-layout"
import { doctorsService } from "services/doctorsApi.mjs"; 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"]; 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,25 +207,25 @@ export default function EditarMedicoPage() {
}; };
if (loading) { if (loading) {
return ( return (
<Sidebar> <ManagerLayout>
<div className="flex justify-center items-center h-full w-full py-16"> <div className="flex justify-center items-center h-full w-full py-16">
<Loader2 className="w-8 h-8 animate-spin text-primary" /> <Loader2 className="w-8 h-8 animate-spin text-green-600" />
<p className="ml-2 text-muted-foreground">Carregando dados do médico...</p> <p className="ml-2 text-gray-600">Carregando dados do médico...</p>
</div> </div>
</Sidebar> </ManagerLayout>
); );
} }
return ( return (
<Sidebar> <ManagerLayout>
<div className="w-full space-y-6 p-4 md:p-8"> <div className="w-full space-y-6 p-4 md:p-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground"> <h1 className="text-2xl font-bold text-gray-900">
Editar Médico: <span className="text-primary">{formData.nomeCompleto}</span> Editar Médico: <span className="text-green-600">{formData.nomeCompleto}</span>
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-gray-500">
Atualize as informações do médico Atualize as informações do médico (ID: {id}).
</p> </p>
</div> </div>
<Link href="/manager/home"> <Link href="/manager/home">
@ -239,19 +239,19 @@ export default function EditarMedicoPage() {
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{error && ( {error && (
<div className="p-3 rounded-lg border bg-destructive/10 text-destructive border-destructive/30"> <div className="p-3 bg-red-100 text-red-700 rounded-lg border border-red-300">
<p className="font-medium">Erro na Atualização:</p> <p className="font-medium">Erro na Atualização:</p>
<p className="text-sm">{error}</p> <p className="text-sm">{error}</p>
</div> </div>
)} )}
<div className="space-y-4 p-4 rounded-xl border border-border shadow-sm bg-card"> <div className="space-y-4 p-4 border rounded-xl shadow-sm bg-white">
<h2 className="text-lg font-semibold text-foreground border-b border-border pb-2"> <h2 className="text-lg font-semibold text-gray-800 border-b pb-2">
Dados Principais e Pessoais Dados Principais e Pessoais
</h2> </h2>
<div className="grid md:grid-cols-4 gap-4"> <div className="grid md:grid-cols-4 gap-4">
<div className="space-y-2 col-span-2"> <div className="space-y-2 col-span-2">
<Label htmlFor="nomeCompleto">Nome Completo</Label> <Label htmlFor="nomeCompleto">Nome Completo (full_name)</Label>
<Input <Input
id="nomeCompleto" id="nomeCompleto"
value={formData.nomeCompleto} value={formData.nomeCompleto}
@ -269,7 +269,7 @@ export default function EditarMedicoPage() {
/> />
</div> </div>
<div className="space-y-2 col-span-1"> <div className="space-y-2 col-span-1">
<Label htmlFor="crmEstado">UF do CRM</Label> <Label htmlFor="crmEstado">UF do CRM (crm_uf)</Label>
<Select value={formData.crmEstado} onValueChange={(v) => handleInputChange("crmEstado", v)}> <Select value={formData.crmEstado} onValueChange={(v) => handleInputChange("crmEstado", v)}>
<SelectTrigger id="crmEstado"> <SelectTrigger id="crmEstado">
<SelectValue placeholder="UF" /> <SelectValue placeholder="UF" />
@ -286,7 +286,7 @@ export default function EditarMedicoPage() {
<div className="grid md:grid-cols-3 gap-4"> <div className="grid md:grid-cols-3 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="especialidade">Especialidade</Label> <Label htmlFor="especialidade">Especialidade (specialty)</Label>
<Input <Input
id="especialidade" id="especialidade"
value={formData.especialidade} value={formData.especialidade}
@ -327,7 +327,7 @@ export default function EditarMedicoPage() {
/> />
</div> </div>
<div className="space-y-2 col-span-1"> <div className="space-y-2 col-span-1">
<Label htmlFor="dataNascimento">Data de Nascimento</Label> <Label htmlFor="dataNascimento">Data de Nascimento (birth_date)</Label>
<Input <Input
id="dataNascimento" id="dataNascimento"
type="date" type="date"
@ -342,20 +342,20 @@ export default function EditarMedicoPage() {
checked={formData.ativo} checked={formData.ativo}
onCheckedChange={(checked) => handleInputChange("ativo", checked === true)} onCheckedChange={(checked) => handleInputChange("ativo", checked === true)}
/> />
<Label htmlFor="ativo">Médico Ativo</Label> <Label htmlFor="ativo">Médico Ativo (active)</Label>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-4 p-4 rounded-xl border border-border shadow-sm bg-card"> <div className="space-y-4 p-4 border rounded-xl shadow-sm bg-white">
<h2 className="text-lg font-semibold text-foreground border-b border-border pb-2"> <h2 className="text-lg font-semibold text-gray-800 border-b pb-2">
Contato e Endereço Contato e Endereço
</h2> </h2>
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="telefoneCelular">Telefone Celular</Label> <Label htmlFor="telefoneCelular">Telefone Celular (phone_mobile)</Label>
<Input <Input
id="telefoneCelular" id="telefoneCelular"
value={formData.telefoneCelular} value={formData.telefoneCelular}
@ -365,7 +365,7 @@ export default function EditarMedicoPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="telefone2">Telefone Adicional</Label> <Label htmlFor="telefone2">Telefone Adicional (phone2)</Label>
<Input <Input
id="telefone2" id="telefone2"
value={formData.telefone2} value={formData.telefone2}
@ -389,7 +389,7 @@ export default function EditarMedicoPage() {
/> />
</div> </div>
<div className="space-y-2 col-span-3"> <div className="space-y-2 col-span-3">
<Label htmlFor="endereco">Logradouro</Label> <Label htmlFor="endereco">Logradouro (street)</Label>
<Input <Input
id="endereco" id="endereco"
value={formData.endereco} value={formData.endereco}
@ -440,7 +440,7 @@ export default function EditarMedicoPage() {
/> />
</div> </div>
<div className="space-y-2 col-span-1"> <div className="space-y-2 col-span-1">
<Label htmlFor="estado">Estado</Label> <Label htmlFor="estado">Estado (state)</Label>
<Input <Input
id="estado" id="estado"
value={formData.estado} value={formData.estado}
@ -452,8 +452,8 @@ export default function EditarMedicoPage() {
</div> </div>
<div className="space-y-4 p-4 rounded-xl border border-border shadow-sm bg-card"> <div className="space-y-4 p-4 border rounded-xl shadow-sm bg-white">
<h2 className="text-lg font-semibold text-foreground border-b border-border pb-2"> <h2 className="text-lg font-semibold text-gray-800 border-b pb-2">
Observações (Apenas internas) Observações (Apenas internas)
</h2> </h2>
<Textarea <Textarea
@ -474,7 +474,7 @@ export default function EditarMedicoPage() {
</Link> </Link>
<Button <Button
type="submit" type="submit"
className="bg-primary hover:bg-primary/90" className="bg-green-600 hover:bg-green-700"
disabled={isSaving} disabled={isSaving}
> >
{isSaving ? ( {isSaving ? (
@ -487,6 +487,6 @@ export default function EditarMedicoPage() {
</div> </div>
</form> </form>
</div> </div>
</Sidebar> </ManagerLayout>
); );
} }

View File

@ -0,0 +1,534 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { Upload, X, ChevronDown, Save, Loader2 } from "lucide-react"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import ManagerLayout from "@/components/manager-layout"
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"];
interface DoctorFormData {
nomeCompleto: string;
crm: string;
crmEstado: string;
cpf: string;
email: string;
especialidade: string;
telefoneCelular: string;
telefone2: string;
cep: string;
endereco: string;
numero: string;
complemento: string;
bairro: string;
cidade: string;
estado: string;
dataNascimento: string;
rg: string;
ativo: boolean;
observacoes: string;
anexos: { id: number, name: string }[];
}
const apiMap: { [K in keyof DoctorFormData]: string | null } = {
nomeCompleto: 'full_name',
crm: 'crm',
crmEstado: 'crm_uf',
cpf: 'cpf',
email: 'email',
especialidade: 'specialty',
telefoneCelular: 'phone_mobile',
telefone2: 'phone2',
cep: 'cep',
endereco: 'street',
numero: 'number',
complemento: 'complement',
bairro: 'neighborhood',
cidade: 'city',
estado: 'state',
dataNascimento: 'birth_date',
rg: 'rg',
ativo: 'active',
observacoes: null,
anexos: null,
};
const defaultFormData: DoctorFormData = {
nomeCompleto: '', crm: '', crmEstado: '', cpf: '', email: '',
especialidade: '', telefoneCelular: '', telefone2: '', cep: '',
endereco: '', numero: '', complemento: '', bairro: '', cidade: '', estado: '',
dataNascimento: '', rg: '', ativo: true,
observacoes: '', anexos: [],
};
const cleanNumber = (value: string): string => value.replace(/\D/g, '');
const formatCPF = (value: string): string => {
const cleaned = cleanNumber(value).substring(0, 11);
return cleaned.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4');
};
const formatCEP = (value: string): string => {
const cleaned = cleanNumber(value).substring(0, 8);
return cleaned.replace(/(\d{5})(\d{3})/, '$1-$2');
};
const formatPhoneMobile = (value: string): string => {
const cleaned = cleanNumber(value).substring(0, 11);
if (cleaned.length > 10) {
return cleaned.replace(/(\d{2})(\d{5})(\d{4})/, '($1) $2-$3');
}
return cleaned.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2-$3');
};
export default function NovoMedicoPage() {
const router = useRouter();
const [formData, setFormData] = useState<DoctorFormData>(defaultFormData);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [anexosOpen, setAnexosOpen] = useState(false);
const handleInputChange = (key: keyof DoctorFormData, value: string | boolean | { id: number, name: string }[]) => {
if (typeof value === 'string') {
let maskedValue = value;
if (key === 'cpf') maskedValue = formatCPF(value);
if (key === 'cep') maskedValue = formatCEP(value);
if (key === 'telefoneCelular' || key === 'telefone2') maskedValue = formatPhoneMobile(value);
setFormData((prev) => ({ ...prev, [key]: maskedValue }));
} else {
setFormData((prev) => ({ ...prev, [key]: value }));
}
};
const adicionarAnexo = () => {
const newId = Date.now();
handleInputChange('anexos', [...formData.anexos, { id: newId, name: `Documento ${formData.anexos.length + 1}` }]);
}
const removerAnexo = (id: number) => {
handleInputChange('anexos', formData.anexos.filter((anexo) => anexo.id !== id));
}
const requiredFields = [
{ key: 'nomeCompleto', name: 'Nome Completo' },
{ key: 'crm', name: 'CRM' },
{ key: 'crmEstado', name: 'UF do CRM' },
{ key: 'cpf', name: 'CPF' },
{ key: 'email', name: 'E-mail' },
] as const;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsSaving(true);
for (const field of requiredFields) {
let valueToCheck = formData[field.key];
if (!valueToCheck || String(valueToCheck).trim() === '') {
setError(`O campo obrigatório "${field.name}" deve ser preenchido.`);
setIsSaving(false);
return;
}
}
const finalPayload: { [key: string]: any } = {};
const formKeys = Object.keys(formData) as Array<keyof DoctorFormData>;
formKeys.forEach((key) => {
const apiFieldName = apiMap[key];
if (!apiFieldName) return;
let value = formData[key];
if (typeof value === 'string') {
let trimmedValue = value.trim();
const isOptional = !requiredFields.some(f => f.key === key);
if (isOptional && trimmedValue === '') {
finalPayload[apiFieldName] = null;
return;
}
if (key === 'crmEstado' || key === 'estado') {
trimmedValue = trimmedValue.toUpperCase();
}
value = trimmedValue;
}
finalPayload[apiFieldName] = value;
});
try {
const response = await doctorsService.create(finalPayload);
router.push("/manager/home");
} catch (e: any) {
console.error("Erro ao salvar o médico:", e);
let detailedError = `Erro na requisição. Verifique se o **CRM** ou **CPF** já existem ou se as **Máscaras/Datas** estão incorretas.`;
if (e.message && e.message.includes("duplicate key value violates unique constraint")) {
detailedError = "O CPF ou CRM informado já está cadastrado no sistema. Por favor, verifique os dados de identificação.";
} else if (e.message && e.message.includes("Detalhes:")) {
detailedError = e.message.split("Detalhes:")[1].trim();
} else if (e.message) {
detailedError = e.message;
}
setError(`Erro ao cadastrar. Detalhes: ${detailedError}`);
} finally {
setIsSaving(false);
}
};
return (
<ManagerLayout>
<div className="w-full space-y-6 p-4 md:p-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Novo Médico</h1>
<p className="text-sm text-gray-500">
Preencha os dados do novo médico para cadastro.
</p>
</div>
<Link href="/manager/home">
<Button variant="outline">Cancelar</Button>
</Link>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-3 bg-red-100 text-red-700 rounded-lg border border-red-300">
<p className="font-medium">Erro no Cadastro:</p>
<p className="text-sm">{error}</p>
</div>
)}
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-800 border-b pb-2">
Dados Principais e Pessoais
</h2>
<div className="grid md:grid-cols-4 gap-4">
<div className="space-y-2 col-span-2">
<Label htmlFor="nomeCompleto">Nome Completo *</Label>
<Input
id="nomeCompleto"
value={formData.nomeCompleto}
onChange={(e) => handleInputChange("nomeCompleto", e.target.value)}
placeholder="Nome do Médico"
required
/>
</div>
<div className="space-y-2 col-span-1">
<Label htmlFor="crm">CRM *</Label>
<Input
id="crm"
value={formData.crm}
onChange={(e) => handleInputChange("crm", e.target.value)}
placeholder="Ex: 123456"
required
/>
</div>
<div className="space-y-2 col-span-1">
<Label htmlFor="crmEstado">UF do CRM *</Label>
<Select value={formData.crmEstado} onValueChange={(v) => handleInputChange("crmEstado", v)}>
<SelectTrigger id="crmEstado">
<SelectValue placeholder="UF" />
</SelectTrigger>
<SelectContent>
{UF_LIST.map(uf => (
<SelectItem key={uf} value={uf}>{uf}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="especialidade">Especialidade</Label>
<Input
id="especialidade"
value={formData.especialidade}
onChange={(e) => handleInputChange("especialidade", e.target.value)}
placeholder="Ex: Cardiologia"
/>
</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="000.000.000-00"
maxLength={14}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="rg">RG</Label>
<Input
id="rg"
value={formData.rg}
onChange={(e) => handleInputChange("rg", e.target.value)}
placeholder="00.000.000-0"
/>
</div>
</div>
<div className="grid md:grid-cols-3 gap-4">
<div className="space-y-2 col-span-2">
<Label htmlFor="email">E-mail *</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
placeholder="exemplo@dominio.com"
required
/>
</div>
<div className="space-y-2 col-span-1">
<Label htmlFor="dataNascimento">Data de Nascimento</Label>
<Input
id="dataNascimento"
type="date"
value={formData.dataNascimento}
onChange={(e) => handleInputChange("dataNascimento", e.target.value)}
/>
</div>
</div>
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-800 border-b pb-2">
Contato e Endereço
</h2>
<div className="grid md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="telefoneCelular">Telefone Celular</Label>
<Input
id="telefoneCelular"
value={formData.telefoneCelular}
onChange={(e) => handleInputChange("telefoneCelular", e.target.value)}
placeholder="(00) 00000-0000"
maxLength={15}
/>
</div>
<div className="space-y-2">
<Label htmlFor="telefone2">Telefone Adicional</Label>
<Input
id="telefone2"
value={formData.telefone2}
onChange={(e) => handleInputChange("telefone2", e.target.value)}
placeholder="(00) 00000-0000"
maxLength={15}
/>
</div>
<div className="space-y-2 flex items-end justify-center pb-1">
<div className="flex items-center space-x-2">
<Checkbox
id="ativo"
checked={formData.ativo}
onCheckedChange={(checked) => handleInputChange("ativo", checked === true)}
/>
<Label htmlFor="ativo">Médico Ativo</Label>
</div>
</div>
</div>
<div className="grid md:grid-cols-4 gap-4">
<div className="space-y-2 col-span-1">
<Label htmlFor="cep">CEP</Label>
<Input
id="cep"
value={formData.cep}
onChange={(e) => handleInputChange("cep", e.target.value)}
placeholder="00000-000"
maxLength={9}
/>
</div>
<div className="space-y-2 col-span-3">
<Label htmlFor="endereco">Rua</Label>
<Input
id="endereco"
value={formData.endereco}
onChange={(e) => handleInputChange("endereco", e.target.value)}
placeholder="Rua, Avenida, etc."
/>
</div>
</div>
<div className="grid md:grid-cols-4 gap-4">
<div className="space-y-2 col-span-1">
<Label htmlFor="numero">Número</Label>
<Input
id="numero"
value={formData.numero}
onChange={(e) => handleInputChange("numero", e.target.value)}
placeholder="123"
/>
</div>
<div className="space-y-2 col-span-3">
<Label htmlFor="complemento">Complemento</Label>
<Input
id="complemento"
value={formData.complemento}
onChange={(e) => handleInputChange("complemento", e.target.value)}
placeholder="Apto, Bloco, etc."
/>
</div>
</div>
<div className="grid md:grid-cols-4 gap-4">
<div className="space-y-2 col-span-2">
<Label htmlFor="bairro">Bairro</Label>
<Input
id="bairro"
value={formData.bairro}
onChange={(e) => handleInputChange("bairro", e.target.value)}
placeholder="Bairro"
/>
</div>
<div className="space-y-2 col-span-1">
<Label htmlFor="estado">Estado</Label>
<Input
id="estado"
value={formData.estado}
onChange={(e) => handleInputChange("estado", e.target.value)}
placeholder="SP"
/>
</div>
<div className="space-y-2 col-span-1">
<Label htmlFor="cidade">Cidade</Label>
<Input
id="cidade"
value={formData.cidade}
onChange={(e) => handleInputChange("cidade", e.target.value)}
placeholder="São Paulo"
/>
</div>
</div>
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-800 border-b pb-2">
Outras Informações (Internas)
</h2>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="observacoes">Observações (Apenas internas)</Label>
<Textarea
id="observacoes"
value={formData.observacoes}
onChange={(e) => handleInputChange("observacoes", e.target.value)}
placeholder="Notas internas sobre o médico..."
className="min-h-[100px]"
/>
</div>
<div className="space-y-4">
<Collapsible open={anexosOpen} onOpenChange={setAnexosOpen}>
<CollapsibleTrigger asChild>
<div className="flex justify-between items-center cursor-pointer pb-2 border-b">
<h2 className="text-md font-semibold text-gray-800">Anexos ({formData.anexos.length})</h2>
<ChevronDown className={`w-5 h-5 transition-transform ${anexosOpen ? 'rotate-180' : 'rotate-0'}`} />
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
<Button type="button" onClick={adicionarAnexo} variant="outline" className="w-full">
<Upload className="w-4 h-4 mr-2" />
Adicionar Documento
</Button>
{formData.anexos.map((anexo) => (
<div key={anexo.id} className="flex items-center justify-between p-3 bg-gray-50 border rounded-lg">
<span className="text-sm text-gray-700">{anexo.name}</span>
<Button type="button" variant="ghost" size="icon" onClick={() => removerAnexo(anexo.id)}>
<X className="w-4 h-4 text-red-500" />
</Button>
</div>
))}
</CollapsibleContent>
</Collapsible>
</div>
</div>
</div>
<div className="flex justify-end gap-4 pb-8 pt-4">
<Link href="/manager/home">
<Button type="button" variant="outline" disabled={isSaving}>
Cancelar
</Button>
</Link>
<Button
type="submit"
className="bg-green-600 hover:bg-green-700"
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
{isSaving ? "Salvando..." : "Salvar Médico"}
</Button>
</div>
</form>
</div>
</ManagerLayout>
);
}

View File

@ -1,20 +1,26 @@
"use client"; "use client";
import React, { useEffect, useState, useCallback, useMemo } from "react"; import React, { useEffect, useState, useCallback } from "react"
import Link from "next/link"; import ManagerLayout from "@/components/manager-layout";
import Link from "next/link"
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Edit, Trash2, Eye, Calendar, Filter, Loader2, MoreVertical } from "lucide-react" import { Plus, Edit, Trash2, Eye, Calendar, Filter, MoreVertical, Loader2 } from "lucide-react"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog" import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { doctorsService } from "services/doctorsApi.mjs"; import { doctorsService } from "services/doctorsApi.mjs";
import Sidebar from "@/components/Sidebar";
// --- NOVOS IMPORTS (Certifique-se que criou os arquivos no passo anterior) ---
import { FilterBar } from "@/components/ui/filter-bar";
import { normalizeSpecialty, getUniqueSpecialties } from "@/lib/normalization";
interface Doctor { interface Doctor {
id: number; id: number;
@ -24,21 +30,23 @@ interface Doctor {
phone_mobile: string | null; phone_mobile: string | null;
city: string | null; city: string | null;
state: string | null; state: string | null;
status?: string;
} }
interface DoctorDetails { interface DoctorDetails {
nome: string; nome: string;
crm: string; crm: string;
especialidade: string; especialidade: string;
contato: { contato: {
celular?: string; celular?: string;
telefone1?: string; telefone1?: string;
}; }
endereco: { endereco: {
cidade?: string; cidade?: string;
estado?: string; estado?: string;
}; }
convenio?: string; convenio?: string;
vip?: boolean; vip?: boolean;
status?: string; status?: string;
@ -50,41 +58,25 @@ interface DoctorDetails {
export default function DoctorsPage() { export default function DoctorsPage() {
const router = useRouter(); const router = useRouter();
// --- Estados de Dados ---
const [doctors, setDoctors] = useState<Doctor[]>([]); const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// --- Estados de Modais ---
const [detailsDialogOpen, setDetailsDialogOpen] = useState(false); const [detailsDialogOpen, setDetailsDialogOpen] = useState(false);
const [doctorDetails, setDoctorDetails] = useState<DoctorDetails | null>(null); const [doctorDetails, setDoctorDetails] = useState<DoctorDetails | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [doctorToDeleteId, setDoctorToDeleteId] = useState<number | null>(null); const [doctorToDeleteId, setDoctorToDeleteId] = useState<number | null>(null);
// --- Estados de Filtro e Busca ---
const [searchTerm, setSearchTerm] = useState("");
const [filters, setFilters] = useState({
specialty: "all",
status: "all"
});
// --- Estados de Paginação ---
const [itemsPerPage, setItemsPerPage] = useState(10);
const [currentPage, setCurrentPage] = useState(1);
// 1. Buscar Médicos na API
const fetchDoctors = useCallback(async () => { const fetchDoctors = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const data: Doctor[] = await doctorsService.list(); const data: Doctor[] = await doctorsService.list();
// Mockando status para visualização (conforme original) setDoctors(data || []);
const dataWithStatus = data.map((doc, index) => ({
...doc,
status: index % 3 === 0 ? "Inativo" : index % 2 === 0 ? "Férias" : "Ativo",
}));
setDoctors(dataWithStatus || []);
// Não resetamos a página aqui para manter a navegação fluida se apenas recarregar dados
} catch (e: any) { } catch (e: any) {
console.error("Erro ao carregar lista de médicos:", e); console.error("Erro ao carregar lista de médicos:", e);
setError("Não foi possível carregar a lista de médicos. Verifique a conexão com a API."); setError("Não foi possível carregar a lista de médicos. Verifique a conexão com a API.");
@ -94,263 +86,181 @@ export default function DoctorsPage() {
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchDoctors(); fetchDoctors();
}, [fetchDoctors]); }, [fetchDoctors]);
// 2. Gerar lista única de especialidades (Normalizada)
const uniqueSpecialties = useMemo(() => {
return getUniqueSpecialties(doctors);
}, [doctors]);
// 3. Lógica de Filtragem Centralizada const openDetailsDialog = async (doctor: Doctor) => {
const filteredDoctors = useMemo(() => {
return doctors.filter((doctor) => {
// Normaliza a especialidade do médico atual para comparar
const normalizedDocSpecialty = normalizeSpecialty(doctor.specialty);
// Filtros exatos
const specialtyMatch = filters.specialty === "all" || normalizedDocSpecialty === filters.specialty;
const statusMatch = filters.status === "all" || doctor.status === filters.status;
// Busca textual (Nome, Telefone, CRM)
const searchLower = searchTerm.toLowerCase();
const nameMatch = doctor.full_name?.toLowerCase().includes(searchLower);
const phoneMatch = doctor.phone_mobile?.includes(searchLower);
const crmMatch = doctor.crm?.toLowerCase().includes(searchLower);
return specialtyMatch && statusMatch && (searchTerm === "" || nameMatch || phoneMatch || crmMatch);
});
}, [doctors, filters, searchTerm]);
// --- Handlers de Controle (Com Reset de Paginação) ---
const handleSearch = (term: string) => {
setSearchTerm(term);
setCurrentPage(1); // Correção: Reseta para página 1 ao buscar
};
const handleFilterChange = (key: string, value: string) => {
setFilters(prev => ({ ...prev, [key]: value }));
setCurrentPage(1); // Correção: Reseta para página 1 ao filtrar
};
const handleClearFilters = () => {
setSearchTerm("");
setFilters({ specialty: "all", status: "all" });
setCurrentPage(1); // Correção: Reseta para página 1 ao limpar
};
const handleItemsPerPageChange = (value: string) => {
setItemsPerPage(Number(value));
setCurrentPage(1);
};
// --- Lógica de Paginação ---
const totalPages = Math.ceil(filteredDoctors.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredDoctors.slice(indexOfFirstItem, indexOfLastItem);
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
const goToPrevPage = () => setCurrentPage((prev) => Math.max(1, prev - 1));
const goToNextPage = () => setCurrentPage((prev) => Math.min(totalPages, prev + 1));
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);
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);
// --- Handlers de Ações (Detalhes e Delete) ---
const openDetailsDialog = (doctor: Doctor) => {
setDetailsDialogOpen(true); setDetailsDialogOpen(true);
setDoctorDetails({ setDoctorDetails({
nome: doctor.full_name, nome: doctor.full_name,
crm: doctor.crm, crm: doctor.crm,
especialidade: normalizeSpecialty(doctor.specialty), // Exibe normalizado especialidade: doctor.specialty,
contato: { celular: doctor.phone_mobile ?? undefined }, contato: {
endereco: { cidade: doctor.city ?? undefined, estado: doctor.state ?? undefined }, celular: doctor.phone_mobile ?? undefined,
status: doctor.status || "Ativo", telefone1: undefined
},
endereco: {
cidade: doctor.city ?? undefined,
estado: doctor.state ?? undefined,
},
convenio: "Particular", convenio: "Particular",
vip: false, vip: false,
status: "Ativo",
ultimo_atendimento: "N/A", ultimo_atendimento: "N/A",
proximo_atendimento: "N/A", proximo_atendimento: "N/A",
}); });
}; };
const handleDelete = async () => {
if (doctorToDeleteId === null) return;
setLoading(true);
try {
await doctorsService.delete(doctorToDeleteId);
console.log(`Médico com ID ${doctorToDeleteId} excluído com sucesso!`);
setDeleteDialogOpen(false);
setDoctorToDeleteId(null);
await fetchDoctors();
} catch (e) {
console.error("Erro ao excluir:", e);
alert("Erro ao excluir médico.");
} finally {
setLoading(false);
}
};
const openDeleteDialog = (doctorId: number) => { const openDeleteDialog = (doctorId: number) => {
setDoctorToDeleteId(doctorId); setDoctorToDeleteId(doctorId);
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
}; };
const handleDelete = async () => {
if (doctorToDeleteId === null) return; const handleEdit = (doctorId: number) => {
setLoading(true);
try { router.push(`/manager/home/${doctorId}/editar`);
await doctorsService.delete(doctorToDeleteId);
setDeleteDialogOpen(false);
setDoctorToDeleteId(null);
await fetchDoctors();
} catch (e) {
console.error("Erro ao excluir:", e);
alert("Erro ao excluir médico.");
} finally {
setLoading(false);
}
}; };
return ( return (
<Sidebar> <ManagerLayout>
<div className="space-y-6 px-2 sm:px-4 md:px-6"> <div className="space-y-6">
{/* Cabeçalho */} <div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div> <div>
<h1 className="text-2xl font-bold"> <h1 className="text-2xl font-bold text-gray-900">Médicos Cadastrados</h1>
Médicos Cadastrados <p className="text-sm text-gray-500">Gerencie todos os profissionais de saúde.</p>
</h1>
<p className="text-sm text-muted-foreground">
Gerencie todos os profissionais de saúde.
</p>
</div> </div>
<Link href="/manager/home/novo">
<Button className="bg-green-600 hover:bg-green-700">
<Plus className="w-4 h-4 mr-2" />
Adicionar Novo
</Button>
</Link>
</div> </div>
{/* --- NOVO COMPONENTE DE FILTRO --- */}
<FilterBar <div className="flex items-center space-x-4 bg-white p-4 rounded-lg border border-gray-200">
searchTerm={searchTerm} <Filter className="w-5 h-5 text-gray-400" />
onSearch={handleSearch} <Select>
activeFilters={filters} <SelectTrigger className="w-[180px]">
onFilterChange={handleFilterChange} <SelectValue placeholder="Especialidade" />
onClearFilters={handleClearFilters}
searchPlaceholder="Buscar por nome, CRM ou telefone..."
filters={[
{
key: "specialty",
label: "Especialidade",
options: uniqueSpecialties
},
{
key: "status",
label: "Status",
options: ["Ativo", "Férias", "Inativo"]
}
]}
>
{/* Seletor de Itens por Página (Filho do FilterBar) */}
<div className="hidden lg:block">
<Select onValueChange={handleItemsPerPageChange} defaultValue={String(itemsPerPage)}>
<SelectTrigger className="w-[70px]">
<SelectValue placeholder="10" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="5">5</SelectItem> <SelectItem value="cardiologia">Cardiologia</SelectItem>
<SelectItem value="10">10</SelectItem> <SelectItem value="dermatologia">Dermatologia</SelectItem>
<SelectItem value="20">20</SelectItem> <SelectItem value="pediatria">Pediatria</SelectItem>
</SelectContent>
</Select>
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ativo">Ativo</SelectItem>
<SelectItem value="ferias">Férias</SelectItem>
<SelectItem value="inativo">Inativo</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</FilterBar>
{/* Tabela de Médicos */}
<div className="bg-card rounded-lg border shadow-md overflow-hidden hidden md:block"> <div className="bg-white rounded-lg border border-gray-200 shadow-md overflow-hidden">
{loading ? ( {loading ? (
<div className="p-8 text-center text-muted-foreground"> <div className="p-8 text-center text-gray-500">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-3 text-primary" /> <Loader2 className="w-8 h-8 animate-spin mx-auto mb-3 text-green-600" />
Carregando médicos... Carregando médicos...
</div> </div>
) : error ? ( ) : error ? (
<div className="p-8 text-center text-destructive">{error}</div> <div className="p-8 text-center text-red-600">
) : filteredDoctors.length === 0 ? ( {error}
<div className="p-8 text-center text-muted-foreground"> </div>
{doctors.length === 0 ) : doctors.length === 0 ? (
? <>Nenhum médico cadastrado. <Link href="/manager/home/novo" className="text-primary hover:underline">Adicione um novo</Link>.</> <div className="p-8 text-center text-gray-500">
: "Nenhum médico encontrado com os filtros aplicados." Nenhum médico cadastrado. <Link href="/manager/home/novo" className="text-green-600 hover:underline">Adicione um novo</Link>.
}
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[600px]"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-muted border-b"> <thead className="bg-gray-50">
<tr> <tr>
<th className="text-left p-2 md:p-4 font-medium text-muted-foreground">Nome</th> <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Nome</th>
<th className="text-left p-2 md:p-4 font-medium text-muted-foreground">CRM</th> <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">CRM</th>
<th className="text-left p-2 md:p-4 font-medium text-muted-foreground">Especialidade</th> <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Especialidade</th>
<th className="text-left p-2 md:p-4 font-medium text-muted-foreground hidden lg:table-cell">Status</th> <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Celular</th>
<th className="text-left p-2 md:p-4 font-medium text-muted-foreground hidden xl:table-cell">Cidade/Estado</th> <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Cidade/Estado</th>
<th className="text-right p-4 font-medium text-muted-foreground">Ações</th> <th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Ações</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-card divide-y"> <tbody className="bg-white divide-y divide-gray-200">
{currentItems.map((doctor) => ( {doctors.map((doctor) => (
<tr key={doctor.id} className="hover:bg-muted transition"> <tr key={doctor.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{doctor.full_name}</td>
{doctor.full_name} <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{doctor.crm}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{doctor.specialty}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{doctor.phone_mobile || "N/A"}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{(doctor.city || doctor.state) ? `${doctor.city || ''}${doctor.city && doctor.state ? '/' : ''}${doctor.state || ''}` : "N/A"}
</td> </td>
<td className="px-4 py-3 text-muted-foreground hidden sm:table-cell">{doctor.crm}</td> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<td className="px-4 py-3 text-muted-foreground hidden md:table-cell">
{/* Exibe Especialidade Normalizada */} <div className="flex justify-end space-x-1">
{normalizeSpecialty(doctor.specialty)}
</td> <Button variant="outline" size="icon" onClick={() => openDetailsDialog(doctor)} title="Visualizar Detalhes">
<td className="px-4 py-3 text-muted-foreground hidden lg:table-cell"> <Eye className="h-4 w-4" />
<span className={`px-2 py-1 rounded-full text-xs ${ </Button>
doctor.status === 'Ativo' ? 'bg-primary/10 text-primary' :
doctor.status === 'Inativo' ? 'bg-destructive/10 text-destructive' : 'bg-yellow-400/10 text-yellow-400' <Button variant="outline" size="icon" onClick={() => handleEdit(doctor.id)} title="Editar">
}`}> <Edit className="h-4 w-4 text-blue-600" />
{doctor.status || "N/A"} </Button>
</span>
</td> <Button variant="outline" size="icon" onClick={() => openDeleteDialog(doctor.id)} title="Excluir">
<td className="px-4 py-3 text-muted-foreground hidden xl:table-cell"> <Trash2 className="h-4 w-4 text-red-600" />
{(doctor.city || doctor.state) </Button>
? `${doctor.city || ""}${doctor.city && doctor.state ? '/' : ''}${doctor.state || ""}`
: "N/A"}
</td>
<td className="px-4 py-3 text-right">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0"> <Button variant="ghost" className="h-8 w-8 p-0" title="Mais Ações">
<span className="sr-only">Abrir menu</span> <span className="sr-only">Mais Ações</span>
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <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> <DropdownMenuItem>
<Calendar className="mr-2 h-4 w-4" /> <Calendar className="mr-2 h-4 w-4" />
Marcar consulta Agendar Consulta
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => openDeleteDialog(doctor.id)}>
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div>
</td> </td>
</tr> </tr>
))} ))}
@ -360,184 +270,55 @@ export default function DoctorsPage() {
)} )}
</div> </div>
{/* Cards de Médicos (Mobile) */}
<div className="bg-card rounded-lg border shadow-md p-4 block md:hidden">
{loading ? (
<div className="p-8 text-center text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-3 text-primary" />
Carregando médicos...
</div>
) : error ? (
<div className="p-8 text-center text-destructive">{error}</div>
) : filteredDoctors.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
{doctors.length === 0
? <>Nenhum médico cadastrado. <Link href="/manager/home/novo" className="text-primary 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-muted rounded-lg p-4 flex justify-between items-center border">
<div>
<div className="font-semibold">{doctor.full_name}</div>
<div className="text-xs text-muted-foreground mb-1">{doctor.phone_mobile}</div>
<div className="text-sm text-muted-foreground">{normalizeSpecialty(doctor.specialty)}</div>
<div className="text-xs mt-1">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doctor.status === 'Ativo' ? 'bg-primary/10 text-primary' :
doctor.status === 'Inativo' ? 'bg-destructive/10 text-destructive' : 'bg-yellow-400/10 text-yellow-400'
}`}>
{doctor.status || "N/A"}
</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<span className="sr-only">Abrir menu</span>
<div className="font-bold text-muted-foreground">...</div>
</Button>
</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 className="text-destructive" 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-card rounded-lg border shadow-md">
<button
onClick={goToPrevPage}
disabled={currentPage === 1}
className="flex items-center px-4 py-2 rounded-md font-medium transition-colors text-sm bg-muted text-muted-foreground hover:bg-muted/90 disabled:opacity-50 disabled:cursor-not-allowed border"
>
{"< Anterior"}
</button>
{visiblePageNumbers.map((number) => (
<button
key={number}
onClick={() => paginate(number)}
className={`px-4 py-2 rounded-md font-medium transition-colors text-sm border ${
currentPage === number
? "bg-primary text-primary-foreground shadow-md border-primary"
: "bg-muted text-muted-foreground hover:bg-muted/90"
}`}
>
{number}
</button>
))}
<button
onClick={goToNextPage}
disabled={currentPage === totalPages}
className="flex items-center px-4 py-2 rounded-md font-medium transition-colors text-sm bg-muted text-muted-foreground hover:bg-muted/90 disabled:opacity-50 disabled:cursor-not-allowed border"
>
{"Próximo >"}
</button>
</div>
)}
{/* Dialogs (Exclusão e Detalhes) */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Confirma a exclusão?</AlertDialogTitle> <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> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancelar</AlertDialogCancel> <AlertDialogCancel disabled={loading}>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive/90" disabled={loading}> <AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700" disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null} {loading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : null}
Excluir Excluir
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<AlertDialog
open={detailsDialogOpen} <AlertDialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
onOpenChange={setDetailsDialogOpen} <AlertDialogContent>
>
<AlertDialogContent className="max-w-[95%] sm:max-w-lg">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className="text-2xl"> <AlertDialogTitle className="text-2xl">{doctorDetails?.nome}</AlertDialogTitle>
{doctorDetails?.nome} <AlertDialogDescription className="text-left text-gray-700">
</AlertDialogTitle>
<AlertDialogDescription className="text-left text-muted-foreground">
{doctorDetails && ( {doctorDetails && (
<div className="space-y-3 text-left"> <div className="space-y-3 text-left">
<h3 className="font-semibold mt-2"> <h3 className="font-semibold mt-2">Informações Principais</h3>
Informações Principais <div className="grid grid-cols-2 gap-y-1 gap-x-4 text-sm">
</h3> <div><strong>CRM:</strong> {doctorDetails.crm}</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-y-2 gap-x-4 text-sm"> <div><strong>Especialidade:</strong> {doctorDetails.especialidade}</div>
<div> <div><strong>Celular:</strong> {doctorDetails.contato.celular || 'N/A'}</div>
<strong>CRM:</strong> {doctorDetails.crm} <div><strong>Localização:</strong> {`${doctorDetails.endereco.cidade || 'N/A'}/${doctorDetails.endereco.estado || 'N/A'}`}</div>
</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> </div>
<h3 className="font-semibold mt-4"> <h3 className="font-semibold mt-4">Atendimento e Convênio</h3>
Atendimento e Convênio <div className="grid grid-cols-2 gap-y-1 gap-x-4 text-sm">
</h3> <div><strong>Convênio:</strong> {doctorDetails.convenio || 'N/A'}</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-y-2 gap-x-4 text-sm"> <div><strong>VIP:</strong> {doctorDetails.vip ? "Sim" : "Não"}</div>
<div> <div><strong>Status:</strong> {doctorDetails.status || 'N/A'}</div>
<strong>Convênio:</strong>{" "} <div><strong>Último atendimento:</strong> {doctorDetails.ultimo_atendimento || 'N/A'}</div>
{doctorDetails.convenio || "N/A"} <div><strong>Próximo atendimento:</strong> {doctorDetails.proximo_atendimento || 'N/A'}</div>
</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>
</div> </div>
)} )}
{doctorDetails === null && !loading && ( {doctorDetails === null && !loading && (
<div className="text-destructive">Detalhes não disponíveis.</div> <div className="text-red-600">Detalhes não disponíveis.</div>
)} )}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
@ -547,6 +328,6 @@ export default function DoctorsPage() {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
</Sidebar> </ManagerLayout>
); );
} }

View File

@ -1,31 +1,12 @@
// Caminho: app/(manager)/login/page.tsx // Caminho: app/(manager)/login/page.tsx
import { LoginForm } from "@/components/LoginForm"; import { LoginForm } from "@/components/LoginForm";
import Link from "next/link"; // Adicionado para o link de "Voltar"
export default function ManagerLoginPage() { export default function ManagerLoginPage() {
// NOTA: Esta página se tornou obsoleta com a criação do /login central.
// O ideal no futuro é deletar esta página e redirecionar os usuários.
return ( return (
<div className="min-h-screen bg-background flex items-center justify-center p-4"> // Mantemos o seu plano de fundo original
<div className="w-full max-w-md text-center"> <div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-50 flex items-center justify-center p-4">
<h1 className="text-3xl font-bold text-foreground mb-2">Área do Gestor</h1> <LoginForm title="Área do Gestor" description="Acesse o sistema médico" role="manager" themeColor="blue" redirectPath="/manager/home" />
<p className="text-muted-foreground mb-8">Acesse o sistema médico</p>
{/* --- ALTERAÇÃO PRINCIPAL AQUI --- */}
{/* Chamando o LoginForm unificado sem props desnecessárias */}
<LoginForm>
{/* Adicionamos um link de "Voltar" como filho (children) */}
<div className="mt-6 text-center text-sm">
<Link href="/">
<span className="font-semibold text-primary hover:underline cursor-pointer">
Voltar à página inicial
</span>
</Link>
</div>
</LoginForm>
</div>
</div> </div>
); );
} }

View File

@ -1,651 +0,0 @@
"use client";
import type React from "react";
import { useState, useEffect, useRef } from "react";
import { useRouter, useParams } from "next/navigation";
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 { 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 { patientsService } from "@/services/patientsApi.mjs";
import Sidebar from "@/components/Sidebar";
export default function EditarPacientePage() {
const router = useRouter();
const params = useParams();
const patientId = params.id;
const { toast } = useToast();
// Photo upload state
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [isUploadingPhoto, setIsUploadingPhoto] = useState(false);
const [photoUrl, setPhotoUrl] = useState<string | null>(null);
// Anexos state
const [anexos, setAnexos] = useState<any[]>([]);
const [isUploadingAnexo, setIsUploadingAnexo] = useState(false);
const anexoInputRef = useRef<HTMLInputElement | null>(null);
// Tipagem completa do formulário
type FormData = {
nome: string;
cpf: string;
dataNascimento: string;
sexo: string;
id?: string;
nomeSocial?: string;
rg?: string;
documentType?: string;
documentNumber?: string;
ethnicity?: string;
race?: string;
naturality?: string;
nationality?: string;
profession?: string;
maritalStatus?: string;
motherName?: string;
motherProfession?: string;
fatherName?: string;
fatherProfession?: string;
guardianName?: string;
guardianCpf?: string;
spouseName?: string;
rnInInsurance?: boolean;
legacyCode?: string;
notes?: string;
email?: string;
phoneMobile?: string;
phone1?: string;
phone2?: string;
cep?: string;
street?: string;
number?: string;
complement?: string;
neighborhood?: string;
city?: string;
state?: string;
reference?: string;
vip?: boolean;
lastVisitAt?: string;
nextAppointmentAt?: string;
createdAt?: string;
updatedAt?: string;
createdBy?: string;
updatedBy?: string;
weightKg?: string;
heightM?: string;
bmi?: string;
bloodType?: string;
};
const [formData, setFormData] = useState<FormData>({
nome: "",
cpf: "",
dataNascimento: "",
sexo: "",
id: "",
nomeSocial: "",
rg: "",
documentType: "",
documentNumber: "",
ethnicity: "",
race: "",
naturality: "",
nationality: "",
profession: "",
maritalStatus: "",
motherName: "",
motherProfession: "",
fatherName: "",
fatherProfession: "",
guardianName: "",
guardianCpf: "",
spouseName: "",
rnInInsurance: false,
legacyCode: "",
notes: "",
email: "",
phoneMobile: "",
phone1: "",
phone2: "",
cep: "",
street: "",
number: "",
complement: "",
neighborhood: "",
city: "",
state: "",
reference: "",
vip: false,
lastVisitAt: "",
nextAppointmentAt: "",
createdAt: "",
updatedAt: "",
createdBy: "",
updatedBy: "",
weightKg: "",
heightM: "",
bmi: "",
bloodType: "",
});
const [isGuiaConvenio, setIsGuiaConvenio] = useState(false);
const [validadeIndeterminada, setValidadeIndeterminada] = useState(false);
useEffect(() => {
async function fetchPatient() {
try {
const res = await patientsService.getById(patientId);
setFormData({
id: res[0]?.id ?? "",
nome: res[0]?.full_name ?? "",
nomeSocial: res[0]?.social_name ?? "",
cpf: res[0]?.cpf ?? "",
rg: res[0]?.rg ?? "",
documentType: res[0]?.document_type ?? "",
documentNumber: res[0]?.document_number ?? "",
sexo: res[0]?.sex ?? "",
dataNascimento: res[0]?.birth_date ?? "",
ethnicity: res[0]?.ethnicity ?? "",
race: res[0]?.race ?? "",
naturality: res[0]?.naturality ?? "",
nationality: res[0]?.nationality ?? "",
profession: res[0]?.profession ?? "",
maritalStatus: res[0]?.marital_status ?? "",
motherName: res[0]?.mother_name ?? "",
motherProfession: res[0]?.mother_profession ?? "",
fatherName: res[0]?.father_name ?? "",
fatherProfession: res[0]?.father_profession ?? "",
guardianName: res[0]?.guardian_name ?? "",
guardianCpf: res[0]?.guardian_cpf ?? "",
spouseName: res[0]?.spouse_name ?? "",
rnInInsurance: res[0]?.rn_in_insurance ?? false,
legacyCode: res[0]?.legacy_code ?? "",
notes: res[0]?.notes ?? "",
email: res[0]?.email ?? "",
phoneMobile: res[0]?.phone_mobile ?? "",
phone1: res[0]?.phone1 ?? "",
phone2: res[0]?.phone2 ?? "",
cep: res[0]?.cep ?? "",
street: res[0]?.street ?? "",
number: res[0]?.number ?? "",
complement: res[0]?.complement ?? "",
neighborhood: res[0]?.neighborhood ?? "",
city: res[0]?.city ?? "",
state: res[0]?.state ?? "",
reference: res[0]?.reference ?? "",
vip: res[0]?.vip ?? false,
lastVisitAt: res[0]?.last_visit_at ?? "",
nextAppointmentAt: res[0]?.next_appointment_at ?? "",
createdAt: res[0]?.created_at ?? "",
updatedAt: res[0]?.updated_at ?? "",
createdBy: res[0]?.created_by ?? "",
updatedBy: res[0]?.updated_by ?? "",
weightKg: res[0]?.weight_kg ? String(res[0].weight_kg) : "",
heightM: res[0]?.height_m ? String(res[0].height_m) : "",
bmi: res[0]?.bmi ? String(res[0].bmi) : "",
bloodType: res[0]?.blood_type ?? "",
});
} catch (e: any) {
toast({ title: "Erro", description: e?.message || "Falha ao carregar paciente" });
}
}
fetchPatient();
}, [patientId, toast]);
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const payload = {
full_name: formData.nome || null,
cpf: formData.cpf || null,
email: formData.email || null,
phone_mobile: formData.phoneMobile || null,
birth_date: formData.dataNascimento || null,
social_name: formData.nomeSocial || null,
sex: formData.sexo || null,
blood_type: formData.bloodType || null,
weight_kg: formData.weightKg ? Number(formData.weightKg) : null,
height_m: formData.heightM ? Number(formData.heightM) : null,
street: formData.street || null,
number: formData.number || null,
complement: formData.complement || null,
neighborhood: formData.neighborhood || null,
city: formData.city || null,
state: formData.state || null,
cep: formData.cep || null,
};
try {
await patientsService.update(patientId, payload);
toast({
title: "Sucesso",
description: "Paciente atualizado com sucesso",
variant: "default"
});
router.push("/manager/pacientes");
} catch (err: any) {
console.error("Erro ao atualizar paciente:", err);
toast({
title: "Erro",
description: err?.message || "Não foi possível atualizar o paciente",
variant: "destructive"
});
}
};
return (
<Sidebar>
<div className="space-y-6 px-2 sm:px-4 pb-20">
{/* --- HEADER RESPONSIVO --- */}
<div className="flex flex-col xl:flex-row gap-6 xl:items-start xl:justify-between">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<Link href="/manager/pacientes">
<Button variant="ghost" size="sm" className="-ml-2">
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Button>
</Link>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-foreground">Editar Paciente</h1>
<p className="text-sm text-muted-foreground">Atualize as informações do paciente</p>
</div>
</div>
{/* Anexos Section */}
<div className="w-full xl:w-auto xl:min-w-[400px] bg-card rounded-lg border border-border p-4 sm:p-6">
<h2 className="text-lg font-semibold text-foreground mb-4 sm: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" size="sm" disabled={isUploadingAnexo} className="w-full sm:w-auto">
<Paperclip className="w-4 h-4 mr-2" /> {isUploadingAnexo ? "Enviando..." : "Adicionar anexo"}
</Button>
</div>
{anexos.length === 0 ? (
<p className="text-sm text-muted-foreground">Nenhum anexo encontrado.</p>
) : (
<ul className="divide-y divide-border">
{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-muted-foreground shrink-0" />
<span className="text-sm text-foreground truncate">{a.nome || a.filename || `Anexo ${a.id}`}</span>
</div>
<Button type="button" variant="ghost" size="sm" className="text-destructive">
<Trash2 className="w-4 h-4 mr-1" /> Remover
</Button>
</li>
))}
</ul>
)}
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6 sm:space-y-8">
{/* --- DADOS PESSOAIS --- */}
<div className="bg-card rounded-lg border border-border p-4 sm:p-6">
<h2 className="text-lg font-semibold text-foreground mb-4 sm:mb-6">Dados Pessoais</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{/* Photo upload Responsivo */}
<div className="space-y-2 col-span-1 md:col-span-2 lg:col-span-3">
<Label>Foto do paciente</Label>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div className="w-20 h-20 rounded-full bg-muted overflow-hidden flex items-center justify-center shrink-0 border">
{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-muted-foreground text-xs text-center px-2">Sem foto</span>
)}
</div>
<div className="flex flex-wrap gap-2 w-full">
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" />
<Button type="button" variant="outline" size="sm" disabled={isUploadingPhoto} className="flex-1 sm:flex-none">
{isUploadingPhoto ? "Enviando..." : "Enviar foto"}
</Button>
{photoUrl && (
<Button type="button" variant="ghost" size="sm" disabled={isUploadingPhoto} className="flex-1 sm:flex-none">
Remover
</Button>
)}
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="nome">Nome *</Label>
<Input id="nome" value={formData.nome} onChange={(e) => handleInputChange("nome", e.target.value)} required />
</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="000.000.000-00" required />
</div>
<div className="space-y-2">
<Label htmlFor="rg">RG</Label>
<Input id="rg" value={formData.rg} onChange={(e) => handleInputChange("rg", e.target.value)} placeholder="00.000.000-0" />
</div>
<div className="space-y-2">
<Label>Sexo *</Label>
<div className="flex flex-wrap gap-4 pt-2">
<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-primary" />
<Label htmlFor="Masculino">Masculino</Label>
</div>
<div className="flex items-center space-x-2">
<input type="radio" id="Feminino" name="sexo" value="Feminino" checked={formData.sexo === "Feminino"} onChange={(e) => handleInputChange("sexo", e.target.value)} className="w-4 h-4 text-primary" />
<Label htmlFor="Feminino">Feminino</Label>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="dataNascimento">Data de nascimento *</Label>
<Input id="dataNascimento" type="date" value={formData.dataNascimento} onChange={(e) => handleInputChange("dataNascimento", e.target.value)} required />
</div>
{/* Demais campos de select e input */}
<div className="space-y-2">
<Label htmlFor="etnia">Etnia</Label>
<Select value={formData.ethnicity} onValueChange={(value) => handleInputChange("ethnicity", value)}>
<SelectTrigger><SelectValue placeholder="Selecione" /></SelectTrigger>
<SelectContent>
<SelectItem value="branca">Branca</SelectItem>
<SelectItem value="preta">Preta</SelectItem>
<SelectItem value="parda">Parda</SelectItem>
<SelectItem value="amarela">Amarela</SelectItem>
<SelectItem value="indigena">Indígena</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="raca">Raça</Label>
<Select value={formData.race} onValueChange={(value) => handleInputChange("race", value)}>
<SelectTrigger><SelectValue placeholder="Selecione" /></SelectTrigger>
<SelectContent>
<SelectItem value="caucasiana">Caucasiana</SelectItem>
<SelectItem value="negroide">Negroide</SelectItem>
<SelectItem value="mongoloide">Mongoloide</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="naturalidade">Naturalidade</Label>
<Input id="naturalidade" value={formData.naturality} onChange={(e) => handleInputChange("naturality", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="nacionalidade">Nacionalidade</Label>
<Select value={formData.nationality} onValueChange={(value) => handleInputChange("nationality", value)}>
<SelectTrigger><SelectValue placeholder="Selecione" /></SelectTrigger>
<SelectContent>
<SelectItem value="brasileira">Brasileira</SelectItem>
<SelectItem value="estrangeira">Estrangeira</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="profissao">Profissão</Label>
<Input id="profissao" value={formData.profession} onChange={(e) => handleInputChange("profession", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="estadoCivil">Estado civil</Label>
<Select value={formData.maritalStatus} onValueChange={(value) => handleInputChange("maritalStatus", value)}>
<SelectTrigger><SelectValue placeholder="Selecione" /></SelectTrigger>
<SelectContent>
<SelectItem value="solteiro">Solteiro(a)</SelectItem>
<SelectItem value="casado">Casado(a)</SelectItem>
<SelectItem value="divorciado">Divorciado(a)</SelectItem>
<SelectItem value="viuvo">Viúvo(a)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="nomeMae">Nome da mãe</Label>
<Input id="nomeMae" value={formData.motherName} onChange={(e) => handleInputChange("motherName", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="profissaoMae">Profissão da mãe</Label>
<Input id="profissaoMae" value={formData.motherProfession} onChange={(e) => handleInputChange("motherProfession", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="nomePai">Nome do pai</Label>
<Input id="nomePai" value={formData.fatherName} onChange={(e) => handleInputChange("fatherName", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="profissaoPai">Profissão do pai</Label>
<Input id="profissaoPai" value={formData.fatherProfession} onChange={(e) => handleInputChange("fatherProfession", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="nomeResponsavel">Nome do responsável</Label>
<Input id="nomeResponsavel" value={formData.guardianName} onChange={(e) => handleInputChange("guardianName", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="cpfResponsavel">CPF do responsável</Label>
<Input id="cpfResponsavel" value={formData.guardianCpf} onChange={(e) => handleInputChange("guardianCpf", e.target.value)} placeholder="000.000.000-00" />
</div>
<div className="space-y-2">
<Label htmlFor="nomeEsposo">Nome do esposo(a)</Label>
<Input id="nomeEsposo" value={formData.spouseName} onChange={(e) => handleInputChange("spouseName", e.target.value)} />
</div>
</div>
<div className="mt-6">
<div className="flex items-center space-x-2">
<Checkbox id="guiaConvenio" checked={isGuiaConvenio} onCheckedChange={(checked) => setIsGuiaConvenio(checked === true)} />
<Label htmlFor="guiaConvenio">RN na Guia do convênio</Label>
</div>
</div>
<div className="mt-6">
<Label htmlFor="observacoes">Observações</Label>
<Textarea id="observacoes" value={formData.notes} onChange={(e) => handleInputChange("notes", e.target.value)} placeholder="Digite observações sobre o paciente..." className="mt-2" />
</div>
</div>
{/* --- CONTATO --- */}
<div className="bg-card rounded-lg border border-border p-4 sm:p-6">
<h2 className="text-lg font-semibold text-foreground mb-4 sm:mb-6">Contato</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 sm: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/>
</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/>
</div>
<div className="space-y-2">
<Label htmlFor="telefone1">Telefone 1</Label>
<Input id="telefone1" value={formData.phone1} onChange={(e) => handleInputChange("phone1", e.target.value)} placeholder="(00) 0000-0000" />
</div>
<div className="space-y-2">
<Label htmlFor="telefone2">Telefone 2</Label>
<Input id="telefone2" value={formData.phone2} onChange={(e) => handleInputChange("phone2", e.target.value)} placeholder="(00) 0000-0000" />
</div>
</div>
</div>
{/* --- ENDEREÇO --- */}
<div className="bg-card rounded-lg border border-border p-4 sm:p-6">
<h2 className="text-lg font-semibold text-foreground mb-4 sm:mb-6">Endereço</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
<div className="space-y-2">
<Label htmlFor="cep">CEP</Label>
<Input id="cep" value={formData.cep} onChange={(e) => handleInputChange("cep", e.target.value)} placeholder="00000-000" />
</div>
<div className="space-y-2 md:col-span-2 lg:col-span-2">
<Label htmlFor="endereco">Endereço</Label>
<Input id="endereco" value={formData.street} onChange={(e) => handleInputChange("street", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="numero">Número</Label>
<Input id="numero" value={formData.number} onChange={(e) => handleInputChange("number", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="complemento">Complemento</Label>
<Input id="complemento" value={formData.complement} onChange={(e) => handleInputChange("complement", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="bairro">Bairro</Label>
<Input id="bairro" value={formData.neighborhood} onChange={(e) => handleInputChange("neighborhood", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="cidade">Cidade</Label>
<Input id="cidade" value={formData.city} onChange={(e) => handleInputChange("city", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="estado">Estado</Label>
<Select value={formData.state} onValueChange={(value) => handleInputChange("state", value)}>
<SelectTrigger><SelectValue placeholder="Selecione" /></SelectTrigger>
<SelectContent>
<SelectItem value="AC">Acre</SelectItem>
<SelectItem value="AL">Alagoas</SelectItem>
<SelectItem value="AP">Amapá</SelectItem>
<SelectItem value="AM">Amazonas</SelectItem>
<SelectItem value="BA">Bahia</SelectItem>
<SelectItem value="CE">Ceará</SelectItem>
<SelectItem value="DF">Distrito Federal</SelectItem>
<SelectItem value="ES">Espírito Santo</SelectItem>
<SelectItem value="GO">Goiás</SelectItem>
<SelectItem value="MA">Maranhão</SelectItem>
<SelectItem value="MT">Mato Grosso</SelectItem>
<SelectItem value="MS">Mato Grosso do Sul</SelectItem>
<SelectItem value="MG">Minas Gerais</SelectItem>
<SelectItem value="PA">Pará</SelectItem>
<SelectItem value="PB">Paraíba</SelectItem>
<SelectItem value="PR">Paraná</SelectItem>
<SelectItem value="PE">Pernambuco</SelectItem>
<SelectItem value="PI">Piauí</SelectItem>
<SelectItem value="RJ">Rio de Janeiro</SelectItem>
<SelectItem value="RN">Rio Grande do Norte</SelectItem>
<SelectItem value="RS">Rio Grande do Sul</SelectItem>
<SelectItem value="RO">Rondônia</SelectItem>
<SelectItem value="RR">Roraima</SelectItem>
<SelectItem value="SC">Santa Catarina</SelectItem>
<SelectItem value="SP">São Paulo</SelectItem>
<SelectItem value="SE">Sergipe</SelectItem>
<SelectItem value="TO">Tocantins</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* --- INFORMAÇÕES MÉDICAS --- */}
<div className="bg-card rounded-lg border border-border p-4 sm:p-6">
<h2 className="text-lg font-semibold text-foreground mb-4 sm:mb-6">Informações Médicas</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6">
<div className="space-y-2">
<Label htmlFor="tipoSanguineo">Tipo Sanguíneo</Label>
<Select value={formData.bloodType} onValueChange={(value) => handleInputChange("bloodType", value)}>
<SelectTrigger><SelectValue placeholder="Selecione" /></SelectTrigger>
<SelectContent>
<SelectItem value="A+">A+</SelectItem>
<SelectItem value="A-">A-</SelectItem>
<SelectItem value="B+">B+</SelectItem>
<SelectItem value="B-">B-</SelectItem>
<SelectItem value="AB+">AB+</SelectItem>
<SelectItem value="AB-">AB-</SelectItem>
<SelectItem value="O+">O+</SelectItem>
<SelectItem value="O-">O-</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="peso">Peso (kg)</Label>
<Input id="peso" type="number" value={formData.weightKg} onChange={(e) => handleInputChange("weightKg", e.target.value)} placeholder="0.0" />
</div>
<div className="space-y-2">
<Label htmlFor="altura">Altura (m)</Label>
<Input id="altura" type="number" step="0.01" value={formData.heightM} onChange={(e) => handleInputChange("heightM", e.target.value)} placeholder="0.00" />
</div>
<div className="space-y-2">
<Label>IMC</Label>
<Input value={formData.weightKg && formData.heightM ? (Number.parseFloat(formData.weightKg) / Number.parseFloat(formData.heightM) ** 2).toFixed(2) : ""} disabled placeholder="Calculado automaticamente" />
</div>
</div>
<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" />
</div>
</div>
{/* --- CONVÊNIO --- */}
<div className="bg-card rounded-lg border border-border p-4 sm:p-6">
<h2 className="text-lg font-semibold text-foreground mb-4 sm:mb-6">Informações de convênio</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6">
<div className="space-y-2">
<Label htmlFor="convenio">Convênio</Label>
<Select onValueChange={(value) => handleInputChange("convenio", value)}>
<SelectTrigger><SelectValue placeholder="Selecione" /></SelectTrigger>
<SelectContent>
<SelectItem value="Particular">Particular</SelectItem>
<SelectItem value="SUS">SUS</SelectItem>
<SelectItem value="Unimed">Unimed</SelectItem>
<SelectItem value="Bradesco">Bradesco Saúde</SelectItem>
<SelectItem value="Amil">Amil</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="plano">Plano</Label>
<Input id="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)} />
</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} />
</div>
</div>
<div className="mt-4">
<div className="flex items-center space-x-2">
<Checkbox id="validadeIndeterminada" checked={validadeIndeterminada} onCheckedChange={(checked) => setValidadeIndeterminada(checked === true)} />
<Label htmlFor="validadeIndeterminada">Validade Indeterminada</Label>
</div>
</div>
</div>
{/* --- BOTÕES DE AÇÃO --- */}
<div className="flex flex-col-reverse sm:flex-row justify-end gap-4 pt-4">
<Link href="/manager/pacientes" className="w-full sm:w-auto">
<Button type="button" variant="outline" className="w-full">
Cancelar
</Button>
</Link>
<Button type="submit" className="bg-primary hover:bg-primary/90 w-full sm:w-auto">
<Save className="w-4 h-4 mr-2" />
Salvar Alterações
</Button>
</div>
</form>
</div>
</Sidebar>
);
}

View File

@ -1,3 +0,0 @@
export default function Loading() {
return null
}

View File

@ -1,338 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
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 { Edit, Trash2, Eye, Calendar, Filter, Loader2, MoreVertical, Phone, MapPin, Activity, ChevronLeft, ChevronRight } from "lucide-react";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { patientsService } from "@/services/patientsApi.mjs";
import Sidebar from "@/components/Sidebar";
export default function PacientesPage() {
// --- ESTADOS ---
const [searchTerm, setSearchTerm] = useState("");
const [convenioFilter, setConvenioFilter] = useState("all");
const [vipFilter, setVipFilter] = useState("all");
const [allPatients, setAllPatients] = useState<any[]>([]);
const [filteredPatients, setFilteredPatients] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// --- PAGINAÇÃO ---
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const totalPages = Math.ceil(filteredPatients.length / pageSize);
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const currentPatients = filteredPatients.slice(startIndex, endIndex);
// --- DIALOGS ---
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [patientToDelete, setPatientToDelete] = useState<string | null>(null);
const [detailsDialogOpen, setDetailsDialogOpen] = useState(false);
const [patientDetails, setPatientDetails] = useState<any | null>(null);
// --- LÓGICA DE NÚMEROS DA PAGINAÇÃO (LIMITADO A 3) ---
const getPageNumbers = () => {
const maxVisible = 3;
if (totalPages <= maxVisible) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
let start = Math.max(1, page - 1);
let end = Math.min(totalPages, start + maxVisible - 1);
if (end === totalPages) {
start = Math.max(1, end - maxVisible + 1);
}
const pages = [];
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
};
// --- FETCH DADOS ---
const fetchAllPacientes = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await patientsService.list();
const mapped = res.map((p: any) => ({
id: String(p.id ?? ""),
nome: p.full_name ?? "—",
telefone: p.phone_mobile ?? p.phone1 ?? "—",
cidade: p.city ?? "—",
estado: p.state ?? "—",
ultimoAtendimento: p.last_visit_at?.split('T')[0] ?? "—",
proximoAtendimento: p.next_appointment_at?.split('T')[0] ?? "—",
vip: Boolean(p.vip ?? false),
convenio: p.convenio ?? "Particular",
status: p.status ?? undefined,
}));
setAllPatients(mapped);
} catch (e: any) {
console.error(e);
setError(e?.message || "Erro ao buscar pacientes");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const filtered = allPatients.filter((patient) => {
const matchesSearch = patient.nome?.toLowerCase().includes(searchTerm.toLowerCase()) || patient.telefone?.includes(searchTerm);
const matchesConvenio = convenioFilter === "all" || patient.convenio === convenioFilter;
const matchesVip = vipFilter === "all" || (vipFilter === "vip" && patient.vip) || (vipFilter === "regular" && !patient.vip);
return matchesSearch && matchesConvenio && matchesVip;
});
setFilteredPatients(filtered);
setPage(1);
}, [allPatients, searchTerm, convenioFilter, vipFilter]);
useEffect(() => {
fetchAllPacientes();
}, []);
// --- AÇÕES ---
const openDetailsDialog = async (patientId: string) => {
setDetailsDialogOpen(true);
setPatientDetails(null);
try {
const res = await patientsService.getById(patientId);
setPatientDetails(Array.isArray(res) ? res[0] : res);
} catch (e: any) {
setPatientDetails({ error: e?.message || "Erro ao buscar detalhes" });
}
};
const handleDeletePatient = async (patientId: string) => {
try {
await patientsService.delete(patientId);
setAllPatients((prev) => prev.filter((p) => String(p.id) !== String(patientId)));
} catch (e: any) {
alert(`Erro ao deletar paciente: ${e?.message || "Erro desconhecido"}`);
}
setDeleteDialogOpen(false);
setPatientToDelete(null);
};
const ActionMenu = ({ patientId }: { patientId: string }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="cursor-pointer p-2 hover:bg-muted rounded-full">
<MoreVertical className="h-4 w-4" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openDetailsDialog(String(patientId))}>
<Eye className="w-4 h-4 mr-2" /> Ver detalhes
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/manager/pacientes/${patientId}/editar`} className="flex items-center w-full">
<Edit className="w-4 h-4 mr-2" /> Editar
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Calendar className="w-4 h-4 mr-2" /> Marcar consulta
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => { setPatientToDelete(patientId); setDeleteDialogOpen(true); }}>
<Trash2 className="w-4 h-4 mr-2" /> Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<Sidebar>
<div className="space-y-6 px-2 sm:px-4 md:px-8 pb-20">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-xl md:text-2xl font-bold">Pacientes</h1>
<p className="text-muted-foreground text-sm md:text-base">Gerencie as informações de seus pacientes</p>
</div>
</div>
{/* Filtros */}
<div className="flex flex-wrap items-center gap-4 bg-card p-4 rounded-lg border">
<Filter className="w-5 h-5 text-muted-foreground" />
<input type="text" placeholder="Buscar por nome ou telefone..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full sm:flex-grow sm:max-w-[300px] p-2 border rounded-md text-sm" />
<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 whitespace-nowrap hidden md:block">Convênio</span>
<Select value={convenioFilter} onValueChange={setConvenioFilter}>
<SelectTrigger className="w-full sm:w-40"><SelectValue placeholder="Convênio" /></SelectTrigger>
<SelectContent><SelectItem value="all">Todos</SelectItem><SelectItem value="Particular">Particular</SelectItem><SelectItem value="SUS">SUS</SelectItem><SelectItem value="Unimed">Unimed</SelectItem></SelectContent>
</Select>
</div>
<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 whitespace-nowrap hidden md:block">VIP</span>
<Select value={vipFilter} onValueChange={setVipFilter}>
<SelectTrigger className="w-full sm:w-32"><SelectValue placeholder="VIP" /></SelectTrigger>
<SelectContent><SelectItem value="all">Todos</SelectItem><SelectItem value="vip">VIP</SelectItem><SelectItem value="regular">Regular</SelectItem></SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto ml-auto sm:ml-0">
<Select value={String(pageSize)} onValueChange={(value) => { setPageSize(Number(value)); setPage(1); }}>
<SelectTrigger className="w-full sm:w-[70px]"><SelectValue placeholder="10" /></SelectTrigger>
<SelectContent><SelectItem value="5">5</SelectItem><SelectItem value="10">10</SelectItem><SelectItem value="20">20</SelectItem></SelectContent>
</Select>
</div>
</div>
{/* Loading / Erro / Conteúdo */}
{error ? (
<div className="p-6 text-destructive bg-card border rounded-lg">{`Erro: ${error}`}</div>
) : loading ? (
<div className="p-6 text-center text-muted-foreground flex items-center justify-center bg-card border rounded-lg"><Loader2 className="w-6 h-6 mr-2 animate-spin text-primary" /> Carregando...</div>
) : (
<>
{/* LISTA MOBILE */}
<div className="grid grid-cols-1 gap-4 md:hidden">
{currentPatients.length === 0 ? (
<div className="p-8 text-center text-muted-foreground bg-card rounded-lg border">Nenhum paciente encontrado.</div>
) : (
currentPatients.map((patient) => (
<div key={patient.id} className="bg-card p-4 rounded-lg border shadow-sm flex flex-col gap-3 relative">
<div className="flex justify-between items-start">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center"><span className="text-primary font-bold text-sm">{patient.nome?.charAt(0) || "?"}</span></div>
<div>
<div className="font-semibold flex items-center gap-2">{patient.nome}{patient.vip && <span className="px-1.5 py-0.5 text-[10px] font-bold rounded-full text-purple-600 bg-purple-100 uppercase">VIP</span>}</div>
<div className="text-xs text-muted-foreground">{patient.convenio}</div>
</div>
</div>
<ActionMenu patientId={String(patient.id)} />
</div>
<div className="grid grid-cols-2 gap-2 text-sm text-muted-foreground mt-2 pt-2 border-t">
<div className="flex items-center gap-2"><Phone className="w-3 h-3" /> {patient.telefone}</div>
<div className="flex items-center gap-2"><MapPin className="w-3 h-3" /> {patient.cidade}</div>
<div className="flex items-center gap-2 col-span-2"><Activity className="w-3 h-3" /> Última: {patient.ultimoAtendimento}</div>
</div>
</div>
))
)}
</div>
{/* TABELA DESKTOP */}
<div className="bg-card rounded-lg border shadow-md hidden md:block">
<div className="overflow-x-auto">
<table className="w-full min-w-[650px]">
<thead className="bg-muted border-b">
<tr>
<th className="text-left p-4 font-medium text-muted-foreground w-[20%]">Nome</th>
<th className="text-left p-4 font-medium text-muted-foreground w-[15%] hidden sm:table-cell">Telefone</th>
<th className="text-left p-4 font-medium text-muted-foreground w-[15%] hidden md:table-cell">Cidade / Estado</th>
<th className="text-left p-4 font-medium text-muted-foreground w-[15%] hidden sm:table-cell">Convênio</th>
<th className="text-left p-4 font-medium text-muted-foreground w-[15%] hidden lg:table-cell">Último atendimento</th>
<th className="text-left p-4 font-medium text-muted-foreground w-[15%] hidden lg:table-cell">Próximo atendimento</th>
<th className="text-left p-4 font-medium text-muted-foreground w-[5%]">Ações</th>
</tr>
</thead>
<tbody>
{currentPatients.length === 0 ? (
<tr><td colSpan={7} className="p-8 text-center text-muted-foreground">Nenhum paciente encontrado</td></tr>
) : (
currentPatients.map((patient) => (
<tr key={patient.id} className="border-b hover:bg-muted">
<td className="p-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center"><span className="text-primary font-medium text-sm">{patient.nome?.charAt(0) || "?"}</span></div>
<span className="font-medium">{patient.nome}{patient.vip && <span className="ml-2 px-2 py-0.5 text-xs font-semibold rounded-full text-purple-400 bg-purple-400/15">VIP</span>}</span>
</div>
</td>
<td className="p-4 text-muted-foreground hidden sm:table-cell">{patient.telefone}</td>
<td className="p-4 text-muted-foreground hidden md:table-cell">{`${patient.cidade} / ${patient.estado}`}</td>
<td className="p-4 text-muted-foreground hidden sm:table-cell">{patient.convenio}</td>
<td className="p-4 text-muted-foreground hidden lg:table-cell">{patient.ultimoAtendimento}</td>
<td className="p-4 text-muted-foreground hidden lg:table-cell">{patient.proximoAtendimento}</td>
<td className="p-4"><ActionMenu patientId={String(patient.id)} /></td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</>
)}
{/* --- RODAPÉ DE PAGINAÇÃO --- */}
{totalPages > 1 && !loading && (
<div className="py-4 px-2 border-t border-border">
{/* 1. PAGINAÇÃO MOBILE (Simples) */}
<div className="flex items-center justify-between md:hidden gap-2">
<Button onClick={() => setPage((prev) => Math.max(1, prev - 1))} disabled={page === 1} variant="outline" size="sm" className="min-w-[90px]">
<ChevronLeft className="w-4 h-4 mr-1" /> Anterior
</Button>
<span className="text-sm font-medium text-muted-foreground">{page} de {totalPages}</span>
<Button onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))} disabled={page === totalPages} variant="outline" size="sm" className="min-w-[90px]">
Próximo <ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
{/* 2. PAGINAÇÃO DESKTOP (Numerada Limitada) */}
<div className="hidden md:flex items-center justify-center gap-2">
<Button onClick={() => setPage((prev) => Math.max(1, prev - 1))} disabled={page === 1} variant="outline" className="px-4">
&lt; Anterior
</Button>
{getPageNumbers().map((pageNum) => (
<Button
key={pageNum}
onClick={() => setPage(pageNum)}
/* CORREÇÃO AQUI: Removemos as classes manuais e usamos apenas o variant */
variant={pageNum === page ? "default" : "outline"}
className="w-10 h-10 p-0"
>
{pageNum}
</Button>
))}
<Button onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))} disabled={page === totalPages} variant="outline" className="px-4">
Próximo &gt;
</Button>
</div>
</div>
)}
{/* Dialogs */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>Confirmar exclusão</AlertDialogTitle><AlertDialogDescription>Tem certeza que deseja excluir este paciente?</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel>Cancelar</AlertDialogCancel><AlertDialogAction onClick={() => patientToDelete && handleDeletePatient(patientToDelete)} className="bg-destructive hover:bg-destructive/90">Excluir</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
<AlertDialogContent className="max-h-[90vh] overflow-y-auto">
<AlertDialogHeader><AlertDialogTitle>Detalhes do Paciente</AlertDialogTitle></AlertDialogHeader>
<AlertDialogDescription>
{patientDetails ? (!patientDetails.error ? (
<div className="grid gap-4 py-4 text-left">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div><p className="font-semibold text-xs text-muted-foreground">NOME</p><p>{patientDetails.full_name}</p></div>
<div><p className="font-semibold text-xs text-muted-foreground">EMAIL</p><p className="break-all">{patientDetails.email}</p></div>
<div><p className="font-semibold text-xs text-muted-foreground">TELEFONE</p><p>{patientDetails.phone_mobile}</p></div>
<div><p className="font-semibold text-xs text-muted-foreground">DATA NASC.</p><p>{patientDetails.birth_date}</p></div>
</div>
<div className="border-t pt-4"><p className="font-semibold text-primary mb-2">Endereço</p><p>{patientDetails.street}, {patientDetails.number}</p><p>{patientDetails.cidade}/{patientDetails.estado}</p></div>
</div>
) : <p className="text-destructive">{patientDetails.error}</p>) : <Loader2 className="w-6 h-6 animate-spin mx-auto text-primary" />}
</AlertDialogDescription>
<AlertDialogFooter><AlertDialogCancel>Fechar</AlertDialogCancel></AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</Sidebar>
);
}

View File

@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Save, Loader2, ArrowLeft } from "lucide-react" import { Save, Loader2, ArrowLeft } from "lucide-react"
import Sidebar from "@/components/Sidebar" import ManagerLayout from "@/components/manager-layout"
// Mock user service for demonstration. Replace with your actual API service. // Mock user service for demonstration. Replace with your actual API service.
const usersService = { const usersService = {
@ -155,24 +155,24 @@ export default function EditarUsuarioPage() {
if (loading) { if (loading) {
return ( return (
<Sidebar> <ManagerLayout>
<div className="flex justify-center items-center h-full w-full py-16"> <div className="flex justify-center items-center h-full w-full py-16">
<Loader2 className="w-8 h-8 animate-spin text-primary" /> <Loader2 className="w-8 h-8 animate-spin text-green-600" />
<p className="ml-2 text-muted-foreground">Carregando dados do usuário...</p> <p className="ml-2 text-gray-600">Carregando dados do usuário...</p>
</div> </div>
</Sidebar> </ManagerLayout>
); );
} }
return ( return (
<Sidebar> <ManagerLayout>
<div className="w-full max-w-2xl mx-auto space-y-6 p-4 md:p-8"> <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 className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground"> <h1 className="text-2xl font-bold text-gray-900">
Editar Usuário: <span className="text-primary">{formData.nomeCompleto}</span> Editar Usuário: <span className="text-green-600">{formData.nomeCompleto}</span>
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-gray-500">
Atualize as informações do usuário (ID: {id}). Atualize as informações do usuário (ID: {id}).
</p> </p>
</div> </div>
@ -184,9 +184,9 @@ export default function EditarUsuarioPage() {
</Link> </Link>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-8 bg-card p-8 border border-border rounded-lg shadow-sm"> <form onSubmit={handleSubmit} className="space-y-8 bg-white p-8 border rounded-lg shadow-sm">
{error && ( {error && (
<div className="p-3 rounded-lg border bg-destructive/10 text-destructive border-destructive/30"> <div className="p-3 bg-red-100 text-red-700 rounded-lg border border-red-300">
<p className="font-medium">Erro na Atualização:</p> <p className="font-medium">Erro na Atualização:</p>
<p className="text-sm">{error}</p> <p className="text-sm">{error}</p>
</div> </div>
@ -261,7 +261,7 @@ export default function EditarUsuarioPage() {
</Link> </Link>
<Button <Button
type="submit" type="submit"
className="bg-primary hover:bg-primary/90" className="bg-green-600 hover:bg-green-700"
disabled={isSaving} disabled={isSaving}
> >
{isSaving ? ( {isSaving ? (
@ -274,6 +274,6 @@ export default function EditarUsuarioPage() {
</div> </div>
</form> </form>
</div> </div>
</Sidebar> </ManagerLayout>
); );
} }

View File

@ -1,55 +1,53 @@
// ARQUIVO COMPLETO PARA: app/manager/usuario/novo/page.tsx "use client"
"use client"; import { useState } from "react"
import { useRouter } from "next/navigation"
import { useState } from "react"; import Link from "next/link"
import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"
import Link from "next/link"; import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Label } from "@/components/ui/label"; import { Save, Loader2 } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import ManagerLayout from "@/components/manager-layout"
import { Save, Loader2 } from "lucide-react"; import { usersService } from "services/usersApi.mjs";
import { usersService } from "@/services/usersApi.mjs";
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 { interface UserFormData {
email: string; email: string;
password: string;
nomeCompleto: string; nomeCompleto: string;
telefone: string; telefone: string;
papel: string; papel: string;
senha: string;
confirmarSenha: string;
cpf: string;
crm: string;
crm_uf: string;
specialty: string;
} }
const defaultFormData: UserFormData = { const defaultFormData: UserFormData = {
email: "", email: '',
nomeCompleto: "", password: '',
telefone: "", nomeCompleto: '',
papel: "", telefone: '',
senha: "", papel: '',
confirmarSenha: "",
cpf: "",
crm: "",
crm_uf: "",
specialty: "",
}; };
const cleanNumber = (value: string): string => value.replace(/\D/g, ""); // Remove todos os caracteres não numéricos
const cleanNumber = (value: string): string => value.replace(/\D/g, '');
// Definição do requisito mínimo de senha
const MIN_PASSWORD_LENGTH = 8;
const formatPhone = (value: string): string => { const formatPhone = (value: string): string => {
const cleaned = cleanNumber(value).substring(0, 11); const cleaned = cleanNumber(value).substring(0, 11);
if (cleaned.length === 11) return cleaned.replace(/(\d{2})(\d{5})(\d{4})/, "($1) $2-$3");
if (cleaned.length === 10) return cleaned.replace(/(\d{2})(\d{4})(\d{4})/, "($1) $2-$3"); if (cleaned.length === 11) {
return cleaned.replace(/(\d{2})(\d{5})(\d{4})/, '($1) $2-$3');
}
if (cleaned.length === 10) {
return cleaned.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2-$3');
}
return cleaned; return cleaned;
}; };
export default function NovoUsuarioPage() { export default function NovoUsuarioPage() {
const router = useRouter(); const router = useRouter();
const [formData, setFormData] = useState<UserFormData>(defaultFormData); const [formData, setFormData] = useState<UserFormData>(defaultFormData);
@ -57,116 +55,140 @@ export default function NovoUsuarioPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleInputChange = (key: keyof UserFormData, value: string) => { const handleInputChange = (key: keyof UserFormData, value: string) => {
let updatedValue = value; const updatedValue = key === 'telefone' ? formatPhone(value) : value;
if (key === "telefone") {
updatedValue = formatPhone(value);
} else if (key === "crm_uf") {
updatedValue = value.toUpperCase();
}
setFormData((prev) => ({ ...prev, [key]: updatedValue })); setFormData((prev) => ({ ...prev, [key]: updatedValue }));
}; };
// Handles form submission
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
if (!formData.email || !formData.nomeCompleto || !formData.papel || !formData.senha || !formData.confirmarSenha || !formData.cpf) { // Basic validation
if (!formData.email || !formData.password || !formData.nomeCompleto || !formData.papel) {
setError("Por favor, preencha todos os campos obrigatórios."); setError("Por favor, preencha todos os campos obrigatórios.");
return; return;
} }
if (formData.senha !== formData.confirmarSenha) { // Validação de comprimento mínimo da senha
setError("A Senha e a Confirmação de Senha não coincidem."); if (formData.password.length < MIN_PASSWORD_LENGTH) {
setError(`A senha deve ter no mínimo ${MIN_PASSWORD_LENGTH} caracteres.`);
return; return;
} }
// 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.");
return;
}
}
setIsSaving(true); setIsSaving(true);
try { // ----------------------------------------------------------------------
if (formData.papel === "medico") { // CORREÇÃO FINAL: Usa o formato de telefone que o mock API comprovadamente aceitou.
const doctorPayload = { // ----------------------------------------------------------------------
email: formData.email.trim().toLowerCase(), const phoneValue = formData.telefone.trim();
// Prepara o payload com os campos obrigatórios
const payload: any = {
email: formData.email,
password: formData.password,
full_name: formData.nomeCompleto, full_name: formData.nomeCompleto,
cpf: formData.cpf, role: formData.papel,
crm: formData.crm,
crm_uf: formData.crm_uf,
specialty: formData.specialty || null,
phone_mobile: formData.telefone || null,
}; };
await doctorsService.create(doctorPayload);
} else { // Adiciona o telefone APENAS se estiver preenchido, enviando o formato FORMATADO.
const isPatient = formData.papel === "paciente"; if (phoneValue.length > 0) {
const userPayload = { payload.phone = phoneValue;
email: formData.email.trim().toLowerCase(),
password: formData.senha,
full_name: formData.nomeCompleto,
phone: formData.telefone || null,
roles: [formData.papel, "paciente"],
cpf: formData.cpf,
create_patient_record: isPatient,
phone_mobile: isPatient ? formData.telefone || null : undefined,
};
await usersService.create_user(userPayload);
} }
// ----------------------------------------------------------------------
try {
await usersService.create_user(payload);
router.push("/manager/usuario"); router.push("/manager/usuario");
} catch (e: any) { } catch (e: any) {
console.error("Erro ao criar usuário:", e); console.error("Erro ao criar usuário:", e);
// 3. MENSAGEM DE ERRO MELHORADA // Melhorando a mensagem de erro para o usuário final
const detail = e.message?.split('detail:"')[1]?.split('"')[0] || e.message; const apiErrorMsg = e.message?.includes("500")
setError(detail.replace(/\\/g, "") || "Não foi possível criar o usuário. Verifique os dados e tente novamente."); ? "Erro interno do servidor. Verifique os logs do backend ou tente novamente mais tarde. (Possível problema: E-mail já em uso ou falha de conexão.)"
: e.message || "Ocorreu um erro inesperado. Tente novamente.";
setError(apiErrorMsg);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}; };
const isMedico = formData.papel === "medico";
return ( return (
<Sidebar> <ManagerLayout>
<div className="w-full h-full p-4 md:p-8 flex justify-center items-start"> <div className="w-full max-w-2xl mx-auto space-y-6 p-4 md:p-8">
<div className="w-full max-w-screen-lg space-y-8"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between border-b pb-4">
<div> <div>
<h1 className="text-3xl font-extrabold">Novo Usuário</h1> <h1 className="text-2xl font-bold text-gray-900">Novo Usuário</h1>
<p className="text-md text-muted-foreground">Preencha os dados para cadastrar um novo usuário no sistema.</p> <p className="text-sm text-gray-500">
Preencha os dados para cadastrar um novo usuário no sistema.
</p>
</div> </div>
<Link href="/manager/usuario"> <Link href="/manager/usuario">
<Button variant="outline">Cancelar</Button> <Button variant="outline">Cancelar</Button>
</Link> </Link>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-6 bg-card p-6 md:p-10 border rounded-xl shadow-lg"> <form onSubmit={handleSubmit} className="space-y-8 bg-white p-8 border rounded-lg shadow-sm">
{/* Error Message Display */}
{error && ( {error && (
<div className="p-4 bg-destructive/10 text-destructive rounded-lg border border-destructive"> <div className="p-3 bg-red-100 text-red-700 rounded-lg border border-red-300">
<p className="font-semibold">Erro no Cadastro:</p> <p className="font-medium">Erro no Cadastro:</p>
<p className="text-sm break-words">{error}</p> <p className="text-sm">{error}</p>
</div> </div>
)} )}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="space-y-4">
<div className="space-y-2 md:col-span-2"> <div className="space-y-2">
<Label htmlFor="nomeCompleto">Nome Completo *</Label> <Label htmlFor="nomeCompleto">Nome Completo *</Label>
<Input id="nomeCompleto" value={formData.nomeCompleto} onChange={(e) => handleInputChange("nomeCompleto", e.target.value)} placeholder="Nome e Sobrenome" required /> <Input
id="nomeCompleto"
value={formData.nomeCompleto}
onChange={(e) => handleInputChange("nomeCompleto", e.target.value)}
placeholder="Nome e Sobrenome"
required
/>
</div> </div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">E-mail *</Label> <Label htmlFor="email">E-mail *</Label>
<Input id="email" type="email" value={formData.email} onChange={(e) => handleInputChange("email", e.target.value)} placeholder="exemplo@dominio.com" required /> <Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
placeholder="exemplo@dominio.com"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Senha *</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)}
placeholder="••••••••"
required
minLength={MIN_PASSWORD_LENGTH} // Adiciona validação HTML
/>
{/* MENSAGEM DE AJUDA PARA SENHA */}
<p className="text-xs text-gray-500">Mínimo de {MIN_PASSWORD_LENGTH} caracteres.</p>
</div>
</div> </div>
<div className="grid md:grid-cols-2 gap-4">
<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"> <div className="space-y-2">
<Label htmlFor="papel">Papel (Função) *</Label> <Label htmlFor="papel">Papel (Função) *</Label>
<Select value={formData.papel} onValueChange={(v) => handleInputChange("papel", v)} required> <Select value={formData.papel} onValueChange={(v) => handleInputChange("papel", v)} required>
@ -174,68 +196,36 @@ export default function NovoUsuarioPage() {
<SelectValue placeholder="Selecione uma função" /> <SelectValue placeholder="Selecione uma função" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="admin">Administrador</SelectItem>
<SelectItem value="gestor">Gestor</SelectItem> <SelectItem value="gestor">Gestor</SelectItem>
<SelectItem value="medico">Médico</SelectItem> <SelectItem value="secretaria">Secretaria</SelectItem>
<SelectItem value="secretaria">Secretária</SelectItem>
<SelectItem value="paciente">Usuário</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{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>
</>
)}
<div className="space-y-2">
<Label htmlFor="senha">Senha *</Label>
<Input id="senha" type="password" value={formData.senha} onChange={(e) => handleInputChange("senha", e.target.value)} placeholder="Mínimo 8 caracteres" minLength={8} required />
</div>
<div className="space-y-2">
<Label htmlFor="confirmarSenha">Confirmar Senha *</Label>
<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-destructive">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> </div>
</div> </div>
<div className="space-y-2"> {/* Action Buttons */}
<Label htmlFor="cpf">Cpf *</Label> <div className="flex justify-end gap-4 pt-4">
<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">
<Link href="/manager/usuario"> <Link href="/manager/usuario">
<Button type="button" variant="outline" disabled={isSaving}> <Button type="button" variant="outline" disabled={isSaving}>
Cancelar Cancelar
</Button> </Button>
</Link> </Link>
<Button type="submit" className="bg-primary hover:bg-primary/90" disabled={isSaving}> <Button
{isSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />} type="submit"
className="bg-green-600 hover:bg-green-700"
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"} {isSaving ? "Salvando..." : "Salvar Usuário"}
</Button> </Button>
</div> </div>
</form> </form>
</div> </div>
</div> </ManagerLayout>
</Sidebar>
); );
} }

View File

@ -1,15 +1,54 @@
"use client"; "use client";
import React, { useEffect, useState, useCallback } from "react"; import React, { useEffect, useState, useCallback } from "react";
import ManagerLayout from "@/components/manager-layout";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; 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 { Input } from "@/components/ui/input"; // <--- 1. Importação Adicionada import { Plus, Edit, Trash2, Eye, Filter, Loader2 } from "lucide-react";
import { Plus, Eye, Filter, Loader2, Search } from "lucide-react"; // <--- 1. Ícone Search Adicionado import {
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; AlertDialog,
import { api, login } from "services/api.mjs"; AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { usersService } from "services/usersApi.mjs"; import { usersService } from "services/usersApi.mjs";
import Sidebar from "@/components/Sidebar";
interface User {
user: {
id: string;
email: string;
email_confirmed_at?: string;
created_at?: string;
last_sign_in_at?: string;
};
profile: {
id?: string;
full_name?: string;
email?: string;
phone?: string | null;
avatar_url?: string;
disabled?: boolean;
created_at?: string;
updated_at?: string;
};
roles: string[];
permissions: {
isAdmin?: boolean;
isManager?: boolean;
isDoctor?: boolean;
isSecretary?: boolean;
isAdminOrManager?: boolean;
[key: string]: boolean | undefined;
};
}
interface FlatUser { interface FlatUser {
id: string; id: string;
@ -20,435 +59,243 @@ interface FlatUser {
role: string; role: string;
} }
interface UserInfoResponse {
user: any;
profile: any;
roles: string[];
permissions: Record<string, boolean>;
}
export default function UsersPage() { export default function UsersPage() {
const router = useRouter();
const [users, setUsers] = useState<FlatUser[]>([]); const [users, setUsers] = useState<FlatUser[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [detailsDialogOpen, setDetailsDialogOpen] = useState(false); const [detailsDialogOpen, setDetailsDialogOpen] = useState(false);
const [userDetails, setUserDetails] = useState<UserInfoResponse | null>(null); const [userDetails, setUserDetails] = useState<User | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// --- Estados de Filtro --- const [userToDeleteId, setUserToDeleteId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState(""); // <--- 2. Estado da busca const [selectedRole, setSelectedRole] = useState<string>("");
const [selectedRole, setSelectedRole] = useState<string>("all");
// --- Lógica de Paginação INÍCIO ---
const [itemsPerPage, setItemsPerPage] = useState(10);
const [currentPage, setCurrentPage] = useState(1);
const handleItemsPerPageChange = (value: string) => {
setItemsPerPage(Number(value));
setCurrentPage(1);
};
// --- Lógica de Paginação FIM ---
const fetchUsers = useCallback(async () => { const fetchUsers = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const rolesData: any[] = await usersService.list_roles(); const data = await usersService.list_roles(); // já retorna o JSON diretamente
const rolesArray = Array.isArray(rolesData) ? rolesData : []; console.log("Resposta da API list_roles:", data);
const profilesData: any[] = await api.get( if (Array.isArray(data)) {
`/rest/v1/profiles?select=id,full_name,email,phone` const mappedUsers: FlatUser[] = data.map((item: any) => ({
); id: item.id || (item.user_id ?? ""), // id da linha ou fallback
user_id: item.user_id || item.id || "", // garante que user_id exista
full_name: item.full_name || "—",
email: item.email || "—",
phone: item.phone ?? "—",
role: item.role || "—",
}));
const profilesById = new Map<string, any>(); setUsers(mappedUsers);
if (Array.isArray(profilesData)) { } else {
for (const p of profilesData) { console.warn("Formato inesperado recebido em list_roles:", data);
if (p?.id) profilesById.set(p.id, p); setUsers([]);
} }
}
const mapped: FlatUser[] = rolesArray.map((roleItem) => {
const uid = roleItem.user_id;
const profile = profilesById.get(uid);
return {
id: uid,
user_id: uid,
full_name: profile?.full_name ?? "—",
email: profile?.email ?? "—",
phone: profile?.phone ?? "—",
role: roleItem.role ?? "—",
};
});
setUsers(mapped);
setCurrentPage(1);
} catch (err: any) { } catch (err: any) {
console.error("Erro ao buscar usuários:", err); console.error("Erro ao buscar usuários:", err);
setError("Não foi possível carregar os usuários. Veja console."); setError("Não foi possível carregar os usuários. Tente novamente.");
setUsers([]); setUsers([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
const init = async () => { fetchUsers();
try {
await login();
} catch (e) {
console.warn("login falhou no init:", e);
}
await fetchUsers();
};
init();
}, [fetchUsers]); }, [fetchUsers]);
const openDetailsDialog = async (flatUser: FlatUser) => { const openDetailsDialog = async (flatUser: FlatUser) => {
setDetailsDialogOpen(true); setDetailsDialogOpen(true);
setUserDetails(null); setUserDetails(null);
try { try {
const data = await usersService.full_data(flatUser.user_id); console.log("Buscando detalhes do user_id:", flatUser.user_id);
setUserDetails(data); const fullUserData: User = await usersService.full_data(flatUser.user_id);
setUserDetails(fullUserData);
} catch (err: any) { } catch (err: any) {
console.error("Erro ao carregar detalhes:", err); console.error("Erro ao buscar detalhes do usuário:", err);
setUserDetails({ setUserDetails({
user: { id: flatUser.user_id, email: flatUser.email }, user: {
profile: { full_name: flatUser.full_name, phone: flatUser.phone }, id: flatUser.user_id,
roles: [flatUser.role], email: flatUser.email || "",
created_at: "Erro ao Carregar",
last_sign_in_at: "Erro ao Carregar",
},
profile: {
full_name: flatUser.full_name || "Erro ao Carregar Detalhes",
phone: flatUser.phone || "—",
},
roles: [],
permissions: {}, permissions: {},
}); } as any);
} }
}; };
// --- 3. Lógica de Filtragem Atualizada ---
const filteredUsers = users.filter((u) => {
// Filtro por Papel (Role)
const roleMatch = selectedRole === "all" || u.role === selectedRole;
// Filtro da Barra de Pesquisa (Nome, Email ou Telefone)
const searchLower = searchTerm.toLowerCase();
const nameMatch = u.full_name?.toLowerCase().includes(searchLower);
const emailMatch = u.email?.toLowerCase().includes(searchLower);
const phoneMatch = u.phone?.includes(searchLower);
const searchMatch = !searchTerm || nameMatch || emailMatch || phoneMatch; const filteredUsers = selectedRole && selectedRole !== "all"
? users.filter((u) => u.role === selectedRole)
: users;
return roleMatch && searchMatch;
});
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredUsers.slice(indexOfFirstItem, indexOfLastItem);
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
const totalPages = Math.ceil(filteredUsers.length / itemsPerPage);
const goToPrevPage = () => {
setCurrentPage((prev) => Math.max(1, prev - 1));
};
const goToNextPage = () => {
setCurrentPage((prev) => Math.min(totalPages, prev + 1));
};
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);
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);
return ( return (
<Sidebar> <ManagerLayout>
<div className="space-y-6 px-2 sm:px-4 md:px-8"> <div className="space-y-6">
{/* Header */} <div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h1 className="text-2xl font-bold">Usuários</h1> <h1 className="text-2xl font-bold text-gray-900">Usuários Cadastrados</h1>
<p className="text-sm text-muted-foreground">Gerencie usuários.</p> <p className="text-sm text-gray-500">Gerencie todos os usuários e seus papéis no sistema.</p>
</div> </div>
<Link href="/manager/usuario/novo" className="w-full sm:w-auto"> <Link href="/manager/usuario/novo">
<Button className="w-full sm:w-auto"> <Button className="bg-green-600 hover:bg-green-700">
<Plus className="w-4 h-4 mr-2" /> Novo Usuário <Plus className="w-4 h-4 mr-2" /> Adicionar Novo
</Button> </Button>
</Link> </Link>
</div> </div>
{/* --- 4. Filtro (Barra de Pesquisa + Selects) --- */}
<div className="flex flex-col md:flex-row items-start md:items-center gap-3 bg-card p-4 rounded-lg border">
{/* Barra de Pesquisa */} <div className="flex items-center space-x-4 bg-white p-4 rounded-lg border border-gray-200">
<div className="relative w-full md:flex-1"> <Filter className="w-5 h-5 text-gray-400" />
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Select onValueChange={setSelectedRole} value={selectedRole}>
<Input <SelectTrigger className="w-[180px]">
placeholder="Buscar por nome, e-mail ou telefone..." <SelectValue placeholder="Filtrar por Papel" />
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1); // Reseta a paginação ao pesquisar
}}
className="pl-10 w-full bg-muted border-border focus:bg-card transition-colors"
/>
</div>
<div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
{/* Select de Filtro por Papel */}
<div className="flex items-center gap-2 w-full md:w-auto">
<Select
onValueChange={(value) => {
setSelectedRole(value);
setCurrentPage(1);
}}
value={selectedRole}>
<SelectTrigger className="w-full sm:w-[150px]">
<SelectValue placeholder="Papel" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Todos</SelectItem> <SelectItem value="all">Todos</SelectItem>
<SelectItem value="admin">Admin</SelectItem> <SelectItem value="admin">Admin</SelectItem>
<SelectItem value="gestor">Gestor</SelectItem> <SelectItem value="gestor">Gestor</SelectItem>
<SelectItem value="medico">Médico</SelectItem> <SelectItem value="medico">Médico</SelectItem>
<SelectItem value="secretaria">Secretária</SelectItem> <SelectItem value="secretaria">Secretaria</SelectItem>
<SelectItem value="user">Usuário</SelectItem> <SelectItem value="user">Usuário</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* Select de Itens por Página */}
<div className="flex items-center gap-2 w-full md:w-auto">
<Select
onValueChange={handleItemsPerPageChange}
defaultValue={String(itemsPerPage)}
>
<SelectTrigger className="w-full sm:w-[80px]">
<SelectValue placeholder="10" />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" className="ml-auto w-full md:w-auto hidden lg:flex"> <div className="bg-white rounded-lg border border-gray-200 shadow-md overflow-hidden">
<Filter className="w-4 h-4 mr-2" />
Filtros
</Button>
</div>
</div>
{/* Fim do Filtro */}
{/* Tabela/Lista */}
<div className="bg-card rounded-lg border shadow-md overflow-x-auto">
{loading ? ( {loading ? (
<div className="p-8 text-center text-muted-foreground"> <div className="p-8 text-center text-gray-500">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-3 text-primary" /> <Loader2 className="w-8 h-8 animate-spin mx-auto mb-3 text-green-600" />
Carregando usuários... Carregando usuários...
</div> </div>
) : error ? ( ) : error ? (
<div className="p-8 text-center text-destructive">{error}</div> <div className="p-8 text-center text-red-600">{error}</div>
) : filteredUsers.length === 0 ? ( ) : filteredUsers.length === 0 ? (
<div className="p-8 text-center text-muted-foreground"> <div className="p-8 text-center text-gray-500">
Nenhum usuário encontrado com os filtros aplicados. Nenhum usuário encontrado.{" "}
<Link href="/manager/usuario/novo" className="text-green-600 hover:underline">
Adicione um novo
</Link>
.
</div> </div>
) : ( ) : (
<> <div className="overflow-x-auto">
{/* Tabela para Telas Médias e Grandes */} <table className="min-w-full divide-y divide-gray-200">
<table className="min-w-full divide-y hidden md:table"> <thead className="bg-gray-50">
<thead className="bg-muted">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
Nome <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nome</th>
</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-muted-foreground uppercase"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Telefone</th>
E-mail <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Papel</th>
</th> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Ações</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
Telefone
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
Cargo
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
Ações
</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-card divide-y"> <tbody className="bg-white divide-y divide-gray-200">
{currentItems.map((u) => ( {filteredUsers.map((user) => (
<tr key={u.id} className="hover:bg-muted">
<td className="px-6 py-4 text-sm"> <tr key={user.id} className="hover:bg-gray-50">
{u.full_name} <td className="px-6 py-4 text-sm text-gray-700">{user.id}</td>
</td> <td className="px-6 py-4 text-sm text-gray-900">{user.full_name || "—"}</td>
<td className="px-6 py-4 text-sm text-muted-foreground break-all"> <td className="px-6 py-4 text-sm text-gray-500">{user.email || "—"}</td>
{u.email} <td className="px-6 py-4 text-sm text-gray-500">{user.phone || "—"}</td>
</td> <td className="px-6 py-4 text-sm text-gray-500 capitalize">{user.role || "—"}</td>
<td className="px-6 py-4 text-sm text-muted-foreground">
{u.phone}
</td>
<td className="px-6 py-4 text-sm text-muted-foreground capitalize">
{u.role}
</td>
<td className="px-6 py-4 text-right"> <td className="px-6 py-4 text-right">
<div className="flex justify-end space-x-1">
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => openDetailsDialog(u)} onClick={() => openDetailsDialog(user)}
title="Visualizar" title="Visualizar"
> >
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
</div>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{/* Layout em Cards/Lista para Telas Pequenas */}
<div className="md:hidden divide-y">
{currentItems.map((u) => (
<div key={u.id} className="flex items-center justify-between p-4 hover:bg-muted">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{u.full_name || "—"}
</div>
<div className="text-xs text-muted-foreground truncate">
{u.email}
</div>
<div className="text-sm text-muted-foreground capitalize mt-1">
{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">
{/* 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-muted text-muted-foreground hover:bg-muted/90 disabled:opacity-50 disabled:cursor-not-allowed 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 ${
currentPage === number
? "bg-primary text-primary-foreground shadow-md border-primary"
: "bg-muted text-muted-foreground hover:bg-muted/90"
}`}
>
{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-muted text-muted-foreground hover:bg-muted/90 disabled:opacity-50 disabled:cursor-not-allowed border"
>
{"Próximo >"}
</button>
</div> </div>
)} )}
</>
)}
</div> </div>
{/* Modal de Detalhes */}
<AlertDialog <AlertDialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
open={detailsDialogOpen}
onOpenChange={setDetailsDialogOpen}
>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className="text-2xl"> <AlertDialogTitle className="text-2xl">
{userDetails?.profile?.full_name || "Detalhes do Usuário"} {userDetails?.profile?.full_name || userDetails?.user?.email || "Detalhes do Usuário"}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{!userDetails ? ( {!userDetails ? (
<div className="p-4 text-center text-muted-foreground"> <div className="p-4 text-center text-gray-500">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-3 text-primary" /> <Loader2 className="w-6 h-6 animate-spin mx-auto mb-3 text-green-600" />
Buscando dados completos... Buscando dados completos...
</div> </div>
) : ( ) : (
<div className="space-y-3 pt-2 text-left text-muted-foreground"> <div className="space-y-3 pt-2 text-left text-gray-700">
<div>
<strong>ID:</strong> {userDetails.user.id} <div><strong>ID:</strong> {userDetails.user.id}</div>
</div> <div><strong>E-mail:</strong> {userDetails.user.email}</div>
<div> <div><strong>Email confirmado em:</strong> {userDetails.user.email_confirmed_at || "—"}</div>
<strong>E-mail:</strong> {userDetails.user.email} <div><strong>Último login:</strong> {userDetails.user.last_sign_in_at || "—"}</div>
</div> <div><strong>Criado em:</strong> {userDetails.user.created_at || "—"}</div>
<div>
<strong>Nome completo:</strong>{" "}
{userDetails.profile.full_name} <div><strong>Nome completo:</strong> {userDetails.profile.full_name || "—"}</div>
</div> <div><strong>Telefone:</strong> {userDetails.profile.phone || "—"}</div>
<div> {userDetails.profile.avatar_url && (
<strong>Telefone:</strong> {userDetails.profile.phone} <div><strong>Avatar:</strong> <img src={userDetails.profile.avatar_url} className="w-16 h-16 rounded-full mt-1" /></div>
</div>
<div>
<strong>Roles:</strong> {userDetails.roles?.join(", ")}
</div>
<div className="pt-2">
<strong className="block mb-1">Permissões:</strong>
<ul className="list-disc list-inside space-y-0.5 text-sm">
{Object.entries(userDetails.permissions || {}).map(
([k, v]) => (
<li key={k}>
{k}:{" "}
<span
className={`font-semibold ${
v ? "text-primary" : "text-destructive"
}`}
>
{v ? "Sim" : "Não"}
</span>
</li>
)
)} )}
<div><strong>Conta desativada:</strong> {userDetails.profile.disabled ? "Sim" : "Não"}</div>
<div><strong>Profile criado em:</strong> {userDetails.profile.created_at || "—"}</div>
<div><strong>Profile atualizado em:</strong> {userDetails.profile.updated_at || "—"}</div>
<div>
<strong>Roles:</strong>
<ul className="list-disc list-inside">
{userDetails.roles.map((role, idx) => <li key={idx}>{role}</li>)}
</ul>
</div>
<div>
<strong>Permissões:</strong>
<ul className="list-disc list-inside">
{Object.entries(userDetails.permissions).map(([key, value]) => (
<li key={key}>{key}: {value ? "Sim" : "Não"}</li>
))}
</ul> </ul>
</div> </div>
</div> </div>
)} )}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@ -457,6 +304,6 @@ export default function UsersPage() {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
</Sidebar> </ManagerLayout>
); );
} }

View File

@ -1,215 +1,112 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { useState } from "react";
import { Stethoscope, Baby, Microscope } from "lucide-react";
import { useAccessibility } from "./context/AccessibilityContext";
export default function InicialPage() { export default function InicialPage() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { contrast } = useAccessibility();
const heroClass = contrast === "high"
? "px-6 md:px-10 lg:px-20 py-20 bg-background text-foreground border-y-2 border-primary"
: "px-6 md:px-10 lg:px-20 py-20 bg-gradient-to-r from-[#1E2A78] via-[#007BFF] to-[#00BFFF] text-white";
return ( return (
<div className="min-h-screen flex flex-col bg-background font-sans scroll-smooth text-foreground"> <div className="min-h-screen flex flex-col bg-background">
{/* Barra superior */} {}
<div className="bg-primary text-primary-foreground text-sm py-2 px-4 md:px-6 flex justify-between items-center"> <div className="bg-primary text-primary-foreground text-sm py-2 px-6 flex justify-between">
<span className="hidden sm:inline">Horário: 08h00 - 21h00</span> <span> Horário: 08h00 - 21h00</span>
<span className="hover:underline cursor-pointer transition"> <span> Email: contato@medconnect.com</span>
Email: contato@mediconnect.com
</span>
</div>
{/* Header */}
<header className="bg-muted text-foreground shadow-md py-4 px-4 md:px-6 flex justify-between items-center relative sticky top-0 z-50 backdrop-blur-md">
<a href="#home" className="flex items-center space-x-2 cursor-pointer">
<img
src="/android-chrome-512x512.png"
alt="Logo MediConnect"
className="w-20 h-20 object-contain transition-transform hover:scale-105"
/>
<h1 className="text-2xl font-extrabold text-foreground tracking-tight">
MedConnect
</h1>
</a>
{/* Menu Mobile */}
<div className="md:hidden flex items-center space-x-4">
<Link href="/login">
<Button
variant="outline"
className="rounded-full px-4 py-2 text-sm border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground transition"
>
Login
</Button>
</Link>
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="text-[#1E2A78] focus:outline-none"
>
<svg
className="w-6 h-6"
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> </div>
{/* Navegação */} {}
<nav <header className="bg-card shadow-md py-4 px-6 flex justify-between items-center">
className={`${ <h1 className="text-2xl font-bold text-primary">MedConnect</h1>
isMenuOpen ? "block" : "hidden" <nav className="flex space-x-6 text-muted-foreground font-medium">
} absolute top-[76px] left-0 w-full bg-white shadow-md py-4 md:relative md:top-auto md:left-auto md:w-auto md:block md:bg-transparent md:shadow-none transition-all duration-300 z-10`} <a href="#home" className="hover:text-primary">Home</a>
> <a href="#about" className="hover:text-primary">Sobre</a>
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-8 text-foreground font-medium items-center"> <a href="#departments" className="hover:text-primary">Departamentos</a>
<Link href="#home" className="hover:text-primary transition"> <a href="#doctors" className="hover:text-primary">Médicos</a>
Home <a href="#contact" className="hover:text-primary">Contato</a>
</Link>
<a href="#about" className="hover:text-primary transition">
Sobre
</a>
<a href="#departments" className="hover:text-primary transition">
Departamentos
</a>
<a href="#doctors" className="hover:text-primary transition">
Médicos
</a>
<a href="#contact" className="hover:text-primary transition">
Contato
</a>
</div>
</nav> </nav>
<div className="flex space-x-4">
{/* Login Desktop */} {}
<div className="hidden md:flex space-x-4"> <Link href="/cadastro">
<Link href="/login">
<Button <Button
variant="outline" variant="outline"
className="rounded-full px-6 py-2 border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground transition cursor-pointer" className="rounded-full px-6 py-2 border-2 transition cursor-pointer"
> >
Login Login
</Button> </Button>
</Link> </Link>
</div> </div>
</header> </header>
{/* Hero Section */}
<section className={`flex flex-col md:flex-row items-center justify-between ${heroClass}`}> {}
<div className="max-w-lg mx-auto md:mx-0"> <section className="flex flex-col md:flex-row items-center justify-between px-10 md:px-20 py-16 bg-background">
<h2 className="uppercase text-sm tracking-widest opacity-80"> <div className="max-w-lg">
Bem-vindo à Saúde Digital <h2 className="text-muted-foreground uppercase text-sm">Bem-vindo à Saúde Digital</h2>
</h2> <h1 className="text-4xl font-extrabold text-foreground leading-tight mt-2">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold leading-tight mt-2 drop-shadow-lg">
Soluções Médicas <br /> & Cuidados com a Saúde Soluções Médicas <br /> & Cuidados com a Saúde
</h1> </h1>
<p className="mt-4 text-base leading-relaxed opacity-90 text-foreground"> <p className="text-muted-foreground mt-4">
Excelência em saúde mais de 25 anos. Atendimento médico com Excelência em saúde mais de 25 anos. Atendimento médicio com qualidade,segurança e carinho.
qualidade, segurança e carinho.
</p> </p>
<div className="mt-8 flex flex-col sm:flex-row space-y-4 sm:space-y-0 sm:space-x-4 justify-center md:justify-start"> <div className="mt-6 flex space-x-4">
<Button className="px-8 py-3 text-base font-semibold bg-card text-card-foreground hover:bg-muted transition-all shadow-md"> <Button>
Nossos Serviços Nossos Serviços
</Button> </Button>
<Button className="px-8 py-3 text-base font-semibold bg-card text-card-foreground hover:bg-muted transition-all shadow-md"> <Button
variant="secondary"
>
Saiba Mais Saiba Mais
</Button> </Button>
</div> </div>
</div> </div>
<div className="mt-10 md:mt-0 flex justify-center"> <div className="mt-10 md:mt-0">
<img <img
src="https://t4.ftcdn.net/jpg/03/20/52/31/360_F_320523164_tx7Rdd7I2XDTvvKfz2oRuRpKOPE5z0ni.jpg" src="https://t4.ftcdn.net/jpg/03/20/52/31/360_F_320523164_tx7Rdd7I2XDTvvKfz2oRuRpKOPE5z0ni.jpg"
alt="Médico" alt="Médico"
className="w-72 sm:w-96 lg:w-[28rem] h-auto object-cover rounded-2xl shadow-xl " className="w-80"
/> />
</div> </div>
</section> </section>
{/* Serviços */}
<section
id="departments"
className="py-20 px-6 md:px-10 lg:px-20 bg-secondary"
>
<h2 className="text-center text-3xl sm:text-4xl font-extrabold text-foreground">
Cuidados completos para a sua saúde
</h2>
<p className="text-center text-muted-foreground mt-3 text-base">
Serviços médicos que oferecemos
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10 mt-12 max-w-6xl mx-auto"> {}
{/* Card */} <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>
title: "Clínica Geral",
desc: "Seu primeiro passo para o cuidado. Atendimento focado na prevenção e no diagnóstico inicial.", <div className="grid grid-cols-1 md:grid-cols-3 gap-8 mt-10">
Icon: Stethoscope, <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">
title: "Pediatria", Seu primeiro passo para o cuidado. Atendimento focado na prevenção e no diagnóstico inicial.
desc: "Cuidado gentil e especializado para garantir a saúde e o desenvolvimento de crianças e adolescentes.",
Icon: Baby,
},
{
title: "Exames",
desc: "Resultados rápidos e precisos em exames laboratoriais e de imagem essenciais para seu diagnóstico.",
Icon: Microscope,
},
].map(({ title, desc, Icon }, index) => (
<div
key={index}
className="p-8 bg-card rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 border border-border group"
>
<div className="flex items-center space-x-3">
<Icon className="text-primary w-6 h-6 group-hover:scale-110 transition-transform" />
<h3 className="text-xl font-semibold">{title}</h3>
</div>
<p className="text-muted-foreground mt-3 text-sm leading-relaxed">
{desc}
</p> </p>
<Button className="mt-6 w-full bg-primary hover:opacity-90 text-primary-foreground transition"> <Button className="mt-4">
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>
<Button className="mt-4">
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>
<Button className="mt-4">
Agendar Agendar
</Button> </Button>
</div> </div>
))}
</div> </div>
</section> </section>
{/* Footer */}
<footer className="bg-primary text-primary-foreground py-8 text-center text-sm border-t-2 border-primary-foreground/20"> {}
<div className="space-y-2"> <footer className="bg-primary text-primary-foreground py-6 text-center">
<p>© 2025 MediConnect Todos os direitos reservados</p> <p>© 2025 MedConnect</p>
<div className="flex justify-center space-x-6 opacity-90">
<a href="#about" className="hover:opacity-70 transition">
Sobre
</a>
<a href="#departments" className="hover:opacity-70 transition">
Serviços
</a>
<a href="#contact" className="hover:opacity-70 transition">
Contato
</a>
</div>
</div>
</footer> </footer>
   
</div> </div>
); );
} }

View File

@ -1,271 +1,288 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState } from "react";
import { import Link from "next/link";
Card, import { toast } from "sonner";
CardContent, import { useAppointments, Appointment } from "../../context/AppointmentsContext";
CardDescription,
CardHeader, // Componentes de UI e Ícones
CardTitle, import PatientLayout from "@/components/patient-layout";
} from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogClose } from "@/components/ui/dialog";
Dialog, import { Input } from "@/components/ui/input";
DialogContent, import { Label } from "@/components/ui/label";
DialogDescription, import { Textarea } from "@/components/ui/textarea";
DialogFooter, import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
DialogHeader, import { Calendar, Clock, MapPin, Phone, CalendarDays, X, Trash2 } from "lucide-react";
DialogTitle,
} from "@/components/ui/dialog";
import {
Calendar,
Clock,
MapPin,
Phone,
User,
X,
AlertCircle,
} from "lucide-react";
import { toast } from "sonner";
import { appointmentsService } from "@/services/appointmentsApi.mjs";
import { usersService } from "@/services/usersApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs";
import Sidebar from "@/components/Sidebar";
export default function PatientAppointmentsPage() { export default function PatientAppointmentsPage() {
const [appointments, setAppointments] = useState<any[]>([]); const { appointments, updateAppointment, deleteAppointment } = useAppointments();
const [isLoading, setIsLoading] = useState(true);
// Estados para cancelamento // Estados para controlar os modais e os dados do formulário
const [cancelModal, setCancelModal] = useState(false); const [isRescheduleModalOpen, setRescheduleModalOpen] = useState(false);
const [selectedAppointment, setSelectedAppointment] = useState<any>(null); const [isCancelModalOpen, setCancelModalOpen] = useState(false);
const [selectedAppointment, setSelectedAppointment] = useState<Appointment | null>(null);
const fetchData = async () => { const [rescheduleData, setRescheduleData] = useState({ date: "", time: "", reason: "" });
setIsLoading(true); const [cancelReason, setCancelReason] = useState("");
try {
// 1. Obter usuário logado // --- MANIPULADORES DE EVENTOS ---
const user = await usersService.getMe();
if (!user || !user.user?.id) { const handleRescheduleClick = (appointment: Appointment) => {
toast.error("Usuário não identificado."); setSelectedAppointment(appointment);
// Preenche o formulário com os dados atuais da consulta
setRescheduleData({ date: appointment.date, time: appointment.time, reason: appointment.observations || "" });
setRescheduleModalOpen(true);
};
const handleCancelClick = (appointment: Appointment) => {
setSelectedAppointment(appointment);
setCancelReason(""); // Limpa o motivo ao abrir
setCancelModalOpen(true);
};
const confirmReschedule = () => {
if (!rescheduleData.date || !rescheduleData.time) {
toast.error("Por favor, selecione uma nova data e horário");
return; return;
} }
if (selectedAppointment) {
// 2. Buscar médicos e agendamentos em paralelo updateAppointment(selectedAppointment.id, {
// Filtra apenas agendamentos deste paciente date: rescheduleData.date,
const queryParams = `patient_id=eq.${"user.user.id"}&order=scheduled_at.desc`; time: rescheduleData.time,
console.log("id do paciente:", user.profile.id); observations: rescheduleData.reason, // Atualiza as observações com o motivo
const [appointmentList, doctorList] = await Promise.all([ });
appointmentsService.search_appointment(queryParams), toast.success("Consulta reagendada com sucesso!");
doctorsService.list(), setRescheduleModalOpen(false);
]);
console.log("Agendamentos obtidos:", appointmentList);
console.log("Médicos obtidos:", doctorList);
// 3. Mapear médicos para acesso rápido
const doctorMap = new Map(doctorList.map((d: any) => [d.id, d]));
// 4. Enriquecer os agendamentos com dados do médico
const enrichedAppointments = appointmentList.map((apt: any) => ({
...apt,
doctor: doctorMap.get(apt.doctor_id) || {
full_name: "Médico não encontrado",
specialty: "Clínico Geral",
location: "Consultório",
phone: "N/A"
},
}));
console.log("Agendamentos enriquecidos:", enrichedAppointments);
setAppointments(enrichedAppointments);
} catch (error) {
console.error("Erro ao buscar dados:", error);
toast.error("Não foi possível carregar suas consultas.");
} finally {
setIsLoading(false);
} }
}; };
useEffect(() => { const confirmCancel = () => {
fetchData(); if (cancelReason.trim().length < 10) {
}, []); toast.error("Por favor, forneça um motivo com pelo menos 10 caracteres.");
return;
// --- LÓGICA DE CANCELAMENTO --- }
const handleCancelClick = (appointment: any) => { if (selectedAppointment) {
setSelectedAppointment(appointment); // Apenas atualiza o status e adiciona o motivo do cancelamento nas observações
setCancelModal(true); updateAppointment(selectedAppointment.id, {
}; status: "Cancelada",
observations: `Motivo do cancelamento: ${cancelReason}`
const confirmCancel = async () => { });
if (!selectedAppointment) return; toast.success("Consulta cancelada com sucesso!");
try { setCancelModalOpen(false);
// Opção A: Deletar o registro (como no código da secretária)
await appointmentsService.delete(selectedAppointment.id);
// Opção B: Se preferir apenas mudar o status, descomente abaixo e comente a linha acima:
// await appointmentsService.update(selectedAppointment.id, { status: 'cancelled' });
setAppointments((prev) =>
prev.filter((apt) => apt.id !== selectedAppointment.id)
);
setCancelModal(false);
toast.success("Consulta cancelada com sucesso.");
} catch (error) {
console.error("Erro ao cancelar consulta:", error);
toast.error("Não foi possível cancelar a consulta.");
} }
}; };
const handleDeleteClick = (appointmentId: string) => {
if (window.confirm("Tem certeza que deseja excluir permanentemente esta consulta? Esta ação não pode ser desfeita.")) {
deleteAppointment(appointmentId);
toast.success("Consulta excluída do histórico.");
}
};
// --- LÓGICA AUXILIAR ---
const getStatusBadge = (status: Appointment['status']) => {
switch (status) {
case "Agendada": return <Badge className="bg-blue-100 text-blue-800 font-medium">Agendada</Badge>;
case "Realizada": return <Badge className="bg-green-100 text-green-800 font-medium">Realizada</Badge>;
case "Cancelada": return <Badge className="bg-red-100 text-red-800 font-medium">Cancelada</Badge>;
}
};
const timeSlots = ["08:00", "08:30", "09:00", "09:30", "10:00", "10:30", "14:00", "14:30", "15:00", "15:30"];
const today = new Date();
today.setHours(0, 0, 0, 0); // Zera o horário para comparar apenas o dia
// ETAPA 1: ORDENAÇÃO DAS CONSULTAS
// Cria uma cópia do array e o ordena
const sortedAppointments = [...appointments].sort((a, b) => {
const statusWeight = { 'Agendada': 1, 'Realizada': 2, 'Cancelada': 3 };
// Primeiro, ordena por status (Agendada vem primeiro)
if (statusWeight[a.status] !== statusWeight[b.status]) {
return statusWeight[a.status] - statusWeight[b.status];
}
// Se o status for o mesmo, ordena por data (mais recente/futura no topo)
return new Date(b.date).getTime() - new Date(a.date).getTime();
});
return ( return (
<Sidebar> <PatientLayout>
<div className="space-y-6"> <div className="space-y-8">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h1 className="text-3xl font-bold">Minhas Consultas</h1> <h1 className="text-3xl font-bold text-gray-900">Minhas Consultas</h1>
<p className="text-muted-foreground"> <p className="text-gray-600">Histórico e consultas agendadas</p>
Acompanhe seu histórico e próximos agendamentos
</p>
</div> </div>
<Link href="/patient/schedule">
<Button className="bg-gray-800 hover:bg-gray-900 text-white">
<Calendar className="mr-2 h-4 w-4" />
Agendar Nova Consulta
</Button>
</Link>
</div> </div>
<div className="grid gap-6"> <div className="grid gap-6">
{isLoading ? ( {/* Utiliza o array ORDENADO para a renderização */}
<p>Carregando consultas...</p> {sortedAppointments.map((appointment) => {
) : appointments.length > 0 ? ( const appointmentDate = new Date(appointment.date);
appointments.map((appointment) => ( let displayStatus = appointment.status;
<Card key={appointment.id}>
if (appointment.status === 'Agendada' && appointmentDate < today) {
displayStatus = 'Realizada';
}
return (
<Card key={appointment.id} className="overflow-hidden">
<CardHeader> <CardHeader>
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<CardTitle className="text-lg"> <CardTitle className="text-xl">{appointment.doctorName}</CardTitle>
{appointment.doctor.full_name} <CardDescription>{appointment.specialty}</CardDescription>
</CardTitle>
<CardDescription>
{appointment.doctor.specialty}
</CardDescription>
</div> </div>
{getStatusBadge(appointment.status)} {getStatusBadge(displayStatus)}
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-x-8 gap-y-4 mb-6">
{/* Coluna 1: Data e Hora */} <div className="space-y-4">
<div className="space-y-3"> <div className="flex items-center text-sm text-gray-700">
<div className="flex items-center text-sm text-foreground font-medium"> <Calendar className="mr-3 h-4 w-4 text-gray-500" />
<User className="mr-2 h-4 w-4 text-muted-foreground" /> {new Date(appointment.date).toLocaleDateString("pt-BR", { timeZone: 'UTC' })}
Dr(a). {appointment.doctor.full_name.split(' ')[0]}
</div> </div>
<div className="flex items-center text-sm text-muted-foreground"> <div className="flex items-center text-sm text-gray-700">
<Calendar className="mr-2 h-4 w-4" /> <Clock className="mr-3 h-4 w-4 text-gray-500" />
{new Date(appointment.scheduled_at).toLocaleDateString( {appointment.time}
"pt-BR",
{ timeZone: "UTC" }
)}
</div>
<div className="flex items-center text-sm text-muted-foreground">
<Clock className="mr-2 h-4 w-4" />
{new Date(appointment.scheduled_at).toLocaleTimeString(
"pt-BR",
{
hour: "2-digit",
minute: "2-digit",
timeZone: "UTC",
}
)}
</div> </div>
</div> </div>
<div className="space-y-4">
{/* Coluna 2: Localização e Contato */} <div className="flex items-center text-sm text-gray-700">
<div className="space-y-3"> <MapPin className="mr-3 h-4 w-4 text-gray-500" />
<div className="flex items-center text-sm text-muted-foreground"> {appointment.location || 'Local não informado'}
<MapPin className="mr-2 h-4 w-4" />
{appointment.doctor.location || "Local a definir"}
</div> </div>
<div className="flex items-center text-sm text-muted-foreground"> <div className="flex items-center text-sm text-gray-700">
<Phone className="mr-2 h-4 w-4" /> <Phone className="mr-3 h-4 w-4 text-gray-500" />
{appointment.doctor.phone || "Contato não disponível"} {appointment.phone || 'Telefone não informado'}
</div> </div>
</div> </div>
</div> </div>
{/* Ações */} {/* Container ÚNICO para todas as ações */}
{["requested", "confirmed"].includes(appointment.status) && ( <div className="flex gap-2 pt-4 border-t">
<div className="flex gap-2 mt-4 pt-4 border-t justify-end"> {(displayStatus === "Agendada") && (
<Button <>
variant="destructive" <Button variant="outline" size="sm" onClick={() => handleRescheduleClick(appointment)}>
size="sm" <CalendarDays className="mr-2 h-4 w-4" />
className="bg-transparent text-destructive hover:bg-destructive/10 border border-destructive/20" Reagendar
onClick={() => handleCancelClick(appointment)}
>
<X className="mr-2 h-4 w-4" />
Cancelar Consulta
</Button> </Button>
</div> <Button variant="ghost" size="sm" className="text-orange-600 hover:text-orange-700 hover:bg-orange-50" onClick={() => handleCancelClick(appointment)}>
<X className="mr-2 h-4 w-4" />
Cancelar
</Button>
</>
)} )}
{(displayStatus === "Realizada" || displayStatus === "Cancelada") && (
<Button variant="ghost" size="sm" className="text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => handleDeleteClick(appointment.id)}>
<Trash2 className="mr-2 h-4 w-4" />
Excluir do Histórico
</Button>
)}
</div>
</CardContent> </CardContent>
</Card> </Card>
)) );
) : ( })}
<div className="text-center py-10 border rounded-lg bg-muted/20">
<Calendar className="mx-auto h-10 w-10 text-muted-foreground mb-4" />
<p className="text-muted-foreground">Você ainda não possui consultas agendadas.</p>
</div>
)}
</div> </div>
</div> </div>
{/* Modal de Confirmação de Cancelamento */} {/* ETAPA 2: CONSTRUÇÃO DOS MODAIS */}
<Dialog open={cancelModal} onOpenChange={setCancelModal}>
{/* Modal de Reagendamento */}
<Dialog open={isRescheduleModalOpen} onOpenChange={setRescheduleModalOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle>Reagendar Consulta</DialogTitle>
<AlertCircle className="h-5 w-5 text-destructive" />
Cancelar Consulta
</DialogTitle>
<DialogDescription> <DialogDescription>
Tem certeza que deseja cancelar sua consulta com{" "} Reagendar consulta com {selectedAppointment?.doctorName}.
<strong>{selectedAppointment?.doctor?.full_name}</strong> no dia{" "}
{selectedAppointment &&
new Date(selectedAppointment.scheduled_at).toLocaleDateString(
"pt-BR", { timeZone: "UTC" }
)}
? Esta ação não pode ser desfeita.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="date" className="text-right">Nova Data</Label>
<Input
id="date"
type="date"
value={rescheduleData.date}
onChange={(e) => setRescheduleData({...rescheduleData, date: e.target.value})}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="time" className="text-right">Novo Horário</Label>
<Select
value={rescheduleData.time}
onValueChange={(value) => setRescheduleData({...rescheduleData, time: value})}
>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Selecione um horário" />
</SelectTrigger>
<SelectContent>
{timeSlots.map(time => <SelectItem key={time} value={time}>{time}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="reason" className="text-right">Motivo</Label>
<Textarea
id="reason"
placeholder="Informe o motivo do reagendamento (opcional)"
value={rescheduleData.reason}
onChange={(e) => setRescheduleData({...rescheduleData, reason: e.target.value})}
className="col-span-3"
/>
</div>
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setCancelModal(false)}> <DialogClose asChild>
Voltar <Button type="button" variant="outline">Cancelar</Button>
</Button> </DialogClose>
<Button variant="destructive" onClick={confirmCancel}> <Button type="button" onClick={confirmReschedule}>Confirmar Reagendamento</Button>
Confirmar Cancelamento
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</Sidebar>
);
}
// Helper para Badges (Mantido consistente com o código da secretária) {/* Modal de Cancelamento */}
const getStatusBadge = (status: string) => { <Dialog open={isCancelModalOpen} onOpenChange={setCancelModalOpen}>
switch (status) { <DialogContent>
case "requested": <DialogHeader>
return ( <DialogTitle>Cancelar Consulta</DialogTitle>
<Badge className="bg-yellow-400/10 text-yellow-600 hover:bg-yellow-400/20 border-yellow-400/20">Solicitada</Badge> <DialogDescription>
Você tem certeza que deseja cancelar sua consulta com {selectedAppointment?.doctorName}? Esta ação não pode ser desfeita.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Label htmlFor="cancelReason">Motivo do Cancelamento (obrigatório)</Label>
<Textarea
id="cancelReason"
placeholder="Por favor, descreva o motivo do cancelamento..."
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
className="mt-2"
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">Voltar</Button>
</DialogClose>
<Button type="button" variant="destructive" onClick={confirmCancel}>Confirmar Cancelamento</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PatientLayout>
); );
case "confirmed":
return <Badge className="bg-primary/10 text-primary hover:bg-primary/20 border-primary/20">Confirmada</Badge>;
case "checked_in":
return (
<Badge className="bg-indigo-400/10 text-indigo-600 hover:bg-indigo-400/20 border-indigo-400/20">Check-in</Badge>
);
case "completed":
return <Badge className="bg-green-400/10 text-green-600 hover:bg-green-400/20 border-green-400/20">Realizada</Badge>;
case "cancelled":
return <Badge className="bg-destructive/10 text-destructive hover:bg-destructive/20 border-destructive/20">Cancelada</Badge>;
case "no_show":
return (
<Badge className="bg-muted text-foreground border-muted-foreground/20">Não Compareceu</Badge>
);
default:
return <Badge variant="secondary">{status}</Badge>;
} }
};

View File

@ -1,32 +1,22 @@
import { import PatientLayout from "@/components/patient-layout"
Card, import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
CardContent, import { Button } from "@/components/ui/button"
CardDescription, import { Calendar, Clock, User, Plus } from "lucide-react"
CardHeader, import Link from "next/link"
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() { export default function PatientDashboard() {
return ( return (
<Sidebar> <PatientLayout>
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1> <h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-muted-foreground"> <p className="text-gray-600">Bem-vindo ao seu portal de consultas médicas</p>
Bem-vindo ao seu portal de consultas médicas
</p>
</div> </div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium">Próxima Consulta</CardTitle>
Próxima Consulta
</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" /> <Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -37,16 +27,12 @@ export default function PatientDashboard() {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium">Consultas Este Mês</CardTitle>
Consultas Este Mês
</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" /> <Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">3</div> <div className="text-2xl font-bold">3</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">2 realizadas, 1 agendada</p>
2 realizadas, 1 agendada
</p>
</CardContent> </CardContent>
</Card> </Card>
@ -66,31 +52,23 @@ export default function PatientDashboard() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Ações Rápidas</CardTitle> <CardTitle>Ações Rápidas</CardTitle>
<CardDescription> <CardDescription>Acesse rapidamente as principais funcionalidades</CardDescription>
Acesse rapidamente as principais funcionalidades
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Link href="/patient/schedule"> <Link href="/patient/schedule">
<Button className="w-full justify-start"> <Button className="w-full justify-start">
<User className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Agendar Nova Consulta Agendar Nova Consulta
</Button> </Button>
</Link> </Link>
<Link href="/patient/appointments"> <Link href="/patient/appointments">
<Button <Button variant="outline" className="w-full justify-start bg-transparent">
variant="secondary"
className="w-full justify-start"
>
<Calendar className="mr-2 h-4 w-4" /> <Calendar className="mr-2 h-4 w-4" />
Ver Minhas Consultas Ver Minhas Consultas
</Button> </Button>
</Link> </Link>
<Link href="/patient/profile"> <Link href="/patient/profile">
<Button <Button variant="outline" className="w-full justify-start bg-transparent">
variant="outline"
className="w-full justify-start"
>
<User className="mr-2 h-4 w-4" /> <User className="mr-2 h-4 w-4" />
Atualizar Dados Atualizar Dados
</Button> </Button>
@ -105,24 +83,24 @@ export default function PatientDashboard() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-muted rounded-lg"> <div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<div> <div>
<p className="font-medium">Dr. Silva</p> <p className="font-medium">Dr. Silva</p>
<p className="text-sm text-muted-foreground">Cardiologia</p> <p className="text-sm text-gray-600">Cardiologia</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="font-medium">15 Jan</p> <p className="font-medium">15 Jan</p>
<p className="text-sm text-muted-foreground">14:30</p> <p className="text-sm text-gray-600">14:30</p>
</div> </div>
</div> </div>
<div className="flex items-center justify-between p-3 bg-muted rounded-lg"> <div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div> <div>
<p className="font-medium">Dra. Santos</p> <p className="font-medium">Dra. Santos</p>
<p className="text-sm text-muted-foreground">Dermatologia</p> <p className="text-sm text-gray-600">Dermatologia</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="font-medium">22 Jan</p> <p className="font-medium">22 Jan</p>
<p className="text-sm text-muted-foreground">10:00</p> <p className="text-sm text-gray-600">10:00</p>
</div> </div>
</div> </div>
</div> </div>
@ -130,6 +108,6 @@ export default function PatientDashboard() {
</Card> </Card>
</div> </div>
</div> </div>
</Sidebar> </PatientLayout>
); )
} }

View File

@ -1,4 +1,4 @@
// Caminho: app/patient/login/page.tsx // Caminho: app/(patient)/login/page.tsx
import Link from "next/link"; import Link from "next/link";
import { LoginForm } from "@/components/LoginForm"; import { LoginForm } from "@/components/LoginForm";
@ -6,12 +6,6 @@ import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
export default function PatientLoginPage() { export default function PatientLoginPage() {
// NOTA: Esta página de login específica para pacientes se tornou obsoleta
// com a criação da nossa página de login central em /login.
// Mantemos este arquivo por enquanto para evitar quebrar outras partes do código,
// mas o ideal no futuro seria deletar esta página e redirecionar
// /patient/login para /login.
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex flex-col items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex flex-col items-center justify-center p-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
@ -22,21 +16,16 @@ export default function PatientLoginPage() {
</Link> </Link>
</div> </div>
{/* --- ALTERAÇÃO PRINCIPAL AQUI --- */} <LoginForm title="Área do Paciente" description="Acesse sua conta para gerenciar consultas" role="patient" themeColor="blue" redirectPath="/patient/dashboard">
{/* Removemos as props desnecessárias (title, description, role, etc.) */}
{/* O novo LoginForm é autônomo e não precisa mais delas. */}
<LoginForm>
{/* Este bloco é passado como 'children' para o LoginForm */} {/* Este bloco é passado como 'children' para o LoginForm */}
<div className="mt-6 text-center text-sm"> <Link href="/patient/register" passHref>
<span className="text-muted-foreground">Não tem uma conta? </span> <Button variant="outline" className="w-full h-12 text-base">
<Link href="/patient/register"> Criar nova conta
<span className="font-semibold text-primary hover:underline cursor-pointer"> </Button>
Crie uma agora
</span>
</Link> </Link>
</div>
</LoginForm> </LoginForm>
{/* Conteúdo e espaçamento restaurados */}
<div className="mt-8 text-center"> <div className="mt-8 text-center">
<p className="text-sm text-muted-foreground">Problemas para acessar? Entre em contato conosco</p> <p className="text-sm text-muted-foreground">Problemas para acessar? Entre em contato conosco</p>
</div> </div>

View File

@ -1,244 +1,67 @@
// Caminho: app/patient/profile/page.tsx "use client"
"use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect } from "react"
import Sidebar from "@/components/Sidebar"; import PatientLayout from "@/components/patient-layout"
import { useAuthLayout } from "@/hooks/useAuthLayout"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { patientsService } from "@/services/patientsApi.mjs"; import { Button } from "@/components/ui/button"
import { usersService } from "@/services/usersApi.mjs"; // Adicionado import import { Input } from "@/components/ui/input"
import { api } from "@/services/api.mjs"; import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { User, Mail, Phone, Calendar, FileText } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; interface PatientData {
import { Button } from "@/components/ui/button"; name: string
import { Input } from "@/components/ui/input"; email: string
import { Label } from "@/components/ui/label"; phone: string
import { User, Mail, Phone, Calendar, Upload } from "lucide-react"; cpf: string
import { toast } from "@/hooks/use-toast"; birthDate: string
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; address: string
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() { export default function PatientProfile() {
const { user, isLoading: isAuthLoading } = useAuthLayout({ const [patientData, setPatientData] = useState<PatientData>({
requiredRole: ["paciente", "admin", "medico", "gestor", "secretaria"], name: "",
}); email: "",
phone: "",
const [patientData, setPatientData] = useState<PatientProfileData | null>(null); cpf: "",
const [isEditing, setIsEditing] = useState(false); birthDate: "",
const [isSaving, setIsSaving] = useState(false); address: "",
const fileInputRef = useRef<HTMLInputElement>(null); })
const [isEditing, setIsEditing] = useState(false)
const getInitials = (name: string) => {
if (!name) return "U";
return name
.split(" ")
.map((n) => n[0])
.slice(0, 2)
.join("")
.toUpperCase();
};
// Função auxiliar para construir URL do avatar
const buildAvatarUrl = (path: string | null | undefined) => {
if (!path) return undefined;
const baseUrl = "https://yuanqfswhberkoevtmfr.supabase.co";
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
const separator = cleanPath.includes('?') ? '&' : '?';
return `${baseUrl}/storage/v1/object/avatars/${cleanPath}${separator}t=${new Date().getTime()}`;
};
useEffect(() => { useEffect(() => {
if (user?.id) { const data = localStorage.getItem("patientData")
const loadData = async () => { if (data) {
try { setPatientData(JSON.parse(data))
// 1. Busca dados médicos (Tabela Patients)
const patientDetails = await patientsService.getById(user.id);
// 2. Busca dados de sistema frescos (Tabela Profiles via getMe)
// Isso garante que pegamos o avatar real do banco, não do cache local
const userSystemData = await usersService.getMe();
const freshAvatarPath = userSystemData?.profile?.avatar_url;
const freshAvatarUrl = buildAvatarUrl(freshAvatarPath);
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: freshAvatarUrl, // Usa a URL fresca do banco
});
} catch (error) {
console.error("Erro ao buscar detalhes:", error);
toast({
title: "Erro",
description: "Não foi possível carregar seus dados completos.",
variant: "destructive",
});
} }
}; }, [])
loadData();
const handleSave = () => {
localStorage.setItem("patientData", JSON.stringify(patientData))
setIsEditing(false)
alert("Dados atualizados com sucesso!")
} }
}, [user?.id, user?.email, user?.name]); // Removi user.avatarFullUrl para não depender do cache
const handleInputChange = ( const handleInputChange = (field: keyof PatientData, value: string) => {
field: keyof PatientProfileData, setPatientData((prev) => ({
value: string ...prev,
) => { [field]: value,
setPatientData((prev) => (prev ? { ...prev, [field]: value } : null)); }))
};
const updateLocalSession = (updates: { full_name?: string; avatar_url?: string }) => {
try {
const storedUserString = localStorage.getItem("user_info");
if (storedUserString) {
const storedUser = JSON.parse(storedUserString);
if (!storedUser.user_metadata) storedUser.user_metadata = {};
if (updates.full_name) storedUser.user_metadata.full_name = updates.full_name;
if (updates.avatar_url) storedUser.user_metadata.avatar_url = updates.avatar_url;
if (!storedUser.profile) storedUser.profile = {};
if (updates.full_name) storedUser.profile.full_name = updates.full_name;
if (updates.avatar_url) storedUser.profile.avatar_url = updates.avatar_url;
localStorage.setItem("user_info", JSON.stringify(storedUser));
}
} catch (e) {
console.error("Erro ao atualizar sessão local:", e);
}
};
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);
await api.patch(`/rest/v1/profiles?id=eq.${user.id}`, {
full_name: patientData.name,
});
updateLocalSession({ full_name: patientData.name });
toast({
title: "Sucesso!",
description: "Seus dados foram atualizados. A página será recarregada.",
});
setIsEditing(false);
setTimeout(() => window.location.reload(), 1000);
} 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();
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 = buildAvatarUrl(filePath);
setPatientData((prev) =>
prev ? { ...prev, avatarFullUrl: newFullUrl } : null
);
updateLocalSession({ avatar_url: filePath });
toast({
title: "Sucesso!",
description: "Sua foto de perfil foi atualizada.",
});
setTimeout(() => window.location.reload(), 1000);
} 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 className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Carregando seus dados...</p>
</div>
</Sidebar>
);
} }
return ( return (
<Sidebar> <PatientLayout>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Meus Dados</h1> <h1 className="text-3xl font-bold text-gray-900">Meus Dados</h1>
<p className="text-muted-foreground">Gerencie suas informações pessoais</p> <p className="text-gray-600">Gerencie suas informações pessoais</p>
</div> </div>
<Button <Button
onClick={() => (isEditing ? handleSave() : setIsEditing(true))} onClick={() => (isEditing ? handleSave() : setIsEditing(true))}
disabled={isSaving} variant={isEditing ? "default" : "outline"}
className="bg-blue-600 hover:bg-blue-700 text-white"
> >
{isEditing {isEditing ? "Salvar Alterações" : "Editar Dados"}
? isSaving
? "Salvando..."
: "Salvar Alterações"
: "Editar Dados"}
</Button> </Button>
</div> </div>
@ -250,21 +73,20 @@ export default function PatientProfile() {
<User className="mr-2 h-5 w-5" /> <User className="mr-2 h-5 w-5" />
Informações Pessoais Informações Pessoais
</CardTitle> </CardTitle>
<CardDescription>Seus dados pessoais básicos</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div> <div className="space-y-2">
<Label htmlFor="name">Nome Completo</Label> <Label htmlFor="name">Nome Completo</Label>
<Input <Input
id="name" id="name"
value={patientData.name} value={patientData.name}
onChange={(e) => onChange={(e) => handleInputChange("name", e.target.value)}
handleInputChange("name", e.target.value)
}
disabled={!isEditing} disabled={!isEditing}
/> />
</div> </div>
<div> <div className="space-y-2">
<Label htmlFor="cpf">CPF</Label> <Label htmlFor="cpf">CPF</Label>
<Input <Input
id="cpf" id="cpf"
@ -274,96 +96,61 @@ export default function PatientProfile() {
/> />
</div> </div>
</div> </div>
<div>
<div className="space-y-2">
<Label htmlFor="birthDate">Data de Nascimento</Label> <Label htmlFor="birthDate">Data de Nascimento</Label>
<Input <Input
id="birthDate" id="birthDate"
type="date" type="date"
value={patientData.birthDate} value={patientData.birthDate}
onChange={(e) => onChange={(e) => handleInputChange("birthDate", e.target.value)}
handleInputChange("birthDate", e.target.value)
}
disabled={!isEditing} disabled={!isEditing}
/> />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center"> <CardTitle className="flex items-center">
<Mail className="mr-2 h-5 w-5" /> <Mail className="mr-2 h-5 w-5" />
Contato e Endereço Contato
</CardTitle> </CardTitle>
<CardDescription>Informações de contato</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
value={patientData.email} value={patientData.email}
disabled onChange={(e) => handleInputChange("email", e.target.value)}
disabled={!isEditing}
/> />
</div> </div>
<div> <div className="space-y-2">
<Label htmlFor="phone">Telefone</Label> <Label htmlFor="phone">Telefone</Label>
<Input <Input
id="phone" id="phone"
value={patientData.phone} value={patientData.phone}
onChange={(e) => onChange={(e) => handleInputChange("phone", e.target.value)}
handleInputChange("phone", e.target.value)
}
disabled={!isEditing} disabled={!isEditing}
/> />
</div> </div>
</div> </div>
<div className="grid md:grid-cols-3 gap-4">
<div> <div className="space-y-2">
<Label htmlFor="cep">CEP</Label> <Label htmlFor="address">Endereço</Label>
<Input <Textarea
id="cep" id="address"
value={patientData.cep} value={patientData.address}
onChange={(e) => handleInputChange("cep", e.target.value)} onChange={(e) => handleInputChange("address", e.target.value)}
disabled={!isEditing} disabled={!isEditing}
rows={3}
/> />
</div> </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> </CardContent>
</Card> </Card>
</div> </div>
@ -375,62 +162,61 @@ export default function PatientProfile() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="relative group"> <div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<Avatar <User className="h-6 w-6 text-blue-600" />
className="w-16 h-16 cursor-pointer border-2 border-transparent group-hover:border-blue-500 transition-all"
onClick={handleAvatarClick}
>
<AvatarImage src={patientData.avatarFullUrl} className="object-cover" />
<AvatarFallback className="text-2xl bg-gray-200 text-gray-700 font-bold">
{getInitials(patientData.name)}
</AvatarFallback>
</Avatar>
<div
className="absolute bottom-0 right-0 bg-blue-600 text-white rounded-full p-1.5 cursor-pointer hover:bg-blue-700 shadow-md transition-colors"
onClick={handleAvatarClick}
title="Alterar foto"
>
<Upload className="w-3 h-3" />
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleAvatarUpload}
className="hidden"
accept="image/png, image/jpeg, image/jpg"
/>
</div> </div>
<div> <div>
<p className="font-medium text-lg">{patientData.name}</p> <p className="font-medium">{patientData.name}</p>
<p className="text-sm text-gray-500">Paciente</p> <p className="text-sm text-gray-500">Paciente</p>
</div> </div>
</div> </div>
<div className="space-y-3 pt-4 border-t"> <div className="space-y-3 pt-4 border-t">
<div className="flex items-center text-sm"> <div className="flex items-center text-sm">
<Mail className="mr-2 h-4 w-4 text-muted-foreground" /> <Mail className="mr-2 h-4 w-4 text-gray-500" />
<span className="truncate">{patientData.email}</span> <span className="truncate">{patientData.email}</span>
</div> </div>
<div className="flex items-center text-sm"> <div className="flex items-center text-sm">
<Phone className="mr-2 h-4 w-4 text-muted-foreground" /> <Phone className="mr-2 h-4 w-4 text-gray-500" />
<span>{patientData.phone || "Não informado"}</span> <span>{patientData.phone}</span>
</div> </div>
<div className="flex items-center text-sm"> <div className="flex items-center text-sm">
<Calendar className="mr-2 h-4 w-4 text-muted-foreground" /> <Calendar className="mr-2 h-4 w-4 text-gray-500" />
<span> <span>
{patientData.birthDate {patientData.birthDate
? new Date(patientData.birthDate).toLocaleDateString( ? new Date(patientData.birthDate).toLocaleDateString("pt-BR")
"pt-BR",
{ timeZone: "UTC" }
)
: "Não informado"} : "Não informado"}
</span> </span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </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> </div>
</div> </div>
</Sidebar> </PatientLayout>
); )
} }

View File

@ -1,6 +1,7 @@
"use client" "use client"
import type React from "react" import type React from "react"
import { useState } from "react" import { useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
@ -8,24 +9,24 @@ import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { ArrowLeft, Loader2 } from "lucide-react" import { Textarea } from "@/components/ui/textarea"
import { useToast } from "@/hooks/use-toast" import { Eye, EyeOff, ArrowLeft } from "lucide-react"
import { usersService } from "@/services/usersApi.mjs" // Mantém a importação
import { isValidCPF } from "@/lib/utils"
export default function PatientRegister() { export default function PatientRegister() {
// REMOVIDO: Estados para 'showPassword' e 'showConfirmPassword' const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
email: "", email: "",
password: "",
confirmPassword: "",
phone: "", phone: "",
cpf: "", cpf: "",
birthDate: "", birthDate: "",
// REMOVIDO: Campos 'password' e 'confirmPassword' address: "",
}) })
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const router = useRouter() const router = useRouter()
const { toast } = useToast()
const handleInputChange = (field: string, value: string) => { const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ setFormData((prev) => ({
@ -36,144 +37,166 @@ export default function PatientRegister() {
const handleRegister = async (e: React.FormEvent) => { const handleRegister = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setIsLoading(true)
// --- VALIDAÇÃO DE CPF --- if (formData.password !== formData.confirmPassword) {
if (!isValidCPF(formData.cpf)) { alert("As senhas não coincidem!")
toast({
title: "CPF Inválido",
description: "O CPF informado não é válido. Verifique os dígitos.",
variant: "destructive",
})
setIsLoading(false)
return return
} }
// --- LÓGICA DE REGISTRO COM ENDPOINT PÚBLICO --- setIsLoading(true)
try {
// ALTERADO: Payload ajustado para o endpoint 'register-patient'
const payload = {
email: formData.email.trim().toLowerCase(),
full_name: formData.name,
phone_mobile: formData.phone, // O endpoint espera 'phone_mobile'
cpf: formData.cpf.replace(/\D/g, ''),
birth_date: formData.birthDate,
}
// ALTERADO: Chamada para a nova função de serviço // Simulação de registro - em produção, conectar com API real
await usersService.registerPatient(payload) setTimeout(() => {
// Salvar dados do usuário no localStorage para simulação
// ALTERADO: Mensagem de sucesso para refletir o fluxo de confirmação por e-mail const { confirmPassword, ...userData } = formData
toast({ localStorage.setItem("patientData", JSON.stringify(userData))
title: "Cadastro enviado com sucesso!", router.push("/patient/dashboard")
description: "Enviamos um link de confirmação para o seu e-mail. Por favor, verifique sua caixa de entrada para ativar sua conta.",
})
// Redireciona para a página de login
router.push("/login")
} catch (error: any) {
console.error("Erro no registro:", error)
toast({
title: "Erro ao Criar Conta",
description: error.message || "Não foi possível concluir o cadastro. Verifique seus dados e tente novamente.",
variant: "destructive",
})
} finally {
setIsLoading(false) setIsLoading(false)
} }, 1000)
} }
return ( return (
<div className="min-h-screen bg-background py-8 px-4"> <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-8 px-4">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<div className="mb-6"> <div className="mb-6">
<Link href="/" className="inline-flex items-center text-primary hover:text-primary/90"> <Link href="/" className="inline-flex items-center text-blue-600 hover:text-blue-800">
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 mr-2" />
Voltar ao início Voltar ao início
</Link> </Link>
</div> </div>
<Card> <Card>
<CardHeader className="text-center"> <CardHeader className="text-center">
<CardTitle className="text-2xl text-foreground">Crie sua Conta de Paciente</CardTitle> <CardTitle className="text-2xl">Cadastro de Paciente</CardTitle>
<CardDescription className="text-muted-foreground">Preencha seus dados para acessar o portal MedConnect</CardDescription> <CardDescription>Preencha seus dados para criar sua conta</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleRegister} className="space-y-4"> <form onSubmit={handleRegister} className="space-y-4">
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Nome Completo *</Label> <Label htmlFor="name">Nome Completo</Label>
<Input <Input
id="name" id="name"
value={formData.name} value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)} onChange={(e) => handleInputChange("name", e.target.value)}
required required
disabled={isLoading}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cpf">CPF *</Label> <Label htmlFor="cpf">CPF</Label>
<Input <Input
id="cpf" id="cpf"
value={formData.cpf} value={formData.cpf}
onChange={(e) => handleInputChange("cpf", e.target.value)} onChange={(e) => handleInputChange("cpf", e.target.value)}
placeholder="000.000.000-00" placeholder="000.000.000-00"
required required
disabled={isLoading}
/> />
</div> </div>
</div> </div>
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email *</Label> <Label htmlFor="email">Email</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)} onChange={(e) => handleInputChange("email", e.target.value)}
required required
disabled={isLoading}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="phone">Telefone *</Label> <Label htmlFor="phone">Telefone</Label>
<Input <Input
id="phone" id="phone"
value={formData.phone} value={formData.phone}
onChange={(e) => handleInputChange("phone", e.target.value)} onChange={(e) => handleInputChange("phone", e.target.value)}
placeholder="(11) 99999-9999" placeholder="(11) 99999-9999"
required required
disabled={isLoading}
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="birthDate">Data de Nascimento *</Label> <Label htmlFor="birthDate">Data de Nascimento</Label>
<Input <Input
id="birthDate" id="birthDate"
type="date" type="date"
value={formData.birthDate} value={formData.birthDate}
onChange={(e) => handleInputChange("birthDate", e.target.value)} onChange={(e) => handleInputChange("birthDate", e.target.value)}
required required
disabled={isLoading}
/> />
</div> </div>
{/* REMOVIDO: Seção de senha e confirmação de senha */} <div className="space-y-2">
<Label htmlFor="address">Endereço</Label>
<Textarea
id="address"
value={formData.address}
onChange={(e) => handleInputChange("address", e.target.value)}
placeholder="Rua, número, bairro, cidade, estado"
rows={3}
required
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="password">Senha</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)}
required
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirmar Senha</Label>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
value={formData.confirmPassword}
onChange={(e) => handleInputChange("confirmPassword", e.target.value)}
required
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
<Button type="submit" className="w-full" disabled={isLoading}> <Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Criando conta...</> : "Criar Conta"} {isLoading ? "Criando conta..." : "Criar Conta"}
</Button> </Button>
</form> </form>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-sm"> <p className="text-sm text-gray-600">
<span className="text-muted-foreground"> tem uma conta?</span>{" "} tem uma conta?{" "}
<Link href="/login" className="text-primary hover:underline font-medium"> <Link href="/patient/login" className="text-blue-600 hover:underline">
Faça login aqui Faça login aqui
</Link> </Link>
</p> </p>

View File

@ -1,68 +1,159 @@
"use client" "use client"
import { useState, useEffect, useMemo } from "react" import { useState, useEffect } from "react"
import PatientLayout from "@/components/patient-layout"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { toast } from "@/hooks/use-toast" import { toast } from "@/hooks/use-toast"
import { FileText, Download, Eye, Calendar, User, X, Loader2 } from "lucide-react" import { FileText, Download, Eye, Calendar, User, X } from "lucide-react"
import Sidebar from "@/components/Sidebar"
import { useAuthLayout } from "@/hooks/useAuthLayout"
import { reportsApi } from "@/services/reportsApi.mjs"
interface Report { interface Report {
id: string; id: string
order_number: string; title: string
patient_id: string; doctor: string
status: string; date: string
exam: string; type: string
requested_by: string; status: "disponivel" | "pendente"
cid_code: string; description: string
diagnosis: string; content: {
conclusion: string; patientInfo: {
content_html: string; name: string
content_json: any; age: number
hide_date: boolean; gender: string
hide_signature: boolean; id: string
due_at: string; }
created_by: string; examDetails: {
updated_by: string; requestingDoctor: string
created_at: string; examDate: string
updated_at: string; reportDate: string
technique: string
}
findings: string
conclusion: string
recommendations?: string
}
} }
export default function ReportsPage() { export default function ReportsPage() {
const [reports, setReports] = useState<Report[]>([]) const [reports, setReports] = useState<Report[]>([])
const [selectedReport, setSelectedReport] = useState<Report | null>(null) const [selectedReport, setSelectedReport] = useState<Report | null>(null)
const [isViewModalOpen, setIsViewModalOpen] = useState(false) const [isViewModalOpen, setIsViewModalOpen] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const { user, isLoading: isAuthLoading } = useAuthLayout({
requiredRole: ["paciente", "admin", "medico", "gestor", "secretaria"],
  });
useEffect(() => { useEffect(() => {
if (user) { const mockReports: Report[] = [
const fetchReports = async () => { {
try { id: "1",
setIsLoading(true); title: "Exame de Sangue - Hemograma Completo",
const fetchedReports = await reportsApi.getReports(user.id); doctor: "Dr. João Silva",
setReports(fetchedReports); date: "2024-01-15",
} catch (error) { type: "Exame Laboratorial",
console.error("Erro ao buscar laudos:", error) status: "disponivel",
toast({ description: "Hemograma completo com contagem de células sanguíneas",
title: "Erro ao buscar laudos", content: {
description: "Não foi possível carregar os laudos. Tente novamente.", patientInfo: {
variant: "destructive", name: "Maria Silva Santos",
}) age: 35,
} finally { gender: "Feminino",
setIsLoading(false); id: "123.456.789-00",
} },
} examDetails: {
fetchReports() requestingDoctor: "Dr. João Silva - CRM 12345",
} examDate: "15/01/2024",
}, [user?.id]) reportDate: "15/01/2024",
technique: "Análise automatizada com confirmação microscópica",
},
findings:
"Hemácias: 4.5 milhões/mm³ (VR: 4.0-5.2)\nHemoglobina: 13.2 g/dL (VR: 12.0-15.5)\nHematócrito: 40% (VR: 36-46)\nLeucócitos: 7.200/mm³ (VR: 4.000-11.000)\nPlaquetas: 280.000/mm³ (VR: 150.000-450.000)\n\nFórmula leucocitária:\n- Neutrófilos: 65% (VR: 50-70%)\n- Linfócitos: 28% (VR: 20-40%)\n- Monócitos: 5% (VR: 2-8%)\n- Eosinófilos: 2% (VR: 1-4%)",
conclusion:
"Hemograma dentro dos parâmetros normais. Não foram observadas alterações significativas na série vermelha, branca ou plaquetária.",
recommendations: "Manter acompanhamento médico regular. Repetir exame conforme orientação médica.",
},
},
{
id: "2",
title: "Radiografia do Tórax",
doctor: "Dra. Maria Santos",
date: "2024-01-10",
type: "Exame de Imagem",
status: "disponivel",
description: "Radiografia PA e perfil do tórax",
content: {
patientInfo: {
name: "Maria Silva Santos",
age: 35,
gender: "Feminino",
id: "123.456.789-00",
},
examDetails: {
requestingDoctor: "Dra. Maria Santos - CRM 67890",
examDate: "10/01/2024",
reportDate: "10/01/2024",
technique: "Radiografia digital PA e perfil",
},
findings:
"Campos pulmonares livres, sem sinais de consolidação ou derrame pleural. Silhueta cardíaca dentro dos limites normais. Estruturas ósseas íntegras. Diafragmas em posição normal.",
conclusion: "Radiografia de tórax sem alterações patológicas evidentes.",
recommendations: "Correlacionar com quadro clínico. Acompanhamento conforme indicação médica.",
},
},
{
id: "3",
title: "Eletrocardiograma",
doctor: "Dr. Carlos Oliveira",
date: "2024-01-08",
type: "Exame Cardiológico",
status: "pendente",
description: "ECG de repouso para avaliação cardíaca",
content: {
patientInfo: {
name: "Maria Silva Santos",
age: 35,
gender: "Feminino",
id: "123.456.789-00",
},
examDetails: {
requestingDoctor: "Dr. Carlos Oliveira - CRM 54321",
examDate: "08/01/2024",
reportDate: "",
technique: "ECG de repouso",
},
findings: "",
conclusion: "",
recommendations: "",
},
},
{
id: "4",
title: "Ultrassom Abdominal",
doctor: "Dra. Ana Costa",
date: "2024-01-05",
type: "Exame de Imagem",
status: "disponivel",
description: "Ultrassonografia do abdome total",
content: {
patientInfo: {
name: "Maria Silva Santos",
age: 35,
gender: "Feminino",
id: "123.456.789-00",
},
examDetails: {
requestingDoctor: "Dra. Ana Costa - CRM 98765",
examDate: "05/01/2024",
reportDate: "05/01/2024",
technique: "Ultrassom convencional",
},
findings:
"Viscerais bem posicionadas. Rim direito e esquerdo com contornos normais. Vesícula com volume dentro do normal.",
conclusion: "Ultrassom abdominal sem alterações patológicas evidentes.",
recommendations: "Acompanhamento conforme indicação médica.",
},
},
]
setReports(mockReports)
}, [])
const handleViewReport = (reportId: string) => { const handleViewReport = (reportId: string) => {
const report = reports.find((r) => r.id === reportId) const report = reports.find((r) => r.id === reportId)
@ -77,23 +168,100 @@ export default function ReportsPage() {
if (!report) return if (!report) return
try { try {
// Simular loading
toast({ toast({
title: "Preparando download...", title: "Preparando download...",
description: "Gerando PDF do laudo médico", description: "Gerando PDF do laudo médico",
}) })
const htmlContent = report.content_html; // Criar conteúdo HTML do laudo para conversão em PDF
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Laudo Médico - ${report.title}</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; line-height: 1.6; }
.header { text-align: center; border-bottom: 2px solid #333; padding-bottom: 20px; margin-bottom: 30px; }
.section { margin-bottom: 25px; }
.section-title { font-size: 16px; font-weight: bold; color: #333; margin-bottom: 10px; border-bottom: 1px solid #ccc; padding-bottom: 5px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px; }
.info-item { margin-bottom: 8px; }
.label { font-weight: bold; color: #555; }
.content { white-space: pre-line; }
.footer { margin-top: 40px; text-align: center; font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="header">
<h1>LAUDO MÉDICO</h1>
<h2>${report.title}</h2>
<p><strong>Tipo:</strong> ${report.type}</p>
</div>
<div class="section">
<div class="section-title">DADOS DO PACIENTE</div>
<div class="info-grid">
<div class="info-item"><span class="label">Nome:</span> ${report.content.patientInfo.name}</div>
<div class="info-item"><span class="label">Idade:</span> ${report.content.patientInfo.age} anos</div>
<div class="info-item"><span class="label">Sexo:</span> ${report.content.patientInfo.gender}</div>
<div class="info-item"><span class="label">CPF:</span> ${report.content.patientInfo.id}</div>
</div>
</div>
<div class="section">
<div class="section-title">DETALHES DO EXAME</div>
<div class="info-grid">
<div class="info-item"><span class="label">Médico Solicitante:</span> ${report.content.examDetails.requestingDoctor}</div>
<div class="info-item"><span class="label">Data do Exame:</span> ${report.content.examDetails.examDate}</div>
<div class="info-item"><span class="label">Data do Laudo:</span> ${report.content.examDetails.reportDate}</div>
<div class="info-item"><span class="label">Técnica:</span> ${report.content.examDetails.technique}</div>
</div>
</div>
<div class="section">
<div class="section-title">ACHADOS</div>
<div class="content">${report.content.findings}</div>
</div>
<div class="section">
<div class="section-title">CONCLUSÃO</div>
<div class="content">${report.content.conclusion}</div>
</div>
${
report.content.recommendations
? `
<div class="section">
<div class="section-title">RECOMENDAÇÕES</div>
<div class="content">${report.content.recommendations}</div>
</div>
`
: ""
}
<div class="footer">
<p>Documento gerado em ${new Date().toLocaleDateString("pt-BR")} às ${new Date().toLocaleTimeString("pt-BR")}</p>
<p>Este é um documento médico oficial. Mantenha-o em local seguro.</p>
</div>
</body>
</html>
`
// Criar blob com o conteúdo HTML
const blob = new Blob([htmlContent], { type: "text/html" }) const blob = new Blob([htmlContent], { type: "text/html" })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
// Criar link temporário para download
const link = document.createElement("a") const link = document.createElement("a")
link.href = url link.href = url
link.download = `laudo-${report.order_number}.html` link.download = `laudo-${report.title.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase()}-${report.date}.html`
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
// Limpar URL temporária
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
toast({ toast({
@ -115,25 +283,15 @@ export default function ReportsPage() {
setSelectedReport(null) setSelectedReport(null)
} }
const availableReports = reports.filter((report) => report.status.toLowerCase() === "draft") const availableReports = reports.filter((report) => report.status === "disponivel")
const pendingReports = reports.filter((report) => report.status.toLowerCase() !== "draft") const pendingReports = reports.filter((report) => report.status === "pendente")
if (isLoading || isAuthLoading) {
return (
<Sidebar>
<div className="flex justify-center items-center h-full">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
</Sidebar>
)
}
return ( return (
<Sidebar> <PatientLayout>
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Meus Laudos</h1> <h1 className="text-3xl font-bold text-gray-900">Meus Laudos</h1>
<p className="text-muted-foreground mt-2">Visualize e baixe seus laudos médicos e resultados de exames</p> <p className="text-gray-600 mt-2">Visualize e baixe seus laudos médicos e resultados de exames</p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
@ -168,32 +326,32 @@ export default function ReportsPage() {
{availableReports.length > 0 && ( {availableReports.length > 0 && (
<div> <div>
<h2 className="text-xl font-semibold text-foreground mb-4">Laudos Disponíveis</h2> <h2 className="text-xl font-semibold text-gray-900 mb-4">Laudos Disponíveis</h2>
<div className="grid gap-4"> <div className="grid gap-4">
{availableReports.map((report) => ( {availableReports.map((report) => (
<Card key={report.id} className="hover:shadow-md transition-shadow"> <Card key={report.id} className="hover:shadow-md transition-shadow">
<CardHeader> <CardHeader>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="space-y-1"> <div className="space-y-1">
<CardTitle className="text-lg">{report.exam}</CardTitle> <CardTitle className="text-lg">{report.title}</CardTitle>
<CardDescription className="flex items-center gap-4"> <CardDescription className="flex items-center gap-4">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<User className="h-4 w-4" /> <User className="h-4 w-4" />
{report.requested_by} {report.doctor}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
{new Date(report.created_at).toLocaleDateString("pt-BR")} {new Date(report.date).toLocaleDateString("pt-BR")}
</span> </span>
</CardDescription> </CardDescription>
</div> </div>
<Badge variant="secondary" className="bg-green-100 text-green-800"> <Badge variant="secondary" className="bg-green-100 text-green-800">
Finalizado {report.type}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-muted-foreground mb-4">{report.diagnosis}</p> <p className="text-gray-600 mb-4">{report.description}</p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
@ -211,7 +369,7 @@ export default function ReportsPage() {
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
Baixar Baixar PDF
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@ -223,33 +381,33 @@ export default function ReportsPage() {
{pendingReports.length > 0 && ( {pendingReports.length > 0 && (
<div> <div>
<h2 className="text-xl font-semibold text-foreground mb-4">Laudos Pendentes</h2> <h2 className="text-xl font-semibold text-gray-900 mb-4">Laudos Pendentes</h2>
<div className="grid gap-4"> <div className="grid gap-4">
{pendingReports.map((report) => ( {pendingReports.map((report) => (
<Card key={report.id} className="opacity-75"> <Card key={report.id} className="opacity-75">
<CardHeader> <CardHeader>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="space-y-1"> <div className="space-y-1">
<CardTitle className="text-lg">{report.exam}</CardTitle> <CardTitle className="text-lg">{report.title}</CardTitle>
<CardDescription className="flex items-center gap-4"> <CardDescription className="flex items-center gap-4">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<User className="h-4 w-4" /> <User className="h-4 w-4" />
{report.requested_by} {report.doctor}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
{new Date(report.created_at).toLocaleDateString("pt-BR")} {new Date(report.date).toLocaleDateString("pt-BR")}
</span> </span>
</CardDescription> </CardDescription>
</div> </div>
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800"> <Badge variant="secondary" className="bg-yellow-100 text-yellow-800">
{report.status} Pendente
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-muted-foreground mb-4">{report.diagnosis}</p> <p className="text-gray-600 mb-4">{report.description}</p>
<p className="text-sm text-yellow-600 dark:text-yellow-500 font-medium"> <p className="text-sm text-yellow-600 font-medium">
Laudo em processamento. Você será notificado quando estiver disponível. Laudo em processamento. Você será notificado quando estiver disponível.
</p> </p>
</CardContent> </CardContent>
@ -259,12 +417,12 @@ export default function ReportsPage() {
</div> </div>
)} )}
{reports.length === 0 && !isLoading && ( {reports.length === 0 && (
<Card className="text-center py-12"> <Card className="text-center py-12">
<CardContent> <CardContent>
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> <FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">Nenhum laudo encontrado</h3> <h3 className="text-lg font-medium text-gray-900 mb-2">Nenhum laudo encontrado</h3>
<p className="text-muted-foreground">Seus laudos médicos aparecerão aqui após a realização de exames.</p> <p className="text-gray-600">Seus laudos médicos aparecerão aqui após a realização de exames.</p>
</CardContent> </CardContent>
</Card> </Card>
)} )}
@ -274,9 +432,9 @@ export default function ReportsPage() {
<DialogHeader> <DialogHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<DialogTitle className="text-xl font-bold">{selectedReport?.exam}</DialogTitle> <DialogTitle className="text-xl font-bold">{selectedReport?.title}</DialogTitle>
<DialogDescription className="mt-1"> <DialogDescription className="mt-1">
{selectedReport?.order_number} {selectedReport?.type} - {selectedReport?.doctor}
</DialogDescription> </DialogDescription>
</div> </div>
<Button variant="ghost" size="sm" onClick={handleCloseModal} className="h-8 w-8 p-0"> <Button variant="ghost" size="sm" onClick={handleCloseModal} className="h-8 w-8 p-0">
@ -286,11 +444,98 @@ export default function ReportsPage() {
</DialogHeader> </DialogHeader>
{selectedReport && ( {selectedReport && (
<div className="space-y-6 mt-4" dangerouslySetInnerHTML={{ __html: selectedReport.content_html }} /> <div className="space-y-6 mt-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Dados do Paciente</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-gray-500">Nome</p>
<p className="text-sm">{selectedReport.content.patientInfo.name}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500">Idade</p>
<p className="text-sm">{selectedReport.content.patientInfo.age} anos</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500">Sexo</p>
<p className="text-sm">{selectedReport.content.patientInfo.gender}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500">CPF</p>
<p className="text-sm">{selectedReport.content.patientInfo.id}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Detalhes do Exame</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-gray-500">Médico Solicitante</p>
<p className="text-sm">{selectedReport.content.examDetails.requestingDoctor}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500">Data do Exame</p>
<p className="text-sm">{selectedReport.content.examDetails.examDate}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500">Data do Laudo</p>
<p className="text-sm">{selectedReport.content.examDetails.reportDate}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500">Técnica</p>
<p className="text-sm">{selectedReport.content.examDetails.technique}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Achados</CardTitle>
</CardHeader>
<CardContent>
<div className="whitespace-pre-line text-sm leading-relaxed">{selectedReport.content.findings}</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Conclusão</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed">{selectedReport.content.conclusion}</p>
</CardContent>
</Card>
{selectedReport.content.recommendations && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Recomendações</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed">{selectedReport.content.recommendations}</p>
</CardContent>
</Card>
)}
<div className="flex gap-3 pt-4 border-t">
<Button onClick={() => handleDownloadReport(selectedReport.id)} className="flex items-center gap-2">
<Download className="h-4 w-4" />
Baixar PDF
</Button>
<Button variant="outline" onClick={handleCloseModal}>
Fechar
</Button>
</div>
</div>
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
</Sidebar> </PatientLayout>
) )
} }

View File

@ -1,12 +1,250 @@
// app/patient/appointments/page.tsx "use client"
import Sidebar from "@/components/Sidebar";
import ScheduleForm from "@/components/schedule/schedule-form";
import type React from "react"
import { useState, useEffect, useCallback } from "react"
// Importações de componentes omitidas para brevidade, mas estão no código original
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { Calendar, Clock, User } from "lucide-react"
import { doctorsService } from "services/doctorsApi.mjs";
interface Doctor {
id: string;
full_name: string;
specialty: string;
phone_mobile: string;
export default function PatientAppointments() { }
return (
<Sidebar> // Chave do LocalStorage, a mesma usada em secretarypage.tsx
<ScheduleForm /> const APPOINTMENTS_STORAGE_KEY = "clinic-appointments";
</Sidebar>
); export default function ScheduleAppointment() {
const [selectedDoctor, setSelectedDoctor] = useState("")
const [selectedDate, setSelectedDate] = useState("")
const [selectedTime, setSelectedTime] = useState("")
const [notes, setNotes] = useState("")
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchDoctors = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data: Doctor[] = await doctorsService.list();
setDoctors(data || []);
} catch (e: any) {
console.error("Erro ao carregar lista de médicos:", e);
setError("Não foi possível carregar a lista de médicos. Verifique a conexão com a API.");
setDoctors([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchDoctors();
}, [fetchDoctors]);
const availableTimes = [
"08:00",
"08:30",
"09:00",
"09:30",
"10:00",
"10:30",
"14:00",
"14:30",
"15:00",
"15:30",
"16:00",
"16:30",
]
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const doctorDetails = doctors.find((d) => d.id === selectedDoctor)
// --- SIMULAÇÃO DO PACIENTE LOGADO ---
// Você só tem um usuário para cada role. Vamos simular um paciente:
const patientDetails = {
id: "P001",
full_name: "Paciente Exemplo Único", // Este nome aparecerá na agenda do médico
location: "Clínica Geral",
phone: "(11) 98765-4321"
};
if (!patientDetails || !doctorDetails) {
alert("Erro: Selecione o médico ou dados do paciente indisponíveis.");
return;
}
const newAppointment = {
id: new Date().getTime(), // ID único simples
patientName: patientDetails.full_name,
doctor: doctorDetails.full_name, // Nome completo do médico (necessário para a listagem)
specialty: doctorDetails.specialty,
date: selectedDate,
time: selectedTime,
status: "agendada",
phone: patientDetails.phone,
};
// 1. Carrega agendamentos existentes
const storedAppointmentsRaw = localStorage.getItem(APPOINTMENTS_STORAGE_KEY);
const currentAppointments = storedAppointmentsRaw ? JSON.parse(storedAppointmentsRaw) : [];
// 2. Adiciona o novo agendamento
const updatedAppointments = [...currentAppointments, newAppointment];
// 3. Salva a lista atualizada no LocalStorage
localStorage.setItem(APPOINTMENTS_STORAGE_KEY, JSON.stringify(updatedAppointments));
alert(`Consulta com ${doctorDetails.full_name} agendada com sucesso!`);
// Limpar o formulário após o sucesso (opcional)
setSelectedDoctor("");
setSelectedDate("");
setSelectedTime("");
setNotes("");
}
return (
<PatientLayout>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Agendar Consulta</h1>
<p className="text-gray-600">Escolha o médico, data e horário para sua consulta</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 sua consulta</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<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>
{doctors.map((doctor) => (
<SelectItem key={doctor.id} value={doctor.id}>
{doctor.full_name} - {doctor.specialty}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<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]}
/>
</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="space-y-2">
<Label htmlFor="notes">Observações (opcional)</Label>
<Textarea
id="notes"
placeholder="Descreva brevemente o motivo da consulta ou observações importantes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
/>
</div>
<Button type="submit" className="w-full" disabled={!selectedDoctor || !selectedDate || !selectedTime}>
Agendar Consulta
</Button>
</form>
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Calendar className="mr-2 h-5 w-5" />
Resumo
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{selectedDoctor && (
<div className="flex items-center space-x-2">
<User className="h-4 w-4 text-gray-500" />
<span className="text-sm">{doctors.find((d) => d.id === selectedDoctor)?.full_name}</span>
</div>
)}
{selectedDate && (
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-gray-500" />
<span className="text-sm">{new Date(selectedDate).toLocaleDateString("pt-BR")}</span>
</div>
)}
{selectedTime && (
<div className="flex items-center space-x-2">
<Clock className="h-4 w-4 text-gray-500" />
<span className="text-sm">{selectedTime}</span>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Informações Importantes</CardTitle>
</CardHeader>
<CardContent className="text-sm text-gray-600 space-y-2">
<p> Chegue com 15 minutos de antecedência</p>
<p> Traga documento com foto</p>
<p> Traga carteirinha do convênio</p>
<p> Traga exames anteriores, se houver</p>
</CardContent>
</Card>
</div>
</div>
</div>
</PatientLayout>
)
} }

View File

@ -1,18 +0,0 @@
"use client";
import { AccessibilityProvider } from "./context/AccessibilityContext";
import { AppointmentsProvider } from "./context/AppointmentsContext";
import { AccessibilityModal } from "@/components/accessibility-modal";
import { ThemeInitializer } from "@/components/theme-initializer";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<>
<ThemeInitializer />
<AccessibilityProvider>
<AppointmentsProvider>{children}</AppointmentsProvider>
<AccessibilityModal />
</AccessibilityProvider>
</>
);
}

View File

@ -1,468 +1,283 @@
"use client"; "use client";
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect } from "react";
import { import SecretaryLayout from "@/components/secretary-layout";
Card, import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Dialog } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; // Importei o Input import { Input } from "@/components/ui/input";
import { Calendar as CalendarShadcn } from "@/components/ui/calendar"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Textarea } from "@/components/ui/textarea";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Calendar as CalendarIcon, import { Calendar, Clock, MapPin, Phone, CalendarDays, X, User } from "lucide-react";
Clock,
MapPin,
Phone,
User,
Trash2,
Pencil,
List,
RefreshCw,
Loader2,
Search, // Importei o ícone de busca
} from "lucide-react";
import { format, parseISO, isValid, isToday, isTomorrow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { toast } from "sonner"; import { toast } from "sonner";
import Link from "next/link"; import Link from "next/link";
import { appointmentsService } from "@/services/appointmentsApi.mjs";
import { patientsService } from "@/services/patientsApi.mjs"; const APPOINTMENTS_STORAGE_KEY = "clinic-appointments";
import { doctorsService } from "@/services/doctorsApi.mjs";
import Sidebar from "@/components/Sidebar"; const initialAppointments = [
{
id: 1,
patientName: "Carlos Pereira",
doctor: "Dr. João Silva",
specialty: "Cardiologia",
date: "2024-01-15",
time: "14:30",
status: "agendada",
location: "Consultório A - 2º andar",
phone: "(11) 3333-4444",
},
{
id: 2,
patientName: "Ana Beatriz Costa",
doctor: "Dra. Maria Santos",
specialty: "Dermatologia",
date: "2024-01-22",
time: "10:00",
status: "agendada",
location: "Consultório B - 1º andar",
phone: "(11) 3333-5555",
},
{
id: 3,
patientName: "Roberto Almeida",
doctor: "Dr. Pedro Costa",
specialty: "Ortopedia",
date: "2024-01-08",
time: "16:00",
status: "realizada",
location: "Consultório C - 3º andar",
phone: "(11) 3333-6666",
},
{
id: 4,
patientName: "Fernanda Lima",
doctor: "Dra. Ana Lima",
specialty: "Ginecologia",
date: "2024-01-05",
time: "09:30",
status: "realizada",
location: "Consultório D - 2º andar",
phone: "(11) 3333-7777",
},
];
export default function SecretaryAppointments() { export default function SecretaryAppointments() {
const [appointments, setAppointments] = useState<any[]>([]); const [appointments, setAppointments] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [rescheduleModal, setRescheduleModal] = useState(false);
const [cancelModal, setCancelModal] = useState(false);
const [selectedAppointment, setSelectedAppointment] = useState<any>(null); const [selectedAppointment, setSelectedAppointment] = useState<any>(null);
const [rescheduleData, setRescheduleData] = useState({ date: "", time: "", reason: "" });
// Estados dos Modais const [cancelReason, setCancelReason] = useState("");
const [deleteModal, setDeleteModal] = useState(false);
const [editModal, setEditModal] = useState(false);
// Estado da Busca
const [searchTerm, setSearchTerm] = useState("");
// Estado para o formulário de edição
const [editFormData, setEditFormData] = useState({
date: "",
time: "",
status: "",
});
// Estado de data selecionada
const [selectedDate, setSelectedDate] = useState<Date | undefined>(new Date());
const fetchData = async () => {
setIsLoading(true);
try {
const queryParams = "order=scheduled_at.asc";
const [appointmentList, patientList, doctorList] = await Promise.all([
appointmentsService.search_appointment(queryParams),
patientsService.list(),
doctorsService.list(),
]);
const patientMap = new Map(patientList.map((p: any) => [p.id, p]));
const doctorMap = new Map(doctorList.map((d: any) => [d.id, d]));
const enrichedAppointments = appointmentList.map((apt: any) => ({
...apt,
patient: patientMap.get(apt.patient_id) || {
full_name: "Paciente não encontrado",
},
doctor: doctorMap.get(apt.doctor_id) || {
full_name: "Médico não encontrado",
specialty: "N/A",
},
}));
setAppointments(enrichedAppointments);
} catch (error) {
console.error("Falha ao buscar agendamentos:", error);
toast.error("Não foi possível carregar a lista de agendamentos.");
} finally {
setIsLoading(false);
}
};
useEffect(() => { useEffect(() => {
fetchData(); const storedAppointments = localStorage.getItem(APPOINTMENTS_STORAGE_KEY);
if (storedAppointments) {
setAppointments(JSON.parse(storedAppointments));
} else {
setAppointments(initialAppointments);
localStorage.setItem(APPOINTMENTS_STORAGE_KEY, JSON.stringify(initialAppointments));
}
}, []); }, []);
// --- Filtragem e Agrupamento --- const updateAppointments = (updatedAppointments: any[]) => {
const groupedAppointments = useMemo(() => { setAppointments(updatedAppointments);
let filteredList = appointments; localStorage.setItem(APPOINTMENTS_STORAGE_KEY, JSON.stringify(updatedAppointments));
// 1. Filtro de Texto (Nome do Paciente ou Médico)
if (searchTerm) {
const lowerTerm = searchTerm.toLowerCase();
filteredList = filteredList.filter(
(apt) =>
apt.patient.full_name.toLowerCase().includes(lowerTerm) ||
apt.doctor.full_name.toLowerCase().includes(lowerTerm)
);
}
// 2. Filtro de Data (se selecionada)
if (selectedDate) {
filteredList = filteredList.filter((apt) => {
if (!apt.scheduled_at) return false;
const iso = apt.scheduled_at.toString();
return iso.startsWith(format(selectedDate, "yyyy-MM-dd"));
});
}
// 3. Agrupamento por dia
return filteredList.reduce((acc: Record<string, any[]>, apt: any) => {
if (!apt.scheduled_at) return acc;
const dateObj = new Date(apt.scheduled_at);
if (!isValid(dateObj)) return acc;
const key = format(dateObj, "yyyy-MM-dd");
if (!acc[key]) acc[key] = [];
acc[key].push(apt);
return acc;
}, {});
}, [appointments, selectedDate, searchTerm]);
// Dias que têm consulta (para destacar no calendário)
const bookedDays = useMemo(
() =>
appointments
.map((apt) =>
apt.scheduled_at ? new Date(apt.scheduled_at) : null
)
.filter((d): d is Date => d !== null && isValid(d)),
[appointments]
);
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 });
}; };
// --- LÓGICA DE EDIÇÃO E DELEÇÃO --- const handleReschedule = (appointment: any) => {
const handleEdit = (appointment: any) => {
setSelectedAppointment(appointment); setSelectedAppointment(appointment);
const appointmentDate = new Date(appointment.scheduled_at); setRescheduleData({ date: "", time: "", reason: "" });
setRescheduleModal(true);
setEditFormData({
date: appointmentDate.toISOString().split("T")[0],
time: appointmentDate.toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
timeZone: "UTC",
}),
status: appointment.status,
});
setEditModal(true);
}; };
const confirmEdit = async () => { const handleCancel = (appointment: any) => {
if ( setSelectedAppointment(appointment);
!selectedAppointment || setCancelReason("");
!editFormData.date || setCancelModal(true);
!editFormData.time || };
!editFormData.status
) { const confirmReschedule = () => {
toast.error("Todos os campos são obrigatórios para a edição."); if (!rescheduleData.date || !rescheduleData.time) {
toast.error("Por favor, selecione uma nova data e horário");
return; return;
} }
const updated = appointments.map((apt) => (apt.id === selectedAppointment.id ? { ...apt, date: rescheduleData.date, time: rescheduleData.time } : apt));
try { updateAppointments(updated);
const newScheduledAt = new Date( setRescheduleModal(false);
`${editFormData.date}T${editFormData.time}:00Z` toast.success("Consulta reagendada com sucesso!");
).toISOString();
const updatePayload = {
scheduled_at: newScheduledAt,
status: editFormData.status,
}; };
await appointmentsService.update(selectedAppointment.id, updatePayload); const confirmCancel = () => {
await fetchData(); if (!cancelReason.trim() || cancelReason.trim().length < 10) {
setEditModal(false); toast.error("O motivo do cancelamento é obrigatório e deve ter no mínimo 10 caracteres.");
toast.success("Consulta atualizada com sucesso!"); return;
} catch (error) { }
console.error("Erro ao atualizar consulta:", error); const updated = appointments.map((apt) => (apt.id === selectedAppointment.id ? { ...apt, status: "cancelada" } : apt));
toast.error("Não foi possível atualizar a consulta."); updateAppointments(updated);
setCancelModal(false);
toast.success("Consulta cancelada com sucesso!");
};
const getStatusBadge = (status: string) => {
switch (status) {
case "agendada":
return <Badge className="bg-blue-100 text-blue-800">Agendada</Badge>;
case "realizada":
return <Badge className="bg-green-100 text-green-800">Realizada</Badge>;
case "cancelada":
return <Badge className="bg-red-100 text-red-800">Cancelada</Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
} }
}; };
const handleDelete = (appointment: any) => { const timeSlots = ["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"];
setSelectedAppointment(appointment);
setDeleteModal(true);
};
const confirmDelete = async () => {
if (!selectedAppointment) return;
try {
await appointmentsService.delete(selectedAppointment.id);
setAppointments((prev) =>
prev.filter((apt) => apt.id !== selectedAppointment.id)
);
setDeleteModal(false);
toast.success("Consulta deletada com sucesso!");
} catch (error) {
console.error("Erro ao deletar consulta:", error);
toast.error("Não foi possível deletar a consulta.");
}
};
return ( return (
<Sidebar> <SecretaryLayout>
<div className="space-y-6"> <div className="space-y-6">
{/* Cabeçalho principal */} <div className="flex justify-between items-center">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div> <div>
<h1 className="text-3xl font-bold text-foreground"> <h1 className="text-3xl font-bold text-gray-900">Consultas Agendadas</h1>
Agenda Médica <p className="text-gray-600">Gerencie as consultas dos pacientes</p>
</h1>
<p className="text-muted-foreground">
Consultas para os pacientes
</p>
</div> </div>
<Link href="/secretary/schedule"> <Link href="/secretary/schedule">
<Button className="bg-primary hover:bg-primary/90 text-primary-foreground"> <Button>
<CalendarIcon className="mr-2 h-4 w-4" /> <Calendar className="mr-2 h-4 w-4" />
Agendar Nova Consulta Agendar Nova Consulta
</Button> </Button>
</Link> </Link>
</div> </div>
{/* Barra de Filtros e Ações */} <div className="grid gap-6">
<div className="flex flex-col md:flex-row justify-between items-center gap-4"> {appointments.length > 0 ? (
<h2 className="text-xl font-semibold capitalize whitespace-nowrap"> appointments.map((appointment) => (
{selectedDate <Card key={appointment.id}>
? `Agenda de ${format(selectedDate, "dd/MM/yyyy")}`
: "Todas as Consultas"}
</h2>
<div className="flex flex-col md:flex-row items-center gap-3 w-full md:w-auto">
{/* BARRA DE PESQUISA ADICIONADA AQUI */}
<div className="relative w-full md:w-72">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Buscar paciente ou médico..."
className="pl-9 w-full"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
onClick={() => {
setSelectedDate(undefined);
setSearchTerm("");
}}
variant="ghost"
size="sm"
className="flex-1 md:flex-none"
>
<List className="mr-2 h-4 w-4" />
Mostrar Todas
</Button>
<Button
onClick={() => fetchData()}
disabled={isLoading}
variant="outline"
size="sm"
className="flex-1 md:flex-none"
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
Atualizar
</Button>
</div>
</div>
</div>
{/* Grid com calendário + lista */}
<div className="grid lg:grid-cols-3 gap-6">
{/* Coluna esquerda: calendário */}
<div className="lg:col-span-1">
<Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center"> <div className="flex justify-between items-start">
<CalendarIcon className="mr-2 h-5 w-5" /> <div>
Filtrar por Data <CardTitle className="text-lg">{appointment.doctor}</CardTitle>
</CardTitle> <CardDescription>{appointment.specialty}</CardDescription>
<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>
{getStatusBadge(appointment.status)}
{/* Coluna direita: lista de consultas */}
<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> </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> </CardHeader>
<CardContent> <CardContent>
<p className="text-muted-foreground"> <div className="grid md:grid-cols-2 gap-4">
{searchTerm <div className="space-y-3">
? "Nenhum resultado para a busca." <div className="flex items-center text-sm text-gray-800 font-medium">
: selectedDate <User className="mr-2 h-4 w-4 text-gray-600" />
? "Não há agendamentos para esta data." {appointment.patientName}
: "Não há 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: any) => {
const scheduledAtDate = new Date(
appointment.scheduled_at
);
return (
<Card
key={appointment.id}
className="shadow-sm hover:shadow-md transition-shadow"
>
<CardContent className="p-4 grid grid-cols-1 md:grid-cols-3 items-center gap-4">
{/* Coluna 1: Paciente + 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.patient.full_name}
</div> </div>
<div className="flex items-center text-sm text-muted-foreground"> <div className="flex items-center text-sm text-gray-600">
<Calendar className="mr-2 h-4 w-4" />
{new Date(appointment.date).toLocaleDateString("pt-BR", { timeZone: "UTC" })}
</div>
<div className="flex items-center text-sm text-gray-600">
<Clock className="mr-2 h-4 w-4" /> <Clock className="mr-2 h-4 w-4" />
{isValid(scheduledAtDate) {appointment.time}
? format(scheduledAtDate, "HH:mm")
: "--:--"}
</div> </div>
</div> </div>
<div className="space-y-3">
{/* Coluna 2: Médico / local / telefone */} <div className="flex items-center text-sm text-gray-600">
<div className="col-span-1 flex flex-col gap-2">
<div className="flex items-center text-sm text-muted-foreground">
<User className="mr-2 h-4 w-4" />
{appointment.doctor.full_name}
</div>
<div className="flex items-center text-sm text-muted-foreground">
<MapPin className="mr-2 h-4 w-4" /> <MapPin className="mr-2 h-4 w-4" />
{appointment.doctor.location || {appointment.location}
"Local a definir"}
</div> </div>
<div className="flex items-center text-sm text-muted-foreground"> <div className="flex items-center text-sm text-gray-600">
<Phone className="mr-2 h-4 w-4" /> <Phone className="mr-2 h-4 w-4" />
{appointment.doctor.phone || "N/A"} {appointment.phone}
</div>
</div> </div>
<div>{getStatusBadge(appointment.status)}</div>
</div> </div>
{/* Coluna 3: Ações */} {appointment.status === "agendada" && (
<div className="col-span-1 flex justify-start md:justify-end"> <div className="flex gap-2 mt-4 pt-4 border-t">
<div className="flex gap-2"> <Button variant="outline" size="sm" onClick={() => handleReschedule(appointment)}>
<Button <CalendarDays className="mr-2 h-4 w-4" />
variant="outline" Reagendar
size="sm"
onClick={() => handleEdit(appointment)}
>
<Pencil className="mr-2 h-4 w-4" />
Editar
</Button> </Button>
<Button <Button variant="outline" size="sm" className="text-red-600 hover:text-red-700 hover:bg-red-50 bg-transparent" onClick={() => handleCancel(appointment)}>
variant="destructive" <X className="mr-2 h-4 w-4" />
size="sm"
onClick={() => handleDelete(appointment)}
>
<Trash2 className="mr-2 h-4 w-4" />
Cancelar Cancelar
</Button> </Button>
</div> </div>
</div> )}
</CardContent> </CardContent>
</Card> </Card>
); ))
})} ) : (
</div> <p>Nenhuma consulta encontrada.</p>
<Separator className="my-6" />
</div>
)
)
)} )}
</div> </div>
</div> </div>
{/* MODAL DE EDIÇÃO */} <Dialog open={rescheduleModal} onOpenChange={setRescheduleModal}>
<Dialog open={editModal} onOpenChange={setEditModal}> <DialogContent className="sm:max-w-[425px]">
{/* Modal de edição permanece o mesmo, adicione o DialogContent se precisar */} <DialogHeader>
{/* Aqui estou assumindo que você tem o conteúdo do Dialog no seu código original ou em outro lugar, pois ele não estava completo no snippet anterior */} <DialogTitle>Reagendar Consulta</DialogTitle>
</Dialog> <DialogDescription>Reagendar consulta com {selectedAppointment?.doctor} para {selectedAppointment?.patientName}</DialogDescription>
</DialogHeader>
{/* Modal de Deleção */} <div className="grid gap-4 py-4">
<Dialog open={deleteModal} onOpenChange={setDeleteModal}> <div className="grid gap-2">
{/* Modal de deleção permanece o mesmo */} <Label htmlFor="date">Nova Data</Label>
</Dialog> <Input id="date" type="date" value={rescheduleData.date} onChange={(e) => setRescheduleData((prev) => ({ ...prev, date: e.target.value }))} min={new Date().toISOString().split("T")[0]} />
</div> </div>
</Sidebar> <div className="grid gap-2">
); <Label htmlFor="time">Novo Horário</Label>
} <Select value={rescheduleData.time} onValueChange={(value) => setRescheduleData((prev) => ({ ...prev, time: value }))}>
<SelectTrigger>
<SelectValue placeholder="Selecione um horário" />
</SelectTrigger>
<SelectContent>
{timeSlots.map((time) => (
<SelectItem key={time} value={time}>
{time}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="reason">Motivo do Reagendamento (opcional)</Label>
<Textarea id="reason" placeholder="Informe o motivo do reagendamento..." value={rescheduleData.reason} onChange={(e) => setRescheduleData((prev) => ({ ...prev, reason: e.target.value }))} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRescheduleModal(false)}>
Cancelar
</Button>
<Button onClick={confirmReschedule}>Confirmar Reagendamento</Button>
</DialogFooter>
</DialogContent>
</Dialog>
const getStatusBadge = (status: string) => { <Dialog open={cancelModal} onOpenChange={setCancelModal}>
switch (status) { <DialogContent className="sm:max-w-[425px]">
case "requested": <DialogHeader>
return ( <DialogTitle>Cancelar Consulta</DialogTitle>
<Badge className="bg-yellow-400/10 text-yellow-400">Solicitada</Badge> <DialogDescription>Tem certeza que deseja cancelar a consulta de {selectedAppointment?.patientName} com {selectedAppointment?.doctor}?</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="cancel-reason" className="text-sm font-medium">
Motivo do Cancelamento <span className="text-red-500">*</span>
</Label>
<Textarea id="cancel-reason" placeholder="Por favor, informe o motivo do cancelamento... (obrigatório)" value={cancelReason} onChange={(e) => setCancelReason(e.target.value)} required className={`min-h-[100px] ${!cancelReason.trim() && cancelModal ? "border-red-300 focus:border-red-500" : ""}`} />
<p className="text-xs text-gray-500">Mínimo de 10 caracteres. Este campo é obrigatório.</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelModal(false)}>
Voltar
</Button>
<Button variant="destructive" onClick={confirmCancel} disabled={!cancelReason.trim() || cancelReason.trim().length < 10}>
Confirmar Cancelamento
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SecretaryLayout>
); );
case "confirmed":
return <Badge className="bg-primary/10 text-primary">Confirmada</Badge>;
case "checked_in":
return (
<Badge className="bg-indigo-400/10 text-indigo-400">Check-in</Badge>
);
case "completed":
return <Badge className="bg-green-400/10 text-green-400">Realizada</Badge>;
case "cancelled":
return (
<Badge className="bg-destructive/10 text-destructive">Cancelada</Badge>
);
case "no_show":
return (
<Badge className="bg-muted text-foreground">Não Compareceu</Badge>
);
default:
return <Badge variant="secondary">{status}</Badge>;
} }
};

View File

@ -1,212 +1,41 @@
"use client"; import SecretaryLayout from "@/components/secretary-layout"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { import { Button } from "@/components/ui/button"
Card, import { Calendar, Clock, User, Plus } from "lucide-react"
CardContent, import Link from "next/link"
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 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() { export default function SecretaryDashboard() {
// Estados
const [patients, setPatients] = useState<any[]>([]);
const [loadingPatients, setLoadingPatients] = useState(true);
const [firstConfirmed, setFirstConfirmed] = useState<any>(null);
const [nextAgendada, setNextAgendada] = useState<any>(null);
const [loadingAppointments, setLoadingAppointments] = useState(true);
// 🔹 Buscar pacientes
useEffect(() => {
async function fetchPatients() {
try {
const data = await patientsService.list();
if (Array.isArray(data)) {
setPatients(data.slice(0, 3));
}
} catch (error) {
console.error("Erro ao carregar pacientes:", error);
} finally {
setLoadingPatients(false);
}
}
fetchPatients();
}, []);
// 🔹 Buscar consultas (confirmadas + 1ª do mês)
useEffect(() => {
async function fetchAppointments() {
try {
const hoje = new Date();
const inicioMes = new Date(hoje.getFullYear(), hoje.getMonth(), 1);
const fimMes = new Date(hoje.getFullYear(), hoje.getMonth() + 1, 0);
// Mesmo parâmetro de ordenação da página /secretary/appointments
const queryParams = "order=scheduled_at.desc";
const data = await appointmentsService.search_appointment(queryParams);
if (!Array.isArray(data) || data.length === 0) {
setFirstConfirmed(null);
setNextAgendada(null);
return;
}
// 🩵 1⃣ Consultas confirmadas (para o card “Próxima Consulta Confirmada”)
const confirmadas = data.filter((apt: any) => {
const dataConsulta = new Date(apt.scheduled_at || apt.date);
return apt.status === "confirmed" && dataConsulta >= hoje;
});
confirmadas.sort(
(a: any, b: any) =>
new Date(a.scheduled_at || a.date).getTime() -
new Date(b.scheduled_at || b.date).getTime()
);
setFirstConfirmed(confirmadas[0] || null);
// 💙 2⃣ Consultas deste mês — pegar sempre a 1ª (mais próxima)
const consultasMes = data.filter((apt: any) => {
const dataConsulta = new Date(apt.scheduled_at);
return dataConsulta >= inicioMes && dataConsulta <= fimMes;
});
if (consultasMes.length > 0) {
consultasMes.sort(
(a: any, b: any) =>
new Date(a.scheduled_at).getTime() -
new Date(b.scheduled_at).getTime()
);
setNextAgendada(consultasMes[0]);
} else {
setNextAgendada(null);
}
} catch (error) {
console.error("Erro ao carregar consultas:", error);
} finally {
setLoadingAppointments(false);
}
}
fetchAppointments();
}, []);
return ( return (
<Sidebar> <SecretaryLayout>
<div className="space-y-6"> <div className="space-y-6">
{/* Cabeçalho */}
<div> <div>
<h1 className="text-3xl font-bold">Dashboard</h1> <h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-muted-foreground"> <p className="text-gray-600">Bem-vindo ao seu portal de consultas médicas</p>
Bem-vindo ao seu portal de consultas médicas
</p>
</div> </div>
{/* Cards principais */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Próxima Consulta Confirmada */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium">Próxima Consulta</CardTitle>
Próxima Consulta Confirmada
</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" /> <Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loadingAppointments ? ( <div className="text-2xl font-bold">15 Jan</div>
<div className="text-muted-foreground text-sm"> <p className="text-xs text-muted-foreground">Dr. Silva - 14:30</p>
Carregando próxima consulta...
</div>
) : firstConfirmed ? (
<>
<div className="text-2xl font-bold">
{new Date(
firstConfirmed.scheduled_at || firstConfirmed.date
).toLocaleDateString("pt-BR")}
</div>
<p className="text-xs text-muted-foreground">
{firstConfirmed.doctor_name
? `Dr(a). ${firstConfirmed.doctor_name}`
: "Médico não informado"}{" "}
-{" "}
{new Date(firstConfirmed.scheduled_at).toLocaleTimeString(
"pt-BR",
{
hour: "2-digit",
minute: "2-digit",
}
)}
</p>
</>
) : (
<div className="text-sm text-muted-foreground">
Nenhuma consulta confirmada encontrada
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Consultas Este Mês */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium">Consultas Este Mês</CardTitle>
Consultas Este Mês
</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" /> <Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loadingAppointments ? ( <div className="text-2xl font-bold">3</div>
<div className="text-muted-foreground text-sm"> <p className="text-xs text-muted-foreground">2 realizadas, 1 agendada</p>
Carregando consultas...
</div>
) : nextAgendada ? (
<>
<div className="text-lg font-bold">
{new Date(nextAgendada.scheduled_at).toLocaleDateString(
"pt-BR",
{
day: "2-digit",
month: "2-digit",
year: "numeric",
}
)}{" "}
às{" "}
{new Date(nextAgendada.scheduled_at).toLocaleTimeString(
"pt-BR",
{
hour: "2-digit",
minute: "2-digit",
}
)}
</div>
<p className="text-xs text-muted-foreground">
{nextAgendada.doctor_name
? `Dr(a). ${nextAgendada.doctor_name}`
: "Médico não informado"}
</p>
<p className="text-xs text-muted-foreground">
{nextAgendada.patient_name
? `Paciente: ${nextAgendada.patient_name}`
: ""}
</p>
</>
) : (
<div className="text-sm text-muted-foreground">
Nenhuma consulta agendada neste mês
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Perfil */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Perfil</CardTitle> <CardTitle className="text-sm font-medium">Perfil</CardTitle>
@ -219,87 +48,66 @@ export default function SecretaryDashboard() {
</Card> </Card>
</div> </div>
{/* Cards Secundários */}
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
{/* Ações rápidas */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Ações Rápidas</CardTitle> <CardTitle>Ações Rápidas</CardTitle>
<CardDescription> <CardDescription>Acesse rapidamente as principais funcionalidades</CardDescription>
Acesse rapidamente as principais funcionalidades
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Link href="/secretary/schedule"> <Link href="/secretary/schedule">
<Button className="w-full justify-start bg-primary text-primary-foreground hover:bg-primary/90"> <Button className="w-full justify-start">
<User className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Agendar Nova Consulta Agendar Nova Consulta
</Button> </Button>
</Link> </Link>
<Link href="/secretary/appointments"> <Link href="/secretary/appointments">
<Button <Button variant="outline" className="w-full justify-start bg-transparent">
variant="outline"
className="w-full justify-start bg-transparent"
>
<Calendar className="mr-2 h-4 w-4" /> <Calendar className="mr-2 h-4 w-4" />
Ver Consultas Ver Consultas
</Button> </Button>
</Link> </Link>
<Link href="/secretary/pacientes"> <Link href="##">
<Button <Button variant="outline" className="w-full justify-start bg-transparent">
variant="outline"
className="w-full justify-start bg-transparent"
>
<User className="mr-2 h-4 w-4" /> <User className="mr-2 h-4 w-4" />
Gerenciar Pacientes Atualizar Dados
</Button> </Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
{/* Pacientes */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Pacientes</CardTitle> <CardTitle>Próximas Consultas</CardTitle>
<CardDescription>Últimos pacientes cadastrados</CardDescription> <CardDescription>Suas consultas agendadas</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loadingPatients ? (
<p className="text-sm text-muted-foreground">Carregando pacientes...</p>
) : patients.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nenhum paciente cadastrado.
</p>
) : (
<div className="space-y-4"> <div className="space-y-4">
{patients.map((patient, index) => ( <div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<div
key={index}
className="flex items-center justify-between p-3 bg-primary/10 rounded-lg border border-primary/20"
>
<div> <div>
<p className="font-medium text-foreground"> <p className="font-medium">Dr. Silva</p>
{patient.full_name || "Sem nome"} <p className="text-sm text-gray-600">Cardiologia</p>
</p>
<p className="text-sm text-muted-foreground">
{patient.phone_mobile ||
patient.phone1 ||
"Sem telefone"}
</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="font-medium text-primary"> <p className="font-medium">15 Jan</p>
{patient.convenio || "Particular"} <p className="text-sm text-gray-600">14:30</p>
</p> </div>
</div>
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div>
<p className="font-medium">Dra. Santos</p>
<p className="text-sm text-gray-600">Dermatologia</p>
</div>
<div className="text-right">
<p className="font-medium">22 Jan</p>
<p className="text-sm text-gray-600">10:00</p>
</div> </div>
</div> </div>
))}
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
</Sidebar> </SecretaryLayout>
); )
} }

View File

@ -1,31 +1,11 @@
// Caminho: app/(secretary)/login/page.tsx // Caminho: app/(secretary)/login/page.tsx
import { LoginForm } from "@/components/LoginForm"; import { LoginForm } from "@/components/LoginForm";
import Link from "next/link"; // Adicionado para o link de "Voltar"
export default function SecretaryLoginPage() { export default function SecretaryLoginPage() {
// NOTA: Esta página se tornou obsoleta com a criação do /login central.
// O ideal no futuro é deletar esta página e redirecionar os usuários.
return ( return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-50 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center"> <LoginForm title="Área da Secretária" description="Acesse o sistema de gerenciamento" role="secretary" themeColor="blue" redirectPath="/secretary/pacientes" />
<h1 className="text-3xl font-bold text-foreground mb-2">Área da Secretária</h1>
<p className="text-muted-foreground mb-8">Acesse o sistema de gerenciamento</p>
{/* --- ALTERAÇÃO PRINCIPAL AQUI --- */}
{/* Chamando o LoginForm unificado sem props desnecessárias */}
<LoginForm>
{/* Adicionamos um link de "Voltar" como filho (children) */}
<div className="mt-6 text-center text-sm">
<Link href="/">
<span className="font-semibold text-primary hover:underline cursor-pointer">
Voltar à página inicial
</span>
</Link>
</div>
</LoginForm>
</div>
</div> </div>
); );
} }

View File

@ -13,8 +13,9 @@ import { Checkbox } from "@/components/ui/checkbox";
import { ArrowLeft, Save, Trash2, Paperclip, Upload } from "lucide-react"; import { ArrowLeft, Save, Trash2, Paperclip, Upload } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import SecretaryLayout from "@/components/secretary-layout";
import { patientsService } from "@/services/patientsApi.mjs"; import { patientsService } from "@/services/patientsApi.mjs";
import Sidebar from "@/components/Sidebar"; import { json } from "stream/consumers";
export default function EditarPacientePage() { export default function EditarPacientePage() {
const router = useRouter(); const router = useRouter();
@ -80,12 +81,6 @@ export default function EditarPacientePage() {
heightM?: string; heightM?: string;
bmi?: string; bmi?: string;
bloodType?: string; bloodType?: string;
// Adicionei os campos do convênio para o tipo FormData
convenio?: string;
plano?: string;
numeroMatricula?: string;
validadeCarteira?: string;
alergias?: string;
}; };
@ -138,11 +133,6 @@ export default function EditarPacientePage() {
heightM: "", heightM: "",
bmi: "", bmi: "",
bloodType: "", bloodType: "",
convenio: "",
plano: "",
numeroMatricula: "",
validadeCarteira: "",
alergias: "",
}); });
const [isGuiaConvenio, setIsGuiaConvenio] = useState(false); const [isGuiaConvenio, setIsGuiaConvenio] = useState(false);
@ -202,12 +192,6 @@ export default function EditarPacientePage() {
heightM: res[0]?.height_m ? String(res[0].height_m) : "", heightM: res[0]?.height_m ? String(res[0].height_m) : "",
bmi: res[0]?.bmi ? String(res[0].bmi) : "", bmi: res[0]?.bmi ? String(res[0].bmi) : "",
bloodType: res[0]?.blood_type ?? "", 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) { } catch (e: any) {
@ -263,10 +247,8 @@ export default function EditarPacientePage() {
}; };
return ( return (
<Sidebar> <SecretaryLayout>
{/* 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">
<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"> <div className="flex items-center gap-4">
<Link href="/secretary/pacientes"> <Link href="/secretary/pacientes">
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
@ -275,32 +257,57 @@ export default function EditarPacientePage() {
</Button> </Button>
</Link> </Link>
<div> <div>
<h1 className="text-2xl font-bold">Editar Paciente</h1> <h1 className="text-2xl font-bold text-gray-900">Editar Paciente</h1>
<p className="text-muted-foreground">Atualize as informações do paciente</p> <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> </div>
{/* Espaço reservado para anexos ou ações futuras */}
</div> </div>
<form onSubmit={handleSubmit} className="space-y-8"> <form onSubmit={handleSubmit} className="space-y-8">
{/* Dados Pessoais Section */} <div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="bg-card rounded-lg border p-6"> <h2 className="text-lg font-semibold text-gray-900 mb-6">Dados Pessoais</h2>
<h2 className="text-lg font-semibold mb-6">Dados Pessoais</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Photo upload */} {/* Photo upload */}
<div className="space-y-2 col-span-1 md:col-span-2 lg:col-span-1"> <div className="space-y-2">
<Label>Foto do paciente</Label> <Label>Foto do paciente</Label>
<div className="flex flex-col sm:flex-row items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-full bg-muted overflow-hidden flex items-center justify-center"> <div className="w-20 h-20 rounded-full bg-gray-100 overflow-hidden flex items-center justify-center">
{photoUrl ? ( {photoUrl ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={photoUrl} alt="Foto do paciente" className="w-full h-full object-cover" /> <img src={photoUrl} alt="Foto do paciente" className="w-full h-full object-cover" />
) : ( ) : (
<span className="text-muted-foreground text-sm text-center">Sem foto</span> <span className="text-gray-400 text-sm">Sem foto</span>
)} )}
</div> </div>
<div className="flex flex-col sm:flex-row gap-2 mt-2 sm:mt-0"> <div className="flex gap-2">
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" /> <input ref={fileInputRef} type="file" accept="image/*" className="hidden" />
<Button type="button" variant="outline" disabled={isUploadingPhoto}> <Button type="button" variant="outline" disabled={isUploadingPhoto}>
{isUploadingPhoto ? "Enviando..." : "Enviar foto"} {isUploadingPhoto ? "Enviando..." : "Enviar foto"}
@ -330,13 +337,13 @@ export default function EditarPacientePage() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Sexo *</Label> <Label>Sexo *</Label>
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex gap-4">
<div className="flex items-center space-x-2"> <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-primary" /> <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> <Label htmlFor="Masculino">Masculino</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<input type="radio" id="Feminino" name="sexo" value="Feminino" checked={formData.sexo === "Feminino"} onChange={(e) => handleInputChange("sexo", e.target.value)} className="w-4 h-4 text-primary" /> <input type="radio" id="Feminino" name="sexo" value="Feminino" checked={formData.sexo === "Feminino"} onChange={(e) => handleInputChange("sexo", e.target.value)} className="w-4 h-4 text-blue-600" />
<Label htmlFor="Feminino">Feminino</Label> <Label htmlFor="Feminino">Feminino</Label>
</div> </div>
</div> </div>
@ -397,7 +404,7 @@ export default function EditarPacientePage() {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="profissao">Profissão</Label> <Label htmlFor="profissao">Profissão</Label>
<Input id="profissao" value={formData.profession} onChange={(e) => handleInputChange("profissao", e.target.value)} /> <Input id="profissao" value={formData.profession} onChange={(e) => handleInputChange("profession", e.target.value)} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -465,8 +472,8 @@ export default function EditarPacientePage() {
</div> </div>
{/* Contact Section */} {/* Contact Section */}
<div className="bg-card rounded-lg border p-6"> <div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold mb-6">Contato</h2> <h2 className="text-lg font-semibold text-gray-900 mb-6">Contato</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="space-y-2"> <div className="space-y-2">
@ -492,8 +499,8 @@ export default function EditarPacientePage() {
</div> </div>
{/* Address Section */} {/* Address Section */}
<div className="bg-card rounded-lg border p-6"> <div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold mb-6">Endereço</h2> <h2 className="text-lg font-semibold text-gray-900 mb-6">Endereço</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="space-y-2"> <div className="space-y-2">
@ -567,8 +574,8 @@ export default function EditarPacientePage() {
</div> </div>
{/* Medical Information Section */} {/* Medical Information Section */}
<div className="bg-card rounded-lg border p-6"> <div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold mb-6">Informações Médicas</h2> <h2 className="text-lg font-semibold text-gray-900 mb-6">Informações Médicas</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="space-y-2"> <div className="space-y-2">
@ -608,18 +615,18 @@ export default function EditarPacientePage() {
<div className="mt-6"> <div className="mt-6">
<Label htmlFor="alergias">Alergias</Label> <Label htmlFor="alergias">Alergias</Label>
<Textarea id="alergias" value={formData.alergias} onChange={(e) => handleInputChange("alergias", e.target.value)} placeholder="Ex: AAS, Dipirona, etc." className="mt-2" /> <Textarea id="alergias" onChange={(e) => handleInputChange("alergias", e.target.value)} placeholder="Ex: AAS, Dipirona, etc." className="mt-2" />
</div> </div>
</div> </div>
{/* Insurance Information Section */} {/* Insurance Information Section */}
<div className="bg-card rounded-lg border p-6"> <div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold mb-6">Informações de convênio</h2> <h2 className="text-lg font-semibold text-gray-900 mb-6">Informações de convênio</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="convenio">Convênio</Label> <Label htmlFor="convenio">Convênio</Label>
<Select value={formData.convenio} onValueChange={(value) => handleInputChange("convenio", value)}> <Select onValueChange={(value) => handleInputChange("convenio", value)}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Selecione" /> <SelectValue placeholder="Selecione" />
</SelectTrigger> </SelectTrigger>
@ -635,17 +642,17 @@ export default function EditarPacientePage() {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="plano">Plano</Label> <Label htmlFor="plano">Plano</Label>
<Input id="plano" value={formData.plano} onChange={(e) => handleInputChange("plano", e.target.value)} /> <Input id="plano" onChange={(e) => handleInputChange("plano", e.target.value)} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="numeroMatricula"> de matrícula</Label> <Label htmlFor="numeroMatricula"> de matrícula</Label>
<Input id="numeroMatricula" value={formData.numeroMatricula} onChange={(e) => handleInputChange("numeroMatricula", e.target.value)} /> <Input id="numeroMatricula" onChange={(e) => handleInputChange("numeroMatricula", e.target.value)} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="validadeCarteira">Validade da Carteira</Label> <Label htmlFor="validadeCarteira">Validade da Carteira</Label>
<Input id="validadeCarteira" type="date" value={formData.validadeCarteira} onChange={(e) => handleInputChange("validadeCarteira", e.target.value)} disabled={validadeIndeterminada} /> <Input id="validadeCarteira" type="date" onChange={(e) => handleInputChange("validadeCarteira", e.target.value)} disabled={validadeIndeterminada} />
</div> </div>
</div> </div>
@ -657,19 +664,19 @@ export default function EditarPacientePage() {
</div> </div>
</div> </div>
<div className="flex flex-col sm:flex-row justify-end gap-4"> <div className="flex justify-end gap-4">
<Link href="/secretary/pacientes"> <Link href="/secretary/pacientes">
<Button type="button" variant="outline" className="w-full sm:w-auto"> <Button type="button" variant="outline">
Cancelar Cancelar
</Button> </Button>
</Link> </Link>
<Button type="submit" className="bg-primary hover:bg-primary/90 w-full sm:w-auto"> <Button type="submit" className="bg-blue-600 hover:bg-blue-700">
<Save className="w-4 h-4 mr-2" /> <Save className="w-4 h-4 mr-2" />
Salvar Alterações Salvar Alterações
</Button> </Button>
</div> </div>
</form> </form>
</div> </div>
</Sidebar> </SecretaryLayout>
); );
} }

View File

@ -1,175 +1,676 @@
"use client"; "use client";
import type React from "react";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Save, Loader2 } from "lucide-react"; import { Textarea } from "@/components/ui/textarea";
import { usersService } from "services/usersApi.mjs"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import Sidebar from "@/components/Sidebar"; import { Checkbox } from "@/components/ui/checkbox";
import { Upload, Plus, X, ChevronDown } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { useToast } from "@/hooks/use-toast";
import SecretaryLayout from "@/components/secretary-layout";
import { patientsService } from "@/services/patientsApi.mjs";
// Interface simplificada para refletir apenas os campos necessários export default function NovoPacientePage() {
interface UserFormData { const [anexosOpen, setAnexosOpen] = useState(false);
email: string; const [anexos, setAnexos] = useState<string[]>([]);
nomeCompleto: string; const [isLoading, setIsLoading] = useState(false);
telefone: string;
senha: string;
confirmarSenha: string;
cpf: string;
}
const defaultFormData: UserFormData = {
email: "",
nomeCompleto: "",
telefone: "",
senha: "",
confirmarSenha: "",
cpf: "",
};
const cleanNumber = (value: string): string => value.replace(/\D/g, "");
const formatPhone = (value: string): string => {
const cleaned = cleanNumber(value).substring(0, 11);
if (cleaned.length === 11) return cleaned.replace(/(\d{2})(\d{5})(\d{4})/, "($1) $2-$3");
if (cleaned.length === 10) return cleaned.replace(/(\d{2})(\d{4})(\d{4})/, "($1) $2-$3");
return cleaned;
};
export default function NovoUsuarioPage() {
const router = useRouter(); const router = useRouter();
const [formData, setFormData] = useState<UserFormData>(defaultFormData); const { toast } = useToast();
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleInputChange = (key: keyof UserFormData, value: string) => { const adicionarAnexo = () => {
const updatedValue = key === "telefone" ? formatPhone(value) : value; setAnexos([...anexos, `Documento ${anexos.length + 1}`]);
setFormData((prev) => ({ ...prev, [key]: updatedValue }));
}; };
const handleSubmit = async (e: React.FormEvent) => { const removerAnexo = (index: number) => {
setAnexos(anexos.filter((_, i) => i !== index));
};
const cleanNumber = (value: string): string => value.replace(/\D/g, '');
const formatCPF = (value: string): string => {
const cleaned = cleanNumber(value).substring(0, 11);
return cleaned.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4');
};
const formatCEP = (value: string): string => {
const cleaned = cleanNumber(value).substring(0, 8);
return cleaned.replace(/(\d{5})(\d{3})/, '$1-$2');
};
const formatPhoneMobile = (value: string): string => {
const cleaned = cleanNumber(value).substring(0, 11);
if (cleaned.length > 10) {
return cleaned.replace(/(\d{2})(\d{5})(\d{4})/, '+55 ($1) $2-$3');
}
return cleaned.replace(/(\d{2})(\d{4})(\d{4})/, '+55 ($1) $2-$3');
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setError(null); if (isLoading) return;
setIsLoading(true);
const form = e.currentTarget;
const formData = new FormData(form);
// Validação simplificada const apiPayload = {
if (!formData.email || !formData.nomeCompleto || !formData.senha || !formData.confirmarSenha || !formData.cpf) { full_name: (formData.get("nome") as string) || "", // obrigatório
setError("Por favor, preencha todos os campos obrigatórios."); social_name: (formData.get("nomeSocial") as string) || undefined,
return; cpf: (formatCPF(formData.get("cpf") as string)) || "", // obrigatório
email: (formData.get("email") as string) || "", // obrigatório
phone_mobile: (formatPhoneMobile(formData.get("celular") as string)) || "", // obrigatório
birth_date: formData.get("dataNascimento") ? new Date(formData.get("dataNascimento") as string) : undefined,
sex: (formData.get("sexo") as string) || undefined,
blood_type: (formData.get("tipoSanguineo") as string) || undefined,
weight_kg: formData.get("peso") ? parseFloat(formData.get("peso") as string) : undefined,
height_m: formData.get("altura") ? parseFloat(formData.get("altura") as string) : undefined,
cep: (formatCEP(formData.get("cep") as string)) || undefined,
street: (formData.get("endereco") as string) || undefined,
number: (formData.get("numero") as string) || undefined,
complement: (formData.get("complemento") as string) || undefined,
neighborhood: (formData.get("bairro") as string) || undefined,
city: (formData.get("cidade") as string) || undefined,
state: (formData.get("estado") as string) || undefined,
};
console.log(apiPayload.email)
console.log(apiPayload.cep)
console.log(apiPayload.phone_mobile)
const errors: string[] = [];
const fullName = apiPayload.full_name?.trim() || "";
if (!fullName || fullName.length < 2 || fullName.length > 255) {
errors.push("Nome deve ter entre 2 e 255 caracteres.");
} }
if (formData.senha !== formData.confirmarSenha) { const cpf = apiPayload.cpf || "";
setError("A Senha e a Confirmação de Senha não coincidem."); if (!/^\d{3}\.\d{3}\.\d{3}-\d{2}$/.test(cpf)) {
return; errors.push("CPF deve estar no formato XXX.XXX.XXX-XX.");
} }
setIsSaving(true); const sex = apiPayload.sex;
const allowedSex = ["Masculino", "Feminino", "outro"];
if (!sex || !allowedSex.includes(sex)) {
errors.push("Sexo é obrigatório e deve ser masculino, feminino ou outro.");
}
if (!apiPayload.birth_date) {
errors.push("Data de nascimento é obrigatória.");
}
const phoneMobile = apiPayload.phone_mobile || "";
if (phoneMobile && !/^\+55 \(\d{2}\) \d{4,5}-\d{4}$/.test(phoneMobile)) {
errors.push("Celular deve estar no formato +55 (XX) XXXXX-XXXX.");
}
const cep = apiPayload.cep || "";
if (cep && !/^\d{5}-\d{3}$/.test(cep)) {
errors.push("CEP deve estar no formato XXXXX-XXX.");
}
const state = apiPayload.state || "";
if (state && state.length !== 2) {
errors.push("Estado (UF) deve ter 2 caracteres.");
}
if (errors.length) {
toast({ title: "Corrija os campos", description: errors[0] });
console.log("campos errados")
setIsLoading(false);
return;
}
try { try {
// Payload agora é fixo para a role 'paciente' const res = await patientsService.create(apiPayload);
const payload = { console.log(res)
full_name: formData.nomeCompleto,
email: formData.email.trim().toLowerCase(),
phone: formData.telefone || null,
role: "paciente", // Role fixada
password: formData.senha,
cpf: formData.cpf,
};
console.log("📤 Enviando payload para criação de Usuário (Paciente):"); let message = "Paciente cadastrado com sucesso";
console.log(payload); try {
if (!res[0].id) {
throw new Error(`${res.error} ${res.message}`|| "A API retornou erro");
} else {
console.log(message)
}
} catch {}
// A chamada original à API foi mantida toast({
await usersService.create_user(payload); title: "Sucesso",
description: message,
router.push("/manager/usuario"); });
} catch (e: any) { router.push("/secretary/pacientes");
console.error("Erro ao criar usuário:", e); } catch (err: any) {
setError(e?.message || "Não foi possível criar o paciente. Verifique os dados e tente novamente."); toast({
title: "Erro",
description: err?.message || "Não foi possível cadastrar o paciente",
});
} finally { } finally {
setIsSaving(false); setIsLoading(false);
} }
}; };
return ( return (
<Sidebar> <SecretaryLayout>
{/* Container principal com padding responsivo e centralização */} <div className="space-y-6">
<div className="w-full h-full p-4 md:p-8 lg:p-12 flex justify-center items-start"> <div className="flex items-center justify-between">
{/* 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> <div>
<h1 className="text-2xl sm:text-3xl font-extrabold text-gray-900">Novo Paciente</h1> {/* Tamanho de texto responsivo */} <h1 className="text-2xl font-bold text-gray-900">Novo Paciente</h1>
<p className="text-sm sm:text-md text-gray-500">Preencha os dados para cadastrar um novo paciente no sistema.</p> {/* Tamanho de texto responsivo */} <p className="text-gray-600">Cadastre um novo paciente no sistema</p>
</div> </div>
</div>
<form className="space-y-6" onSubmit={handleSubmit}>
<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="space-y-6">
<div className="flex items-center gap-4">
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center">
<Upload className="w-8 h-8 text-gray-400" />
</div>
<Button variant="outline" type="button" size="sm">
<Upload className="w-4 h-4 mr-2" />
Carregar Foto
</Button>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="nome" className="text-sm font-medium text-gray-700">
Nome *
</Label>
<Input id="nome" name="nome" placeholder="Nome completo" required className="mt-1" />
</div>
<div>
<Label htmlFor="nomeSocial" className="text-sm font-medium text-gray-700">
Nome Social
</Label>
<Input id="nomeSocial" name="nomeSocial" placeholder="Nome social ou apelido" className="mt-1" />
</div>
</div>
<div className="grid md:grid-cols-3 gap-4">
<div>
<Label htmlFor="cpf" className="text-sm font-medium text-gray-700">
CPF *
</Label>
<Input id="cpf" name="cpf" placeholder="000.000.000-00" required className="mt-1" />
</div>
<div>
<Label htmlFor="rg" className="text-sm font-medium text-gray-700">
RG
</Label>
<Input id="rg" name="rg" placeholder="00.000.000-0" className="mt-1" />
</div>
<div>
<Label htmlFor="outrosDocumentos" className="text-sm font-medium text-gray-700">
Outros Documentos
</Label>
<Select name="outrosDocumentos">
<SelectTrigger className="mt-1">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="cnh">CNH</SelectItem>
<SelectItem value="passaporte">Passaporte</SelectItem>
<SelectItem value="carteira-trabalho">Carteira de Trabalho</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid md:grid-cols-3 gap-4">
<div>
<Label className="text-sm font-medium text-gray-700">Sexo *</Label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2">
<input type="radio" name="sexo" value="Masculino" className="text-blue-600" required/>
<span className="text-sm">Masculino</span>
</label>
<label className="flex items-center gap-2">
<input type="radio" name="sexo" value="Feminino" className="text-blue-600" required/>
<span className="text-sm">Feminino</span>
</label>
</div>
</div>
<div>
<Label htmlFor="dataNascimento" className="text-sm font-medium text-gray-700">
Data de Nascimento *
</Label>
<Input id="dataNascimento" name="dataNascimento" type="date" className="mt-1" required/>
</div>
<div>
<Label htmlFor="estadoCivil" className="text-sm font-medium text-gray-700">
Estado Civil
</Label>
<Select name="estadoCivil">
<SelectTrigger className="mt-1">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="solteiro">Solteiro(a)</SelectItem>
<SelectItem value="casado">Casado(a)</SelectItem>
<SelectItem value="divorciado">Divorciado(a)</SelectItem>
<SelectItem value="viuvo">Viúvo(a)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="etnia" className="text-sm font-medium text-gray-700">
Etnia
</Label>
<Select name="etnia">
<SelectTrigger className="mt-1">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="branca">Branca</SelectItem>
<SelectItem value="preta">Preta</SelectItem>
<SelectItem value="parda">Parda</SelectItem>
<SelectItem value="amarela">Amarela</SelectItem>
<SelectItem value="indigena">Indígena</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="raca" className="text-sm font-medium text-gray-700">
Raça
</Label>
<Select name="raca">
<SelectTrigger className="mt-1">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="branca">Branca</SelectItem>
<SelectItem value="preta">Preta</SelectItem>
<SelectItem value="parda">Parda</SelectItem>
<SelectItem value="amarela">Amarela</SelectItem>
<SelectItem value="indigena">Indígena</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="naturalidade" className="text-sm font-medium text-gray-700">
Naturalidade
</Label>
<Select name="naturalidade">
<SelectTrigger className="mt-1">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="aracaju">Aracaju</SelectItem>
<SelectItem value="salvador">Salvador</SelectItem>
<SelectItem value="recife">Recife</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="nacionalidade" className="text-sm font-medium text-gray-700">
Nacionalidade
</Label>
<Select name="nacionalidade">
<SelectTrigger className="mt-1">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="brasileira">Brasileira</SelectItem>
<SelectItem value="estrangeira">Estrangeira</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="profissao" className="text-sm font-medium text-gray-700">
Profissão
</Label>
<Input id="profissao" name="profissao" placeholder="Profissão" className="mt-1" />
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="nomeMae" className="text-sm font-medium text-gray-700">
Nome da Mãe
</Label>
<Input id="nomeMae" name="nomeMae" placeholder="Nome da mãe" className="mt-1" />
</div>
<div>
<Label htmlFor="profissaoMae" className="text-sm font-medium text-gray-700">
Profissão da Mãe
</Label>
<Input id="profissaoMae" name="profissaoMae" placeholder="Profissão da mãe" className="mt-1" />
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="nomePai" className="text-sm font-medium text-gray-700">
Nome do Pai
</Label>
<Input id="nomePai" name="nomePai" placeholder="Nome do pai" className="mt-1" />
</div>
<div>
<Label htmlFor="profissaoPai" className="text-sm font-medium text-gray-700">
Profissão do Pai
</Label>
<Input id="profissaoPai" name="profissaoPai" placeholder="Profissão do pai" className="mt-1" />
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="nomeResponsavel" className="text-sm font-medium text-gray-700">
Nome do Responsável
</Label>
<Input id="nomeResponsavel" name="nomeResponsavel" placeholder="Nome do responsável" className="mt-1" />
</div>
<div>
<Label htmlFor="cpfResponsavel" className="text-sm font-medium text-gray-700">
CPF do Responsável
</Label>
<Input id="cpfResponsavel" name="cpfResponsavel" placeholder="000.000.000-00" className="mt-1" />
</div>
</div>
<div>
<Label htmlFor="nomeEsposo" className="text-sm font-medium text-gray-700">
Nome do Esposo(a)
</Label>
<Input id="nomeEsposo" name="nomeEsposo" placeholder="Nome do esposo(a)" className="mt-1" />
</div>
<div className="flex items-center space-x-2">
<Checkbox id="rnGuia" name="rnGuia" />
<Label htmlFor="rnGuia" className="text-sm text-gray-700">
RN na Guia do convênio
</Label>
</div>
<div>
<Label htmlFor="codigoLegado" className="text-sm font-medium text-gray-700">
Código Legado
</Label>
<Input id="codigoLegado" name="codigoLegado" placeholder="Código do sistema anterior" className="mt-1" />
</div>
<div>
<Label htmlFor="observacoes" className="text-sm font-medium text-gray-700">
Observações
</Label>
<Textarea id="observacoes" name="observacoes" placeholder="Observações gerais sobre o paciente" className="min-h-[100px] mt-1" />
</div>
<Collapsible open={anexosOpen} onOpenChange={setAnexosOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" type="button" className="w-full justify-between p-0 h-auto text-left">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gray-400 rounded-sm flex items-center justify-center">
<span className="text-white text-xs">📎</span>
</div>
<span className="text-sm font-medium text-gray-700">Anexos do paciente</span>
</div>
<ChevronDown className={`w-4 h-4 transition-transform ${anexosOpen ? "rotate-180" : ""}`} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 mt-4">
{anexos.map((anexo, index) => (
<div key={index} className="flex items-center justify-between p-3 border rounded-lg bg-gray-50">
<span className="text-sm">{anexo}</span>
<Button variant="ghost" size="sm" onClick={() => removerAnexo(index)} type="button">
<X className="w-4 h-4" />
</Button>
</div>
))}
<Button variant="outline" onClick={adicionarAnexo} type="button" size="sm">
<Plus className="w-4 h-4 mr-2" />
Adicionar Anexo
</Button>
</CollapsibleContent>
</Collapsible>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-6">Contato</h2>
<div className="space-y-4">
<div className="grid md:grid-cols-3 gap-4">
<div>
<Label htmlFor="email" className="text-sm font-medium text-gray-700">
E-mail *
</Label>
<Input id="email" name="email" type="email" placeholder="email@exemplo.com" className="mt-1" required/>
</div>
<div>
<Label htmlFor="celular" className="text-sm font-medium text-gray-700">
Celular *
</Label>
<div className="flex mt-1">
<Select>
<SelectTrigger className="w-20 rounded-r-none">
<SelectValue placeholder="+55" />
</SelectTrigger>
<SelectContent>
<SelectItem value="+55">+55</SelectItem>
</SelectContent>
</Select>
<Input id="celular" name="celular" placeholder="(XX) XXXXX-XXXX" className="rounded-l-none" required/>
</div>
</div>
<div>
<Label htmlFor="telefone1" className="text-sm font-medium text-gray-700">
Telefone 1
</Label>
<Input id="telefone1" name="telefone1" placeholder="(XX) XXXX-XXXX" className="mt-1" />
</div>
</div>
<div>
<Label htmlFor="telefone2" className="text-sm font-medium text-gray-700">
Telefone 2
</Label>
<Input id="telefone2" name="telefone2" placeholder="(XX) XXXX-XXXX" className="mt-1" />
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-6">Endereço</h2>
<div className="space-y-4">
<div>
<Label htmlFor="cep" className="text-sm font-medium text-gray-700">
CEP
</Label>
<Input id="cep" name="cep" placeholder="00000-000" className="mt-1 max-w-xs" />
</div>
<div className="grid md:grid-cols-3 gap-4">
<div className="md:col-span-2">
<Label htmlFor="endereco" className="text-sm font-medium text-gray-700">
Endereço
</Label>
<Input id="endereco" name="endereco" placeholder="Rua, Avenida..." className="mt-1" />
</div>
<div>
<Label htmlFor="numero" className="text-sm font-medium text-gray-700">
Número
</Label>
<Input id="numero" name="numero" placeholder="123" className="mt-1" />
</div>
</div>
<div>
<Label htmlFor="complemento" className="text-sm font-medium text-gray-700">
Complemento
</Label>
<Input id="complemento" name="complemento" placeholder="Apto, Bloco..." className="mt-1" />
</div>
<div className="grid md:grid-cols-3 gap-4">
<div>
<Label htmlFor="bairro" className="text-sm font-medium text-gray-700">
Bairro
</Label>
<Input id="bairro" name="bairro" placeholder="Bairro" className="mt-1" />
</div>
<div>
<Label htmlFor="cidade" className="text-sm font-medium text-gray-700">
Cidade
</Label>
<Input id="cidade" name="cidade" placeholder="Cidade" className="mt-1" />
</div>
<div>
<Label htmlFor="estado" className="text-sm font-medium text-gray-700">
Estado
</Label>
<Select name="estado">
<SelectTrigger className="mt-1">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="SE">Sergipe</SelectItem>
<SelectItem value="BA">Bahia</SelectItem>
<SelectItem value="AL">Alagoas</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-6">Informações Médicas</h2>
<div className="space-y-4">
<div className="grid md:grid-cols-4 gap-4">
<div>
<Label htmlFor="tipoSanguineo" className="text-sm font-medium text-gray-700">
Tipo Sanguíneo
</Label>
<Select name="tipoSanguineo">
<SelectTrigger className="mt-1">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="A+">A+</SelectItem>
<SelectItem value="A-">A-</SelectItem>
<SelectItem value="B+">B+</SelectItem>
<SelectItem value="B-">B-</SelectItem>
<SelectItem value="AB+">AB+</SelectItem>
<SelectItem value="AB-">AB-</SelectItem>
<SelectItem value="O+">O+</SelectItem>
<SelectItem value="O-">O-</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="peso" className="text-sm font-medium text-gray-700">
Peso
</Label>
<div className="relative mt-1">
<Input id="peso" name="peso" type="number" placeholder="70" />
<span className="absolute right-3 top-1/2 transform -translate-y-1/2 text-sm text-gray-500">kg</span>
</div>
</div>
<div>
<Label htmlFor="altura" className="text-sm font-medium text-gray-700">
Altura
</Label>
<div className="relative mt-1">
<Input id="altura" name="altura" type="number" step="0.01" placeholder="1.70" />
<span className="absolute right-3 top-1/2 transform -translate-y-1/2 text-sm text-gray-500">m</span>
</div>
</div>
<div>
<Label htmlFor="imc" className="text-sm font-medium text-gray-700">
IMC
</Label>
<div className="relative mt-1">
<Input id="imc" name="imc" placeholder="Calculado automaticamente" disabled />
<span className="absolute right-3 top-1/2 transform -translate-y-1/2 text-sm text-gray-500">kg/m²</span>
</div>
</div>
</div>
<div>
<Label htmlFor="alergias" className="text-sm font-medium text-gray-700">
Alergias
</Label>
<Textarea id="alergias" name="alergias" placeholder="Ex: AAS, Dipirona, etc." className="min-h-[80px] mt-1" />
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-6">Informações de Convênio</h2>
<div className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="convenio" className="text-sm font-medium text-gray-700">
Convênio
</Label>
<Select name="convenio">
<SelectTrigger className="mt-1">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="particular">Particular</SelectItem>
<SelectItem value="sus">SUS</SelectItem>
<SelectItem value="unimed">Unimed</SelectItem>
<SelectItem value="bradesco">Bradesco Saúde</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="plano" className="text-sm font-medium text-gray-700">
Plano
</Label>
<Input id="plano" name="plano" placeholder="Nome do plano" className="mt-1" />
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="numeroMatricula" className="text-sm font-medium text-gray-700">
de Matrícula
</Label>
<Input id="numeroMatricula" name="numeroMatricula" placeholder="Número da matrícula" className="mt-1" />
</div>
<div>
<Label htmlFor="validadeCarteira" className="text-sm font-medium text-gray-700">
Validade da Carteira
</Label>
<Input id="validadeCarteira" name="validadeCarteira" type="date" className="mt-1" />
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="validadeIndeterminada" name="validadeIndeterminada" />
<Label htmlFor="validadeIndeterminada" className="text-sm text-gray-700">
Validade Indeterminada
</Label>
</div>
</div>
</div>
<div className="flex justify-end gap-4">
<Link href="/secretary/pacientes"> <Link href="/secretary/pacientes">
<Button variant="outline" className="w-full sm:w-auto">Cancelar</Button> {/* Botão ocupa largura total em telas pequenas */} <Button variant="outline" type="button">
</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">
<p className="font-semibold">Erro no Cadastro:</p>
<p className="text-sm break-words">{error}</p>
</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"> {/* 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>
<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)} placeholder="exemplo@dominio.com" required />
</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>
<div className="space-y-2">
<Label htmlFor="senha">Senha *</Label>
<Input id="senha" type="password" value={formData.senha} onChange={(e) => handleInputChange("senha", e.target.value)} placeholder="Mínimo 8 caracteres" minLength={8} required />
</div>
<div className="space-y-2">
<Label htmlFor="confirmarSenha">Confirmar Senha *</Label>
<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 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>
{/* 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="/secretary/pacientes">
<Button type="button" variant="outline" disabled={isSaving} className="w-full sm:w-auto">
Cancelar Cancelar
</Button> </Button>
</Link> </Link>
<Link href="/secretary/pacientes"> <Button type="submit" className="bg-blue-600 hover:bg-blue-700" disabled={isLoading}>
<Button type="submit" className="bg-green-600 hover:bg-green-700 w-full sm:w-auto" disabled={isSaving}> {isLoading ? "Salvando..." : "Salvar Paciente"}
{isSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
{isSaving ? "Salvando..." : "Salvar Paciente"}
</Button> </Button>
</Link>
</div> </div>
</form> </form>
</div> </div>
</div> </SecretaryLayout>
</Sidebar>
); );
} }

View File

@ -1,138 +1,108 @@
// app/secretary/pacientes/page.tsx
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Edit, Trash2, Eye, Calendar, Filter, Loader2, MoreVertical } from "lucide-react"; import { Plus, Edit, Trash2, Eye, Calendar, Filter } from "lucide-react";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; 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 { patientsService } from "@/services/patientsApi.mjs";
import Sidebar from "@/components/Sidebar";
// Defina o tamanho da página.
const PAGE_SIZE = 5;
export default function PacientesPage() { export default function PacientesPage() {
// --- ESTADOS DE DADOS E GERAL ---
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [convenioFilter, setConvenioFilter] = useState("all"); const [convenioFilter, setConvenioFilter] = useState("all");
const [vipFilter, setVipFilter] = useState("all"); const [vipFilter, setVipFilter] = useState("all");
const [patients, setPatients] = useState<any[]>([]);
// Lista completa, carregada da API uma única vez const [loading, setLoading] = useState(false);
const [allPatients, setAllPatients] = useState<any[]>([]);
// Lista após a aplicação dos filtros (base para a paginação)
const [filteredPatients, setFilteredPatients] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// --- ESTADOS DE PAGINAÇÃO ---
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(true);
// CÁLCULO DA PAGINAÇÃO const [isFetching, setIsFetching] = useState(false);
const totalPages = Math.ceil(filteredPatients.length / PAGE_SIZE); const observerRef = useRef<HTMLDivElement | null>(null);
const startIndex = (page - 1) * PAGE_SIZE;
const endIndex = startIndex + PAGE_SIZE;
// Pacientes a serem exibidos na tabela (aplicando a paginação)
const currentPatients = filteredPatients.slice(startIndex, endIndex);
// --- ESTADOS DE DIALOGS ---
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [patientToDelete, setPatientToDelete] = useState<string | null>(null); const [patientToDelete, setPatientToDelete] = useState<string | null>(null);
const [detailsDialogOpen, setDetailsDialogOpen] = useState(false); const [detailsDialogOpen, setDetailsDialogOpen] = useState(false);
const [patientDetails, setPatientDetails] = useState<any | null>(null); const [patientDetails, setPatientDetails] = useState<any | null>(null);
// --- FUNÇÕES DE LÓGICA ---
// 1. Função para carregar TODOS os pacientes da API
const fetchAllPacientes = useCallback(
async () => {
setLoading(true);
setError(null);
try {
// Como o backend retorna um array, chamamos sem paginação
const res = await patientsService.list();
const mapped = res.map((p: any) => ({
id: String(p.id ?? ""),
nome: p.full_name ?? "—",
telefone: p.phone_mobile ?? p.phone1 ?? "—",
cidade: p.city ?? "—",
estado: p.state ?? "—",
// Formate as datas se necessário, aqui usamos como string
ultimoAtendimento: p.last_visit_at?.split('T')[0] ?? "—",
proximoAtendimento: p.next_appointment_at?.split('T')[0] ?? "—",
vip: Boolean(p.vip ?? false),
convenio: p.convenio ?? "Particular", // Define um valor padrão
status: p.status ?? undefined,
}));
setAllPatients(mapped);
} catch (e: any) {
console.error(e);
setError(e?.message || "Erro ao buscar pacientes");
} finally {
setLoading(false);
}
}, []);
// 2. Efeito para aplicar filtros e calcular a lista filtrada (chama-se quando allPatients ou filtros mudam)
useEffect(() => {
const filtered = allPatients.filter((patient) => {
// Filtro por termo de busca (Nome ou Telefone)
const matchesSearch =
patient.nome?.toLowerCase().includes(searchTerm.toLowerCase()) ||
patient.telefone?.includes(searchTerm);
// Filtro por Convênio
const matchesConvenio =
convenioFilter === "all" ||
patient.convenio === convenioFilter;
// Filtro por VIP
const matchesVip =
vipFilter === "all" ||
(vipFilter === "vip" && patient.vip) ||
(vipFilter === "regular" && !patient.vip);
return matchesSearch && matchesConvenio && matchesVip;
});
setFilteredPatients(filtered);
// Garante que a página atual seja válida após a filtragem
setPage(1);
}, [allPatients, searchTerm, convenioFilter, vipFilter]);
// 3. Efeito inicial para buscar os pacientes
useEffect(() => {
fetchAllPacientes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// --- LÓGICA DE AÇÕES (DELETAR / VER DETALHES) ---
const openDetailsDialog = async (patientId: string) => { const openDetailsDialog = async (patientId: string) => {
setDetailsDialogOpen(true); setDetailsDialogOpen(true);
setPatientDetails(null); setPatientDetails(null);
try { try {
const res = await patientsService.getById(patientId); const res = await patientsService.getById(patientId);
setPatientDetails(Array.isArray(res) ? res[0] : res); // Supondo que retorne um array com um item setPatientDetails(res[0]);
} catch (e: any) { } catch (e: any) {
setPatientDetails({ error: e?.message || "Erro ao buscar detalhes" }); setPatientDetails({ error: e?.message || "Erro ao buscar detalhes" });
} }
}; };
const handleDeletePatient = async (patientId: string) => { const fetchPacientes = useCallback(
async (pageToFetch: number) => {
if (isFetching || !hasNext) return;
setIsFetching(true);
setError(null);
try { try {
await patientsService.delete(patientId); const res = await patientsService.list();
// Atualiza a lista completa para refletir a exclusão const mapped = res.map((p: any) => ({
setAllPatients((prev) => id: String(p.id ?? ""),
prev.filter((p) => String(p.id) !== String(patientId)) nome: p.full_name ?? "",
); telefone: p.phone_mobile ?? p.phone1 ?? "",
cidade: p.city ?? "",
estado: p.state ?? "",
ultimoAtendimento: p.last_visit_at ?? "",
proximoAtendimento: p.next_appointment_at ?? "",
vip: Boolean(p.vip ?? false),
convenio: p.convenio ?? "", // se não existir, fica vazio
status: p.status ?? undefined,
}));
setPatients((prev) => {
const all = [...prev, ...mapped];
const unique = Array.from(new Map(all.map((p) => [p.id, p])).values());
return unique;
});
if (!mapped.id) setHasNext(false); // parar carregamento
else setPage((prev) => prev + 1);
} catch (e: any) { } catch (e: any) {
alert(`Erro ao deletar paciente: ${e?.message || "Erro desconhecido"}`); setError(e?.message || "Erro ao buscar pacientes");
} finally {
setIsFetching(false);
}
},
[isFetching, hasNext]
);
useEffect(() => {
fetchPacientes(page);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!observerRef.current || !hasNext) return;
const observer = new window.IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isFetching && hasNext) {
fetchPacientes(page);
}
});
observer.observe(observerRef.current);
return () => {
if (observerRef.current) observer.unobserve(observerRef.current);
};
}, [fetchPacientes, page, hasNext, isFetching]);
const handleDeletePatient = async (patientId: string) => {
// Remove from current list (client-side deletion)
try {
const res = await patientsService.delete(patientId);
if (res) {
alert(`${res.error} ${res.message}`);
}
setPatients((prev) => prev.filter((p) => String(p.id) !== String(patientId)));
} catch (e: any) {
setError(e?.message || "Erro ao deletar paciente");
} }
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setPatientToDelete(null); setPatientToDelete(null);
@ -143,22 +113,25 @@ export default function PacientesPage() {
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
}; };
const filteredPatients = patients.filter((patient) => {
const matchesSearch = patient.nome?.toLowerCase().includes(searchTerm.toLowerCase()) || patient.telefone?.includes(searchTerm);
const matchesConvenio = convenioFilter === "all" || (patient.convenio ?? "") === convenioFilter;
const matchesVip = vipFilter === "all" || (vipFilter === "vip" && patient.vip) || (vipFilter === "regular" && !patient.vip);
return matchesSearch && matchesConvenio && matchesVip;
});
return ( return (
<Sidebar> <SecretaryLayout>
<div className="space-y-6 px-2 sm:px-4 md:px-8"> <div className="space-y-6">
{/* Header (Responsividade OK) */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div> <div>
<h1 className="text-xl md:text-2xl font-bold"> <h1 className="text-xl md:text-2xl font-bold text-foreground">Pacientes</h1>
Pacientes <p className="text-muted-foreground text-sm md:text-base">Gerencie as informações de seus pacientes</p>
</h1>
<p className="text-muted-foreground text-sm md:text-base">
Gerencie as informações de seus pacientes
</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Link href="/secretary/pacientes/novo" className="w-full md:w-auto"> <Link href="/secretary/pacientes/novo">
<Button className="w-full bg-primary hover:bg-primary/90"> <Button className="w-full md:w-auto">
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Adicionar Adicionar
</Button> </Button>
@ -166,46 +139,55 @@ export default function PacientesPage() {
</div> </div>
</div> </div>
{/* Bloco de Filtros (Responsividade APLICADA) */} <div className="flex flex-col md:flex-row flex-wrap 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"> {/* Convênio */}
<Filter className="w-5 h-5 text-muted-foreground" /> <div className="flex items-center gap-2 w-full md:w-auto">
<span className="text-sm font-medium text-foreground">Convênio</span>
{/* Busca - Ocupa 100% no mobile, depois cresce */}
<input
type="text"
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"
/>
{/* 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 whitespace-nowrap hidden md:block">
Convênio
</span>
<Select value={convenioFilter} onValueChange={setConvenioFilter}> <Select value={convenioFilter} onValueChange={setConvenioFilter}>
<SelectTrigger className="w-full sm:w-40"> <SelectTrigger className="w-full md:w-40">
{" "} <SelectValue placeholder="Selecione o Convênio" />
{/* w-full para mobile, w-40 para sm+ */}
<SelectValue placeholder="Convênio" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Todos</SelectItem> <SelectItem value="all">Todos</SelectItem>
<SelectItem value="Particular">Particular</SelectItem> <SelectItem value="Particular">Particular</SelectItem>
<SelectItem value="SUS">SUS</SelectItem> <SelectItem value="SUS">SUS</SelectItem>
<SelectItem value="Unimed">Unimed</SelectItem> <SelectItem value="Unimed">Unimed</SelectItem>
{/* Adicione outros convênios conforme necessário */}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* VIP - Ocupa a largura total em telas pequenas, depois se ajusta */} <div className="flex items-center gap-2 w-full md:w-auto">
<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">VIP</span>
<span className="text-sm font-medium whitespace-nowrap hidden md:block">VIP</span>
<Select value={vipFilter} onValueChange={setVipFilter}> <Select value={vipFilter} onValueChange={setVipFilter}>
<SelectTrigger className="w-full sm:w-32"> {/* w-full para mobile, w-32 para sm+ */} <SelectTrigger className="w-full md:w-32">
<SelectValue placeholder="VIP" /> <SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="vip">VIP</SelectItem>
<SelectItem value="regular">Regular</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 w-full md:w-auto">
<span className="text-sm font-medium text-foreground">Aniversariantes</span>
<Select>
<SelectTrigger className="w-full md:w-32">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Hoje</SelectItem>
<SelectItem value="week">Esta semana</SelectItem>
<SelectItem value="month">Este mês</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">VIP</span>
<Select value={vipFilter} onValueChange={setVipFilter}>
<SelectTrigger className="w-32">
<SelectValue placeholder="Selecione" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Todos</SelectItem> <SelectItem value="all">Todos</SelectItem>
@ -215,138 +197,87 @@ export default function PacientesPage() {
</Select> </Select>
</div> </div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">Aniversariantes</span>
<Select>
<SelectTrigger className="w-32">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Hoje</SelectItem>
<SelectItem value="week">Esta semana</SelectItem>
<SelectItem value="month">Este mês</SelectItem>
</SelectContent>
</Select>
</div> </div>
{/* --- SEÇÃO DE TABELA (VISÍVEL EM TELAS MAIORES OU IGUAIS A MD) --- */} <Button variant="outline" className="ml-auto w-full md:w-auto">
{/* Garantir que a tabela se esconda em telas menores e apareça em MD+ */} <Filter className="w-4 h-4 mr-2" />
<div className="bg-card rounded-lg border shadow-md hidden md:block"> Filtro avançado
</Button>
</div>
<div className="bg-white rounded-lg border border-gray-200">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{" "}
{/* Permite rolagem horizontal se a tabela for muito larga */}
{error ? ( {error ? (
<div className="p-6 text-destructive">{`Erro ao carregar pacientes: ${error}`}</div> <div className="p-6 text-red-600">{`Erro ao carregar pacientes: ${error}`}</div>
) : loading ? (
<div className="p-6 text-center text-muted-foreground flex items-center justify-center">
<Loader2 className="w-6 h-6 mr-2 animate-spin text-primary" />{" "}
Carregando pacientes...
</div>
) : ( ) : (
<table className="w-full min-w-[650px]"> <table className="w-full min-w-[600px]">
{" "} <thead className="bg-gray-50 border-b border-gray-200">
{/* min-w para evitar que a tabela se contraia demais */}
<thead className="bg-muted border-b">
<tr> <tr>
<th className="text-left p-4 font-medium text-muted-foreground w-[20%]"> <th className="text-left p-2 md:p-4 font-medium text-gray-700">Nome</th>
Nome <th className="text-left p-2 md:p-4 font-medium text-gray-700">Telefone</th>
</th> <th className="text-left p-2 md:p-4 font-medium text-gray-700">Cidade</th>
{/* Ajustes de visibilidade de colunas para diferentes breakpoints */} <th className="text-left p-2 md:p-4 font-medium text-gray-700">Estado</th>
<th className="text-left p-4 font-medium text-muted-foreground w-[15%] hidden sm:table-cell"> <th className="text-left p-2 md:p-4 font-medium text-gray-700">Último atendimento</th>
Telefone <th className="text-left p-2 md:p-4 font-medium text-gray-700">Próximo atendimento</th>
</th> <th className="text-left p-2 md:p-4 font-medium text-gray-700">Ações</th>
<th className="text-left p-4 font-medium text-muted-foreground w-[15%] hidden md:table-cell">
Cidade / Estado
</th>
<th className="text-left p-4 font-medium text-muted-foreground w-[15%] hidden sm:table-cell">
Convênio
</th>
<th className="text-left p-4 font-medium text-muted-foreground w-[15%] hidden lg:table-cell">
Último atendimento
</th>
<th className="text-left p-4 font-medium text-muted-foreground w-[15%] hidden lg:table-cell">
Próximo atendimento
</th>
<th className="text-left p-4 font-medium text-muted-foreground w-[5%]">
Ações
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{currentPatients.length === 0 ? ( {filteredPatients.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="p-8 text-center text-muted-foreground"> <td colSpan={7} className="p-8 text-center text-gray-500">
{allPatients.length === 0 {patients.length === 0 ? "Nenhum paciente cadastrado" : "Nenhum paciente encontrado com os filtros aplicados"}
? "Nenhum paciente cadastrado"
: "Nenhum paciente encontrado com os filtros aplicados"}
</td> </td>
</tr> </tr>
) : ( ) : (
currentPatients.map((patient) => ( filteredPatients.map((patient) => (
<tr <tr key={patient.id} className="border-b border-gray-100 hover:bg-gray-50">
key={patient.id}
className="border-b hover:bg-muted"
>
<td className="p-4"> <td className="p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center"> <div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
<span className="text-primary font-medium text-sm"> <span className="text-gray-600 font-medium text-sm">{patient.nome?.charAt(0) || "?"}</span>
{patient.nome?.charAt(0) || "?"}
</span>
</div> </div>
<span className="font-medium text-gray-900">{patient.nome}</span>
<span className="font-medium">
{patient.nome}
{patient.vip && (
<span className="ml-2 px-2 py-0.5 text-xs font-semibold text-purple-500 bg-purple-500/10 rounded-full">
VIP
</span>
)}
</span>
</div> </div>
</td> </td>
<td className="p-4 text-muted-foreground hidden sm:table-cell"> <td className="p-4 text-gray-600">{patient.telefone}</td>
{patient.telefone} <td className="p-4 text-gray-600">{patient.cidade}</td>
</td> <td className="p-4 text-gray-600">{patient.estado}</td>
<td className="p-4 text-muted-foreground hidden md:table-cell">{`${patient.cidade} / ${patient.estado}`}</td> <td className="p-4 text-gray-600">{patient.ultimoAtendimento}</td>
<td className="p-4 text-muted-foreground hidden sm:table-cell"> <td className="p-4 text-gray-600">{patient.proximoAtendimento}</td>
{patient.convenio}
</td>
<td className="p-4 text-muted-foreground hidden lg:table-cell">
{patient.ultimoAtendimento}
</td>
<td className="p-4 text-muted-foreground hidden lg:table-cell">
{patient.proximoAtendimento}
</td>
<td className="p-4"> <td className="p-4">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0"> <div className="text-blue-600">Ações</div>
<span className="sr-only">Abrir menu</span>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem onClick={() => openDetailsDialog(String(patient.id))}>
onClick={() =>
openDetailsDialog(String(patient.id))
}
>
<Eye className="w-4 h-4 mr-2" /> <Eye className="w-4 h-4 mr-2" />
Ver detalhes Ver detalhes
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link href={`/secretary/pacientes/${patient.id}/editar`}>
href={`/secretary/pacientes/${patient.id}/editar`}
className="flex items-center w-full"
>
<Edit className="w-4 h-4 mr-2" /> <Edit className="w-4 h-4 mr-2" />
Editar Editar
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem>
<Calendar className="w-4 h-4 mr-2" /> <Calendar className="w-4 h-4 mr-2" />
Marcar consulta Marcar consulta
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem className="text-red-600" onClick={() => openDeleteDialog(String(patient.id))}>
className="text-destructive"
onClick={() =>
openDeleteDialog(String(patient.id))
}
>
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Excluir Excluir
</DropdownMenuItem> </DropdownMenuItem>
@ -359,127 +290,11 @@ export default function PacientesPage() {
</tbody> </tbody>
</table> </table>
)} )}
<div ref={observerRef} style={{ height: 1 }} />
{isFetching && <div className="p-4 text-center text-gray-500">Carregando mais pacientes...</div>}
</div> </div>
</div> </div>
{/* --- 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-card rounded-lg border shadow-md p-4 block md:hidden">
{error ? (
<div className="p-6 text-destructive">{`Erro ao carregar pacientes: ${error}`}</div>
) : loading ? (
<div className="p-6 text-center text-muted-foreground flex items-center justify-center">
<Loader2 className="w-6 h-6 mr-2 animate-spin text-primary" />{" "}
Carregando pacientes...
</div>
) : filteredPatients.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
{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-muted rounded-lg p-4 flex flex-col sm:flex-row justify-between items-start sm:items-center border"
>
<div className="flex-grow mb-2 sm:mb-0">
<div className="font-semibold text-lg flex items-center">
{patient.nome}
{patient.vip && (
<span className="ml-2 px-2 py-0.5 text-xs font-semibold text-purple-500 bg-purple-500/10 rounded-full">
VIP
</span>
)}
</div>
<div className="text-sm text-muted-foreground">
Telefone: {patient.telefone}
</div>
<div className="text-sm text-muted-foreground">
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>
<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>
<DropdownMenuItem>
<Calendar className="w-4 h-4 mr-2" />
Marcar consulta
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" 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">
<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-primary hover:bg-primary/90 text-primary-foreground" : "text-muted-foreground"}
>
{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 open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
@ -488,93 +303,81 @@ export default function PacientesPage() {
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel> <AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={() => patientToDelete && handleDeletePatient(patientToDelete)} className="bg-destructive hover:bg-destructive/90"> <AlertDialogAction onClick={() => patientToDelete && handleDeletePatient(patientToDelete)} className="bg-red-600 hover:bg-red-700">
Excluir Excluir
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<AlertDialog {/* Modal de detalhes do paciente */}
open={detailsDialogOpen} <AlertDialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
onOpenChange={setDetailsDialogOpen}
>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Detalhes do Paciente</AlertDialogTitle> <AlertDialogTitle>Detalhes do Paciente</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{patientDetails === null ? ( {patientDetails === null ? (
<div className="text-muted-foreground"> <div className="text-gray-500">Carregando...</div>
<Loader2 className="w-6 h-6 animate-spin mx-auto text-primary my-4" />
Carregando...
</div>
) : patientDetails?.error ? ( ) : patientDetails?.error ? (
<div className="text-destructive p-4">{patientDetails.error}</div> <div className="text-red-600">{patientDetails.error}</div>
) : ( ) : (
<div className="grid gap-4 py-4"> <div className="space-y-2 text-left">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <p>
<div> <strong>Nome:</strong> {patientDetails.full_name}
<p className="font-semibold">Nome Completo</p> </p>
<p>{patientDetails.full_name}</p> <p>
</div> <strong>CPF:</strong> {patientDetails.cpf}
<div> </p>
<p className="font-semibold">Email</p> <p>
<p>{patientDetails.email}</p> <strong>Email:</strong> {patientDetails.email}
</div> </p>
<div> <p>
<p className="font-semibold">Telefone</p> <strong>Telefone:</strong> {patientDetails.phone_mobile ?? patientDetails.phone1 ?? patientDetails.phone2 ?? "-"}
<p>{patientDetails.phone_mobile}</p> </p>
</div> <p>
<div> <strong>Nome social:</strong> {patientDetails.social_name ?? "-"}
<p className="font-semibold">Data de Nascimento</p> </p>
<p>{patientDetails.birth_date}</p> <p>
</div> <strong>Sexo:</strong> {patientDetails.sex ?? "-"}
<div> </p>
<p className="font-semibold">CPF</p> <p>
<p>{patientDetails.cpf}</p> <strong>Tipo sanguíneo:</strong> {patientDetails.blood_type ?? "-"}
</div> </p>
<div> <p>
<p className="font-semibold">Tipo Sanguíneo</p> <strong>Peso:</strong> {patientDetails.weight_kg ?? "-"}
<p>{patientDetails.blood_type}</p> {patientDetails.weight_kg ? "kg" : ""}
</div> </p>
<div> <p>
<p className="font-semibold">Peso (kg)</p> <strong>Altura:</strong> {patientDetails.height_m ?? "-"}
<p>{patientDetails.weight_kg}</p> {patientDetails.height_m ? "m" : ""}
</div> </p>
<div> <p>
<p className="font-semibold">Altura (m)</p> <strong>IMC:</strong> {patientDetails.bmi ?? "-"}
<p>{patientDetails.height_m}</p> </p>
</div> <p>
</div> <strong>Endereço:</strong> {patientDetails.street ?? "-"}
<div className="border-t pt-4 mt-4"> </p>
<h3 className="font-semibold mb-2">Endereço</h3> <p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <strong>Bairro:</strong> {patientDetails.neighborhood ?? "-"}
<div> </p>
<p className="font-semibold">Rua</p> <p>
<p>{`${patientDetails.street}, ${patientDetails.number}`}</p> <strong>Cidade:</strong> {patientDetails.city ?? "-"}
</div> </p>
<div> <p>
<p className="font-semibold">Complemento</p> <strong>Estado:</strong> {patientDetails.state ?? "-"}
<p>{patientDetails.complement}</p> </p>
</div> <p>
<div> <strong>CEP:</strong> {patientDetails.cep ?? "-"}
<p className="font-semibold">Bairro</p> </p>
<p>{patientDetails.neighborhood}</p> <p>
</div> <strong>Criado em:</strong> {patientDetails.created_at ?? "-"}
<div> </p>
<p className="font-semibold">Cidade</p> <p>
<p>{patientDetails.cidade}</p> <strong>Atualizado em:</strong> {patientDetails.updated_at ?? "-"}
</div> </p>
<div> <p>
<p className="font-semibold">Estado</p> <strong>Id:</strong> {patientDetails.id ?? "-"}
<p>{patientDetails.estado}</p> </p>
</div>
<div>
<p className="font-semibold">CEP</p>
<p>{patientDetails.cep}</p>
</div>
</div>
</div>
</div> </div>
)} )}
</AlertDialogDescription> </AlertDialogDescription>
@ -585,6 +388,6 @@ export default function PacientesPage() {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
</Sidebar> </SecretaryLayout>
); );
} }

View File

@ -1,11 +1,241 @@
import Sidebar from "@/components/Sidebar"; "use client";
import ScheduleForm from "@/components/schedule/schedule-form";
export default function SecretaryAppointments() { import type React from "react";
return ( import { useState, useEffect } from "react";
<Sidebar> import { useRouter } from "next/navigation";
<ScheduleForm /> import SecretaryLayout from "@/components/secretary-layout";
</Sidebar> 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";
import { patientsService } from "@/services/patientsApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs"; // Importar o serviço de médicos
import { toast } from "sonner";
const APPOINTMENTS_STORAGE_KEY = "clinic-appointments";
export default function ScheduleAppointment() {
const router = useRouter();
const [patients, setPatients] = useState<any[]>([]);
const [doctors, setDoctors] = useState<any[]>([]); // Estado para armazenar os médicos da API
const [selectedPatient, setSelectedPatient] = useState("");
const [selectedDoctor, setSelectedDoctor] = useState("");
const [selectedDate, setSelectedDate] = useState("");
const [selectedTime, setSelectedTime] = useState("");
const [notes, setNotes] = useState("");
useEffect(() => {
const fetchData = async () => {
try {
// Carrega pacientes e médicos em paralelo para melhor performance
const [patientList, doctorList] = await Promise.all([
patientsService.list(),
doctorsService.list()
]);
setPatients(patientList);
setDoctors(doctorList);
} catch (error) {
console.error("Falha ao buscar dados iniciais:", error);
toast.error("Não foi possível carregar os dados de pacientes e médicos.");
}
};
fetchData();
}, []);
const availableTimes = ["08:00", "08:30", "09:00", "09:30", "10:00", "10:30", "14:00", "14:30", "15:00", "15:30", "16:00", "16:30"];
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const patientDetails = patients.find((p) => String(p.id) === selectedPatient);
const doctorDetails = doctors.find((d) => String(d.id) === selectedDoctor);
if (!patientDetails || !doctorDetails) {
toast.error("Erro ao encontrar detalhes do paciente ou médico.");
return;
} }
const newAppointment = {
id: new Date().getTime(), // ID único simples
patientName: patientDetails.full_name,
doctor: doctorDetails.full_name, // Usar full_name para consistência
specialty: doctorDetails.specialty,
date: selectedDate,
time: selectedTime,
status: "agendada",
location: doctorDetails.location || "Consultório a definir", // Fallback
phone: doctorDetails.phone || "N/A", // Fallback
};
const storedAppointmentsRaw = localStorage.getItem(APPOINTMENTS_STORAGE_KEY);
const currentAppointments = storedAppointmentsRaw ? JSON.parse(storedAppointmentsRaw) : [];
const updatedAppointments = [...currentAppointments, newAppointment];
localStorage.setItem(APPOINTMENTS_STORAGE_KEY, JSON.stringify(updatedAppointments));
toast.success("Consulta agendada com sucesso!");
router.push("/secretary/appointments");
};
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">Escolha o paciente, médico, data e horário para a consulta</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>
{patients.length > 0 ? (
patients.map((patient) => (
<SelectItem key={patient.id} value={String(patient.id)}>
{patient.full_name}
</SelectItem>
))
) : (
<SelectItem value="loading" disabled>
Carregando pacientes...
</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>
{doctors.length > 0 ? (
doctors.map((doctor) => (
<SelectItem key={doctor.id} value={String(doctor.id)}>
{doctor.full_name} - {doctor.specialty}
</SelectItem>
))
) : (
<SelectItem value="loading" disabled>
Carregando médicos...
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<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]} />
</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="space-y-2">
<Label htmlFor="notes">Observações (opcional)</Label>
<Textarea id="notes" placeholder="Descreva brevemente o motivo da consulta ou observações importantes" value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} />
</div>
<Button type="submit" className="w-full" disabled={!selectedPatient || !selectedDoctor || !selectedDate || !selectedTime}>
Agendar Consulta
</Button>
</form>
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Calendar className="mr-2 h-5 w-5" />
Resumo
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{selectedPatient && (
<div className="flex items-start space-x-2">
<User className="h-4 w-4 text-gray-500 mt-1 flex-shrink-0" />
<div className="text-sm">
<span className="font-semibold text-gray-800">Paciente:</span>
<p className="text-gray-600">{patients.find((p) => String(p.id) === selectedPatient)?.full_name}</p>
</div>
</div>
)}
{selectedDoctor && (
<div className="flex items-start space-x-2">
<User className="h-4 w-4 text-gray-500 mt-1 flex-shrink-0" />
<div className="text-sm">
<span className="font-semibold text-gray-800">Médico:</span>
<p className="text-gray-600">{doctors.find((d) => String(d.id) === selectedDoctor)?.full_name}</p>
</div>
</div>
)}
{selectedDate && (
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-gray-500" />
<span className="text-sm">{new Date(selectedDate).toLocaleDateString("pt-BR", { timeZone: "UTC" })}</span>
</div>
)}
{selectedTime && (
<div className="flex items-center space-x-2">
<Clock className="h-4 w-4 text-gray-500" />
<span className="text-sm">{selectedTime}</span>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Informações Importantes</CardTitle>
</CardHeader>
<CardContent className="text-sm text-gray-600 space-y-2">
<p> Chegue com 15 minutos de antecedência</p>
<p> Traga documento com foto</p>
<p> Traga carteirinha do convênio</p>
<p> Traga exames anteriores, se houver</p>
</CardContent>
</Card>
</div>
</div>
</div>
</SecretaryLayout>
);
}

View File

@ -1,21 +1,34 @@
// ARQUIVO COMPLETO E CORRIGIDO PARA: components/LoginForm.tsx // Caminho: components/LoginForm.tsx
"use client"; "use client";
import type React from "react"; import type React from "react";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { login, api } from "@/services/api.mjs"; import Link from "next/link";
import Cookies from "js-cookie";
import { jwtDecode } from "jwt-decode";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { apikey } from "@/services/api.mjs";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Eye, EyeOff, Mail, Lock, Loader2 } from "lucide-react";
import { usersService } from "@/services/usersApi.mjs";
import { Eye, EyeOff, Mail, Lock, Loader2, UserCheck, Stethoscope, IdCard, Receipt } from "lucide-react";
interface LoginFormProps { interface LoginFormProps {
title: string;
description: string;
role: "secretary" | "doctor" | "patient" | "admin" | "manager" | "finance";
themeColor: "blue" | "green" | "orange";
redirectPath: string;
children?: React.ReactNode; children?: React.ReactNode;
} }
@ -24,206 +37,186 @@ interface FormState {
password: string; password: string;
} }
export function LoginForm({ children }: LoginFormProps) { // Supondo que o payload do seu token tenha esta estrutura
interface DecodedToken {
name: string;
email: string;
role: string;
exp: number;
// Adicione outros campos que seu token possa ter
}
const themeClasses = {
blue: {
iconBg: "bg-blue-100",
iconText: "text-blue-600",
button: "bg-blue-600 hover:bg-blue-700",
link: "text-blue-600 hover:text-blue-700",
focus: "focus:border-blue-500 focus:ring-blue-500",
},
green: {
iconBg: "bg-green-100",
iconText: "text-green-600",
button: "bg-green-600 hover:bg-green-700",
link: "text-green-600 hover:text-green-700",
focus: "focus:border-green-500 focus:ring-green-500",
},
orange: {
iconBg: "bg-orange-100",
iconText: "text-orange-600",
button: "bg-orange-600 hover:bg-orange-700",
link: "text-orange-600 hover:text-orange-700",
focus: "focus:border-orange-500 focus:ring-orange-500",
},
};
const roleIcons = {
secretary: UserCheck,
patient: Stethoscope,
doctor: Stethoscope,
admin: UserCheck,
manager: IdCard,
finance: Receipt,
};
export function LoginForm({ title, description, role, themeColor, redirectPath, children }: LoginFormProps) {
const [form, setForm] = useState<FormState>({ email: "", password: "" }); const [form, setForm] = useState<FormState>({ email: "", password: "" });
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const [userRoles, setUserRoles] = useState<string[]>([]); const currentTheme = themeClasses[themeColor];
const [authenticatedUser, setAuthenticatedUser] = useState<any>(null); const Icon = roleIcons[role];
/**
* --- NOVA FUNÇÃO ---
* Finaliza o login com o perfil de dashboard escolhido e redireciona.
*/
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([]);
return;
}
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 "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) {
toast({ title: `Entrando como ${selectedDashboardRole}...` });
router.push(redirectPath);
} else {
toast({
title: "Erro",
description: "Perfil selecionado inválido.",
variant: "destructive",
});
}
};
// ==================================================================
// AJUSTE PRINCIPAL NA LÓGICA DE LOGIN
// ==================================================================
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
localStorage.removeItem("token");
localStorage.removeItem("user_info"); const LOGIN_URL = "https://yuanqfswhberkoevtmfr.supabase.co/auth/v1/token?grant_type=password";
const API_KEY = apikey;
if (!API_KEY) {
toast({
title: "Erro de Configuração",
description: "A chave da API não foi encontrada.",
});
setIsLoading(false);
return;
}
try { try {
const authData = await login(form.email, form.password); const response = await fetch(LOGIN_URL, {
const user = authData.user; method: "POST",
if (!user || !user.id) { headers: {
throw new Error("Resposta de autenticação inválida."); "Content-Type": "application/json",
apikey: API_KEY,
},
body: JSON.stringify({ email: form.email, password: form.password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error_description || "Credenciais inválidas. Tente novamente.");
} }
const rolesData = await api.get( const accessToken = data.access_token;
`/rest/v1/user_roles?user_id=eq.${user.id}&select=role` const user = data.user;
);
const me = await usersService.getMeSimple(); /* =================== Verificação de Role Desativada Temporariamente =================== */
console.log(me.roles); // if (user.user_metadata.role !== role) {
// toast({ title: "Acesso Negado", ... });
// return;
// }
/* ===================================================================================== */
if (!me.roles || me.roles.length === 0) { Cookies.set("access_token", accessToken, { expires: 1, secure: true });
throw new Error( localStorage.setItem("user_info", JSON.stringify(user));
"Nenhum perfil de acesso foi encontrado para este usuário."
);
}
handleRoleSelection(me.roles[0], user); toast({
title: "Login bem-sucedido!",
description: `Bem-vindo(a), ${user.user_metadata.full_name || "usuário"}! Redirecionando...`,
});
router.push(redirectPath);
} catch (error) { } catch (error) {
localStorage.removeItem("token");
localStorage.removeItem("user_info");
toast({ toast({
title: "Erro no Login", title: "Erro no Login",
description: description: error instanceof Error ? error.message : "Ocorreu um erro inesperado.",
error instanceof Error
? error.message
: "Ocorreu um erro inesperado.",
variant: "destructive",
}); });
} finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
// Estado para guardar os botões de seleção de perfil // O JSX do return permanece exatamente o mesmo, preservando seus ajustes.
const [roleSelectionUI, setRoleSelectionUI] =
useState<React.ReactNode | null>(null);
return ( return (
<Card className="w-full bg-transparent border-0 shadow-none"> <Card className="w-full max-w-md shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardContent className="p-0"> <CardHeader className="text-center space-y-4 pb-8">
{!roleSelectionUI ? ( <div className={cn("mx-auto w-16 h-16 rounded-full flex items-center justify-center", currentTheme.iconBg)}>
<Icon className={cn("w-8 h-8", currentTheme.iconText)} />
</div>
<div>
<CardTitle className="text-2xl font-bold text-gray-900">{title}</CardTitle>
<CardDescription className="text-gray-600 mt-2">{description}</CardDescription>
</div>
</CardHeader>
<CardContent className="px-8 pb-8">
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Inputs e Botão */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">E-mail</Label> <Label htmlFor="email">E-mail</Label>
<div className="relative"> <div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-5 h-5" /> <Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
<Input <Input id="email" type="email" placeholder="seu.email@clinica.com" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={cn("pl-11 h-12 border-slate-200", currentTheme.focus)} required disabled={isLoading} />
id="email"
type="email"
placeholder="seu.email@exemplo.com"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="pl-10 h-11 focus-visible:ring-blue-600 focus-visible:ring-2"
required
disabled={isLoading}
autoComplete="username"
/>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Senha</Label> <Label htmlFor="password">Senha</Label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-5 h-5" /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
<Input <Input id="password" type={showPassword ? "text" : "password"} placeholder="Digite sua senha" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className={cn("pl-11 pr-12 h-12 border-slate-200", currentTheme.focus)} required disabled={isLoading} />
id="password" <button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-2 top-1/2 -translate-y-1/2 h-8 w-8 p-0 text-gray-400 hover:text-gray-600" disabled={isLoading}>
type={showPassword ? "text" : "password"} {showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
placeholder="Digite sua senha"
value={form.password}
onChange={(e) =>
setForm({ ...form, password: e.target.value })
}
className="pl-10 pr-12 h-11 focus-visible:ring-blue-600 focus-visible:ring-2"
required
disabled={isLoading}
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
disabled={isLoading}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button> </button>
</div> </div>
</div> </div>
<Button <Button type="submit" className={cn("w-full h-12 text-base font-semibold", currentTheme.button)} disabled={isLoading}>
type="submit" {isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : "Entrar"}
className="w-full h-11 bg-blue-600 hover:bg-blue-700 text-white"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
"Entrar"
)}
</Button> </Button>
</form> </form>
) : ( {/* Conteúdo Extra (children) */}
<div className="space-y-4 animate-in fade-in-50"> <div className="mt-8">
<h3 className="text-lg font-medium text-center text-foreground"> {children ? (
Você tem múltiplos perfis <div className="space-y-4">
</h3> <div className="relative">
<p className="text-sm text-muted-foreground text-center"> <div className="absolute inset-0 flex items-center">
Selecione com qual perfil deseja entrar: <div className="w-full border-t border-slate-200"></div>
</p> </div>
<div className="flex flex-col space-y-3 pt-2"> <div className="relative flex justify-center text-sm">
{userRoles.map((role) => ( <span className="px-4 bg-white text-slate-500">Novo por aqui?</span>
<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>
</div> </div>
)}
{children} {children}
</div>
) : (
<>
<div className="relative">
<Separator className="my-6" />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white px-3 text-sm text-gray-500">ou</span>
</div>
<div className="text-center">
<Link href="/" className={cn("text-sm font-medium hover:underline", currentTheme.link)}>
Voltar à página inicial
</Link>
</div>
</>
)}
</div>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -1,403 +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";
import { api } from "@/services/api.mjs";
import { usersService } from "@/services/usersApi.mjs"; // Importando usersService
import { useAccessibility } from "@/app/context/AccessibilityContext";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
LogOut,
ChevronLeft,
ChevronRight,
Home,
CalendarCheck2,
ClipboardPlus,
CalendarClock,
Users,
SquareUser,
ClipboardList,
Stethoscope,
} from "lucide-react";
import SidebarUserSection from "@/components/ui/userToolTip";
interface UserData {
id: string;
email: string;
app_metadata: {
user_role: string;
};
user_metadata: {
cpf: string;
email_verified: boolean;
full_name: string;
phone_mobile: string;
role: string;
avatar_url?: string;
};
identities: {
identity_id: string;
id: string;
user_id: string;
provider: string;
}[];
is_anonymous: boolean;
}
interface MenuItem {
href: string;
icon: React.ElementType;
label: string;
}
interface SidebarProps {
children: React.ReactNode;
}
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 [avatarFullUrl, setAvatarFullUrl] = useState<string | undefined>(undefined);
const router = useRouter();
const pathname = usePathname();
// Função auxiliar para construir URL
const buildAvatarUrl = (path: string) => {
if (!path) return undefined;
const baseUrl = "https://yuanqfswhberkoevtmfr.supabase.co";
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
const separator = cleanPath.includes('?') ? '&' : '?';
return `${baseUrl}/storage/v1/object/avatars/${cleanPath}${separator}t=${new Date().getTime()}`;
};
const { theme, contrast } = useAccessibility();
useEffect(() => {
const userInfoString = localStorage.getItem("user_info");
const token = localStorage.getItem("token");
if (userInfoString && token) {
try {
const userInfo = JSON.parse(userInfoString);
// 1. Tenta pegar o avatar do cache local
let rawAvatarPath =
userInfo.profile?.avatar_url ||
userInfo.user_metadata?.avatar_url ||
userInfo.app_metadata?.avatar_url ||
"";
// Configura estado inicial com o que tem no cache
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 || userInfo.profile?.full_name || "Usuário",
phone_mobile: userInfo.user_metadata?.phone_mobile ?? "",
role: userInfo.user_metadata?.role ?? "",
avatar_url: rawAvatarPath,
},
identities: userInfo.identities ?? [],
is_anonymous: userInfo.is_anonymous ?? false,
});
setRole(userInfo.user_metadata?.role);
if (rawAvatarPath) {
setAvatarFullUrl(buildAvatarUrl(rawAvatarPath));
}
// 2. AUTO-REPARO: Se não tiver avatar ou profile no cache, busca na API e atualiza
if (!rawAvatarPath || !userInfo.profile) {
console.log("[Sidebar] Cache incompleto. Buscando dados frescos...");
usersService.getMe().then((freshData) => {
if (freshData && freshData.profile) {
const freshAvatar = freshData.profile.avatar_url;
// Atualiza o objeto local
const updatedUserInfo = {
...userInfo,
profile: freshData.profile, // Injeta o profile completo
user_metadata: {
...userInfo.user_metadata,
avatar_url: freshAvatar || userInfo.user_metadata.avatar_url
}
};
// Salva no localStorage para a próxima vez
localStorage.setItem("user_info", JSON.stringify(updatedUserInfo));
console.log("[Sidebar] LocalStorage sincronizado com sucesso.");
// Atualiza visualmente se achou um avatar novo
if (freshAvatar && freshAvatar !== rawAvatarPath) {
setAvatarFullUrl(buildAvatarUrl(freshAvatar));
// Atualiza o userData também para refletir no tooltip
setUserData(prev => prev ? ({
...prev,
user_metadata: {
...prev.user_metadata,
avatar_url: freshAvatar
}
}) : undefined);
}
}
}).catch(err => console.error("[Sidebar] Falha no auto-reparo:", err));
}
} catch (e) {
console.error("Erro ao processar dados do usuário na Sidebar:", e);
}
} else {
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);
const confirmLogout = async () => {
try {
await api.logout();
} catch (error) {
console.error("Erro ao fazer logout", error);
} finally {
localStorage.removeItem("user_info");
localStorage.removeItem("token");
Cookies.remove("access_token");
setShowLogoutDialog(false);
router.push("/");
}
};
const cancelLogout = () => setShowLogoutDialog(false);
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" },
];
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: "/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: "/secretary/appointments", icon: CalendarCheck2, label: "Consultas" },
{ href: "/manager/disponibilidade", icon: ClipboardList, label: "Disponibilidade" },
];
switch (role) {
case "gestor":
case "admin":
return managerItems;
case "medico":
return doctorItems;
case "secretaria":
return secretaryItems;
case "paciente":
default:
return patientItems;
}
};
const menuItems = SetMenuItems(role);
const isDefaultMode = theme === "light" && contrast === "normal";
if (!userData) {
return (
<div className="flex h-screen w-full items-center justify-center">
Carregando...
</div>
);
}
return (
<div className="min-h-screen bg-background flex">
<div
className={`fixed top-0 h-screen flex flex-col z-30 transition-all duration-300
${sidebarCollapsed ? "w-16" : "w-64"}
${isDefaultMode ? "bg-[#123965] text-white" : "bg-sidebar text-sidebar-foreground"}`}
>
{/* TOPO */}
<div className={`p-4 border-b ${isDefaultMode ? "border-white/10" : "border-sidebar-border"} flex items-center justify-between`}>
{!sidebarCollapsed && (
<div className="flex items-center gap-2">
<div className="bg-background p-1 rounded-lg">
<img
src="/Logo MedConnect.png"
alt="Logo MedConnect"
className="w-12 h-12 object-contain"
/>
</div>
<span className="font-semibold text-lg">
MedConnect
</span>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className={`p-1 ${isDefaultMode ? "text-white hover:bg-white/10" : "hover:bg-sidebar-accent"} cursor-pointer`}
>
{sidebarCollapsed ? (
<ChevronRight className="w-5 h-5" />
) : (
<ChevronLeft className="w-5 h-5" />
)}
</Button>
</div>
{/* MENU */}
<nav className="flex-1 px-3 py-6 overflow-y-auto flex flex-col gap-2">
{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 transition-colors
${
isActive
? `${isDefaultMode ? "bg-white/20 text-white font-semibold" : "bg-sidebar-primary text-sidebar-primary-foreground font-semibold"}`
: `${isDefaultMode ? "text-white/80 hover:bg-white/10 hover:text-white" : "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"}`
}
`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && (
<span className="font-medium">{item.label}</span>
)}
</div>
</Link>
);
})}
</nav>
{/* PERFIL ORIGINAL + NOME BRANCO - CORREÇÃO DE ALINHAMENTO AQUI */}
<div
className={`
mt-auto p-3 border-t
${isDefaultMode ? "border-white/10" : "border-sidebar-border"}
flex flex-col
${sidebarCollapsed ? "items-center justify-center" : "items-stretch"}
`}
>
<SidebarUserSection
userData={userData}
sidebarCollapsed={sidebarCollapsed}
handleLogout={handleLogout}
isActive={role !== "paciente"}
avatarUrl={avatarFullUrl}
/>
</div>
</div>
<div
className={`flex-1 flex flex-col transition-all duration-300 ${
sidebarCollapsed ? "ml-16" : "ml-64"
}`}
>
<main className="flex-1 p-4 md:p-6">{children}</main>
<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.
</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>
</div>
);
}

View File

@ -0,0 +1,355 @@
"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"; // <-- 1. IMPORTAÇÃO ADICIONADA
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;
}
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();
// ==================================================================
// 2. BLOCO DE SEGURANÇA CORRIGIDO
// ==================================================================
useEffect(() => {
const userInfoString = localStorage.getItem("user_info");
const token = Cookies.get("access_token");
if (userInfoString && token) {
const userInfo = JSON.parse(userInfoString);
// 3. "TRADUZIMOS" os dados da API para o formato que o layout espera
setDoctorData({
id: userInfo.id || "",
name: userInfo.user_metadata?.full_name || "Doutor(a)",
email: userInfo.email || "",
specialty: userInfo.user_metadata?.specialty || "Especialidade",
// Campos que não vêm do login, definidos como vazios para não quebrar
phone: userInfo.phone || "",
cpf: "",
crm: "",
department: "",
permissions: {},
});
} else {
// Se faltar o token ou os dados, volta para o login
router.push("/doctor/login");
}
}, [router]);
useEffect(() => {
const handleResize = () => setWindowWidth(window.innerWidth);
handleResize(); // inicializa com a largura atual
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
useEffect(() => {
if (isMobile) {
setSidebarCollapsed(true);
} else {
setSidebarCollapsed(false);
}
}, [isMobile]);
const handleLogout = () => {
setShowLogoutDialog(true);
};
const confirmLogout = () => {
localStorage.removeItem("doctorData");
setShowLogoutDialog(false);
router.push("/");
};
const cancelLogout = () => {
setShowLogoutDialog(false);
};
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
const menuItems = [
{
href: "#",
icon: Home,
label: "Dashboard",
// Botão para o dashboard do médico
},
{
href: "/doctor/medicos/consultas",
icon: Calendar,
label: "Consultas",
// Botão para página de consultas marcadas do médico atual
},
{
href: "#",
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
},
];
if (!doctorData) {
return <div>Carregando...</div>;
}
return (
<div className="min-h-screen bg-gray-50 flex">
{/* 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">MidConnecta</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">MedConnect</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">
{/* Se a sidebar estiver recolhida, o avatar e o texto do usuário também devem ser condensados ou ocultados */}
{!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"> {/* Centraliza o avatar quando recolhido */}
<AvatarImage src="/placeholder.svg?height=40&width=40" />
<AvatarFallback>
{doctorData.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
)}
</div>
{/* Novo botão de sair, usando a mesma estrutura dos itens de menu */}
<div
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors text-gray-600 hover:bg-gray-50 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>
{/* Sidebar para mobile (apresentado como um menu overlay) */}
{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}> {/* Fechar menu ao clicar */}
<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" />
<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(); }}> {/* Fechar menu ao deslogar */}
<LogOut className="mr-2 h-4 w-4" />
Sair
</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-white border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input placeholder="Buscar paciente" className="pl-10 bg-gray-50 border-gray-200" />
</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-red-500 text-white 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,286 @@
"use client";
import type React from "react";
import { useState, useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
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 data = localStorage.getItem("financierData");
if (data) {
setFinancierData(JSON.parse(data));
} else {
router.push("/finance/login");
}
}, [router]);
// 🔥 Responsividade automática da sidebar
useEffect(() => {
const handleResize = () => {
// Ajuste o breakpoint conforme necessário. 1024px (lg) ou 768px (md) são comuns.
if (window.innerWidth < 1024) {
setSidebarCollapsed(true);
} else {
setSidebarCollapsed(false);
}
};
handleResize(); // executa na primeira carga
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const handleLogout = () => {
setShowLogoutDialog(true);
};
const confirmLogout = () => {
localStorage.removeItem("financierData");
setShowLogoutDialog(false);
router.push("/");
};
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>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">
MidConnecta
</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>
{/* Footer user info */}
<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>
{/* 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 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

@ -0,0 +1,227 @@
"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">MedConnect</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

@ -0,0 +1,279 @@
"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"; // <-- 1. IMPORTAÇÃO ADICIONADA
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,
User,
LogOut,
ChevronLeft,
ChevronRight,
Home,
} from "lucide-react";
interface ManagerData {
id: string;
name: string;
email: string;
phone: string;
cpf: string;
department: string;
permissions: object;
}
interface ManagerLayoutProps { // Corrigi o nome da prop aqui
children: React.ReactNode;
}
export default function ManagerLayout({ children }: ManagerLayoutProps) {
const [managerData, setManagerData] = useState<ManagerData | null>(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const router = useRouter();
const pathname = usePathname();
// ==================================================================
// 2. BLOCO DE SEGURANÇA CORRIGIDO
// ==================================================================
useEffect(() => {
const userInfoString = localStorage.getItem("user_info");
const token = Cookies.get("access_token");
if (userInfoString && token) {
const userInfo = JSON.parse(userInfoString);
// 3. "TRADUZIMOS" os dados da API para o formato que o layout espera
setManagerData({
id: userInfo.id || "",
name: userInfo.user_metadata?.full_name || "Gestor(a)",
email: userInfo.email || "",
department: userInfo.user_metadata?.role || "Gestão",
// Campos que não vêm do login, definidos como vazios para não quebrar
phone: userInfo.phone || "",
cpf: "",
permissions: {},
});
} else {
// Se faltar o token ou os dados, volta para o login
router.push("/manager/login");
}
}, [router]);
// 🔥 Responsividade automática da sidebar
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 1024) {
setSidebarCollapsed(true); // colapsa em telas pequenas (lg breakpoint ~ 1024px)
} else {
setSidebarCollapsed(false); // expande em desktop
}
};
handleResize(); // roda na primeira carga
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const handleLogout = () => setShowLogoutDialog(true);
const confirmLogout = () => {
localStorage.removeItem("managerData");
setShowLogoutDialog(false);
router.push("/");
};
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: "#", icon: Calendar, label: "Configurações" },
];
if (!managerData) {
return <div>Carregando...</div>;
}
return (
<div className="min-h-screen bg-gray-50 flex">
{/* Sidebar */}
<div
className={`bg-white border-r border-gray-200 transition-all duration-300 fixed top-0 h-screen flex flex-col z-30
${sidebarCollapsed ? "w-16" : "w-64"}`}
>
{/* Logo + collapse button */}
<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">
MidConnecta
</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>
{/* Menu Items */}
<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>
{/* Perfil no rodapé */}
<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>
{/* 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>
{/* Conteúdo principal */}
<div
className={`flex-1 flex flex-col transition-all duration-300 w-full
${sidebarCollapsed ? "ml-16" : "ml-64"}`}
>
{/* Header */}
<header className="bg-white border-b border-gray-200 px-4 md:px-6 py-4 flex items-center justify-between">
{/* Search */}
<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-gray-400 w-4 h-4" />
<Input
placeholder="Buscar paciente"
className="pl-10 bg-gray-50 border-gray-200"
/>
</div>
</div>
{/* Notifications */}
<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>
{/* Page Content */}
<main className="flex-1 p-4 md: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,279 @@
"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 Cookies from "js-cookie"
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 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()
// 🔹 Ajuste automático no resize
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 1024) {
setSidebarCollapsed(true) // colapsa no mobile
} else {
setSidebarCollapsed(false) // expande no desktop
}
}
handleResize()
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [])
useEffect(() => {
// 1. Procuramos pela chave correta: 'user_info'
const userInfoString = localStorage.getItem("user_info");
// 2. Para mais segurança, verificamos também se o token de acesso existe no cookie
const token = Cookies.get("access_token");
if (userInfoString && token) {
const userInfo = JSON.parse(userInfoString);
// 3. Adaptamos os dados para a estrutura que seu layout espera (PatientData)
// Usamos os dados do objeto 'user' que a API do Supabase nos deu
setPatientData({
name: userInfo.user_metadata?.full_name || "Paciente",
email: userInfo.email || "",
// Os campos abaixo não vêm do login, então os deixamos vazios por enquanto
phone: userInfo.phone || "",
cpf: "",
birthDate: "",
address: "",
});
} else {
// Se as informações do usuário ou o token não forem encontrados, mandamos para o login.
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"
} 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">MedConnect</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 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,618 +0,0 @@
"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, CalendarDays, Stethoscope, Check, ChevronsUpDown } from "lucide-react";
import { smsService } from "@/services/Sms.mjs";
import { toast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
// --- Importações do Combobox ---
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export default function ScheduleForm() {
// --- ESTADOS ---
const [role, setRole] = useState<string>("paciente");
const [userId, setUserId] = useState<string | null>(null);
// Estados de Paciente
const [patients, setPatients] = useState<any[]>([]);
const [selectedPatient, setSelectedPatient] = useState("");
const [openPatientCombobox, setOpenPatientCombobox] = useState(false);
// Estados de Médico
const [doctors, setDoctors] = useState<any[]>([]);
const [selectedDoctor, setSelectedDoctor] = useState("");
const [openDoctorCombobox, setOpenDoctorCombobox] = useState(false);
// Estados de Agendamento
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);
// Configurações
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);
// --- HELPER FUNCTIONS ---
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));
// --- EFFECTS ---
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);
}
})();
}, []);
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]);
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({});
}
};
useEffect(() => {
if (selectedDoctor) {
loadDoctorDisponibilidades(selectedDoctor);
} else {
setDisponibilidades([]);
setAvailabilityCounts({});
}
setSelectedDate("");
setSelectedTime("");
setAvailableTimes([]);
}, [selectedDoctor, loadDoctorDisponibilidades]);
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]);
// --- SUBMIT ---
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,
};
await appointmentsService.create(body);
const dateFormatted = selectedDate.split("-").reverse().join("/");
toast({
title: "Consulta agendada!",
description: `Consulta marcada para ${dateFormatted} às ${selectedTime}.`,
});
setSelectedDoctor("");
setSelectedDate("");
setSelectedTime("");
setNotes("");
setSelectedPatient("");
} catch (err) {
console.error("❌ Erro ao agendar consulta:", err);
toast({ title: "Erro", description: "Falha ao agendar consulta." });
}
};
// --- TOOLTIP ---
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="w-full min-h-screen p-4 md:p-6 lg:p-8">
<div className="max-w-7xl mx-auto space-y-6">
<div className="space-y-1">
<h1 className="text-2xl md:text-3xl font-bold text-foreground">
Agendar Consulta
</h1>
<p className="text-muted-foreground text-sm md:text-base">
Preencha os dados abaixo para marcar seu horário.
</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-[1fr_350px] gap-6">
{/* == ESQUERDA == */}
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-start">
{/* BLOCO 1: SELEÇÃO */}
<Card className="h-full border shadow-sm">
<CardHeader className="pb-3 border-b bg-muted/20">
<CardTitle className="text-base flex items-center gap-2">
<Stethoscope className="w-4 h-4 text-primary" />
Dados da Consulta
</CardTitle>
</CardHeader>
<CardContent className="space-y-5 pt-5">
{/* COMBOBOX DE PACIENTE */}
{["secretaria", "gestor", "admin"].includes(role) && (
<div className="space-y-2">
<Label className="text-sm font-medium">Selecione o Paciente</Label>
<Popover open={openPatientCombobox} onOpenChange={setOpenPatientCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openPatientCombobox}
className="w-full justify-between"
>
{selectedPatient
? patients.find((p) => p.id === selectedPatient)?.full_name
: "Buscar paciente..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
{/* AQUI: align="start" e w igual ao trigger garantem que não invada a lateral */}
<PopoverContent
className="w-[--radix-popover-trigger-width] min-w-0 p-0"
align="start"
side="bottom"
>
<Command>
<CommandInput placeholder="Procurar paciente..." />
{/* AQUI: max-h-[130px] no mobile deixa a lista bem compacta */}
<CommandList className="max-h-[130px] md:max-h-[300px] overflow-y-auto">
<CommandEmpty>Nenhum paciente encontrado.</CommandEmpty>
<CommandGroup>
{patients.map((p) => (
<CommandItem
key={p.id}
value={p.full_name}
onSelect={() => {
setSelectedPatient(p.id === selectedPatient ? "" : p.id);
setOpenPatientCombobox(false);
}}
className="text-xs md:text-sm py-1.5 md:py-2"
>
<Check
className={cn(
"mr-2 h-3 w-3 md:h-4 md:w-4",
selectedPatient === p.id ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{p.full_name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* COMBOBOX DE MÉDICO */}
<div className="space-y-2">
<Label className="text-sm font-medium">Selecione o Médico</Label>
<Popover open={openDoctorCombobox} onOpenChange={setOpenDoctorCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openDoctorCombobox}
className="w-full justify-between"
disabled={loadingDoctors}
>
{loadingDoctors ? "Carregando..." : (
selectedDoctor
? doctors.find((doctor) => doctor.id === selectedDoctor)?.full_name
: "Buscar médico..."
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
{/* AQUI: Configurações de largura e posicionamento corrigidos */}
<PopoverContent
className="w-[--radix-popover-trigger-width] min-w-0 p-0"
align="start"
side="bottom"
>
<Command>
<CommandInput placeholder="Procurar médico..." />
{/* AQUI: Altura reduzida no mobile */}
<CommandList className="max-h-[130px] md:max-h-[300px] overflow-y-auto">
<CommandEmpty>Nenhum médico encontrado.</CommandEmpty>
<CommandGroup>
{doctors.map((doctor) => (
<CommandItem
key={doctor.id}
value={doctor.full_name}
onSelect={() => {
setSelectedDoctor(doctor.id === selectedDoctor ? "" : doctor.id);
setOpenDoctorCombobox(false);
}}
className="text-xs md:text-sm py-1.5 md:py-2"
>
<Check
className={cn(
"mr-2 h-3 w-3 md:h-4 md:w-4",
selectedDoctor === doctor.id ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col truncate">
<span className="truncate font-medium">{doctor.full_name}</span>
<span className="text-[10px] md:text-xs text-muted-foreground truncate">{doctor.specialty}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground mt-1">
Digite para filtrar por nome.
</p>
</div>
</CardContent>
</Card>
{/* BLOCO 2: CALENDÁRIO */}
<Card className="h-full border shadow-sm flex flex-col">
<CardHeader className="pb-3 border-b bg-muted/20">
<CardTitle className="text-base flex items-center gap-2">
<CalendarDays className="w-4 h-4 text-primary" />
Data Disponível
</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex items-center justify-center pt-4 pb-4">
<div ref={calendarRef} className="flex justify-center w-full overflow-x-auto">
<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);
}}
className="rounded-md border p-3 w-fit"
/>
</div>
</CardContent>
</Card>
</div>
{/* BLOCO 3: OBSERVAÇÕES */}
<Card className="border shadow-sm">
<CardHeader className="pb-3 border-b bg-muted/20">
<CardTitle className="text-base flex items-center gap-2">
<StickyNote className="w-4 h-4 text-primary" />
Observações (Opcional)
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<Textarea
placeholder="Instruções especiais, sintomas ou motivos da consulta..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="resize-none w-full"
/>
</CardContent>
</Card>
</div>
{/* == DIREITA == */}
<div className="w-full">
<div className="xl:sticky xl:top-6">
<Card className="border-2 border-primary shadow-lg h-full flex flex-col">
<CardHeader className="pb-4 border-b border-primary/20 bg-primary/5">
<CardTitle className="text-primary flex items-center gap-2 text-lg">
<User className="h-5 w-5" />
Resumo da Consulta
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-5 flex-1">
<div className="grid grid-cols-2 gap-4 xl:grid-cols-1">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Médico</p>
<p className="text-sm font-semibold text-foreground break-words">
{selectedDoctor ? doctors.find((d) => d.id === selectedDoctor)?.full_name : "—"}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Data</p>
<p className="text-sm font-semibold text-foreground">
{selectedDate ? format(new Date(selectedDate + "T12:00:00"), "dd/MM/yyyy") : "—"}
</p>
</div>
</div>
<div className="space-y-2 pt-2">
<Label htmlFor="time-select" className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Horário da Sessão
</Label>
<Select
value={selectedTime}
onValueChange={setSelectedTime}
disabled={loadingSlots || availableTimes.length === 0}
>
<SelectTrigger id="time-select" className="bg-white w-full border-primary/30 focus:ring-primary">
<SelectValue
placeholder={
loadingSlots ? "Carregando..." : availableTimes.length === 0 ? "Selecione uma data" : "Escolha o horário"
}
/>
</SelectTrigger>
<SelectContent>
{availableTimes.map((h) => (
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="pt-4 border-t border-dashed space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tipo:</span>
<span className="font-medium capitalize">{tipoConsulta}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Duração estimada:</span>
<span className="font-medium">{duracao} min</span>
</div>
</div>
<div className="pt-4 space-y-3 mt-auto">
<Button
type="submit"
onClick={handleSubmit}
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground font-semibold shadow-md py-6 h-auto text-base transition-all"
disabled={!selectedDoctor || !selectedDate || !selectedTime}
>
Confirmar Agendamento
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setSelectedDoctor("");
setSelectedDate("");
setSelectedTime("");
setNotes("");
setSelectedPatient("");
}}
className="w-full text-muted-foreground hover:text-destructive"
>
Limpar Formulário
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
{tooltip && (
<div
style={{
position: "absolute",
left: tooltip.x,
top: tooltip.y,
zIndex: 60,
background: "rgba(0,0,0,0.85)",
color: "white",
padding: "6px 10px",
borderRadius: 6,
fontSize: 12,
fontWeight: 500,
pointerEvents: "none",
}}
>
{tooltip.text}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,251 @@
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { useRouter, usePathname } from "next/navigation"
import Link from "next/link"
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 [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [showLogoutDialog, setShowLogoutDialog] = useState(false)
const router = useRouter()
const pathname = usePathname()
// 🔹 Colapsar no mobile e expandir no desktop automaticamente
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)
const confirmLogout = () => {
setShowLogoutDialog(false)
router.push("/")
}
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" },
]
const secretaryData: SecretaryData = {
id: "1",
name: "Secretária Exemplo",
email: "secretaria@hospital.com",
phone: "999999999",
cpf: "000.000.000-00",
employeeId: "12345",
department: "Atendimento",
permissions: {},
}
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">MedConnect</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>
{/* 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 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">
{/* Este botão no header parece ter sido uma cópia do botão "Sair" da sidebar.
Removi a lógica de sidebarCollapsed aqui, pois o header é independente.
Se a intenção era ter um botão de logout no header, ele não deve ser afetado pela sidebar.
Ajustei para ser um botão de sino de notificação, como nos exemplos anteriores,
que você tem o ícone Bell importado e uma badge para notificação.
Se você quer um botão de LogOut aqui, por favor, me avise!
*/}
<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,105 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { AvailabilityService } from "@/services/availabilityApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs";
type Availability = {
id: string;
doctor_id: string;
weekday: string;
start_time: string;
end_time: string;
slot_minutes: number;
appointment_type: string;
active: boolean;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string | null;
};
interface WeeklyScheduleProps {
doctorId?: string;
}
export default function WeeklyScheduleCard({ doctorId }: WeeklyScheduleProps) {
const [schedule, setSchedule] = useState<Record<string, { start: string; end: string }[]>>({});
const [loading, setLoading] = useState(true);
const weekdaysPT: Record<string, string> = {
sunday: "Domingo",
monday: "Segunda",
tuesday: "Terça",
wednesday: "Quarta",
thursday: "Quinta",
friday: "Sexta",
saturday: "Sábado",
};
const formatTime = (time?: string | null) => time?.split(":")?.slice(0, 2).join(":") ?? "";
function formatAvailability(data: Availability[]) {
const grouped = data.reduce((acc: any, item) => {
const { weekday, start_time, end_time } = item;
if (!acc[weekday]) acc[weekday] = [];
acc[weekday].push({ start: start_time, end: end_time });
return acc;
}, {});
return grouped;
}
useEffect(() => {
const fetchSchedule = async () => {
try {
const availabilityList = await AvailabilityService.list();
const filtered = availabilityList.filter((a: Availability) => a.doctor_id == doctorId);
const formatted = formatAvailability(filtered);
setSchedule(formatted);
} catch (err) {
console.error("Erro ao carregar horários:", err);
} finally {
setLoading(false);
}
};
fetchSchedule();
}, []);
return (
<div className="space-y-4 grid md:grid-cols-7 gap-2">
{loading ? (
<p className="text-sm text-muted-foreground col-span-7 text-center">Carregando...</p>
) : (
["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"].map((day) => {
const times = schedule[day] || [];
return (
<div key={day} className="space-y-4">
<div className="flex flex-col items-center justify-between p-3 bg-primary/10 rounded-lg">
<p className="font-medium capitalize text-foreground">{weekdaysPT[day]}</p>
<div className="text-center">
{times.length > 0 ? (
times.map((t, i) => (
<p key={i} className="text-sm text-muted-foreground">
{formatTime(t.start)} <br /> {formatTime(t.end)}
</p>
))
) : (
<p className="text-sm text-muted-foreground italic">Sem horário</p>
)}
</div>
</div>
</div>
);
})
)}
</div>
);
}

View File

@ -1,130 +0,0 @@
'use client'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useEffect, useState } from "react";
import { start } from "repl";
import { appointmentsService } from "@/services/appointmentsApi.mjs";
type Availability = {
id: string;
doctor_id: string;
weekday: string;
start_time: string;
end_time: string;
slot_minutes: number;
appointment_type: string;
active: boolean;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string | null;
};
interface AvailabilityEditModalProps {
isOpen: boolean;
availability: Availability | null;
onClose: () => void;
onSubmit: (formData: any) => void;
}
export function AvailabilityEditModal({ availability, isOpen, onClose, onSubmit }: AvailabilityEditModalProps) {
const [modalidadeConsulta, setModalidadeConsulta] = useState<string>("");
const [form, setForm] = useState({ start_time: "", end_time: "", slot_minutes: "", appointment_type: "", id:availability?.id});
// Mapa de tradução
const weekdaysPT: Record<string, string> = {
sunday: "Domingo",
monday: "Segunda-Feira",
tuesday: "Terça-Feira",
wednesday: "Quarta-Feira",
thursday: "Quinta-Feira",
friday: "Sexta-Feira",
saturday: "Sábado",
};
const handleInputChange = (field: string, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const handleFormSubmit = () => {
onSubmit(form);
};
useEffect(() => {
if (availability) {
setModalidadeConsulta(availability.appointment_type);
setForm({
start_time: availability.start_time,
end_time: availability.end_time,
slot_minutes: availability.slot_minutes.toString(),
appointment_type: availability.appointment_type,
id: availability.id
});
}
}, [availability])
if (!availability) {
return null;
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Edite a disponibilidade</DialogTitle>
<DialogDescription>Altere a disponibilidade atual.</DialogDescription>
</DialogHeader>
<form onSubmit={(e) => { e.preventDefault(); handleFormSubmit(); }}>
<div className="grid gap-4 py-1" >
<h3 className="font-semibold mb-2">{weekdaysPT[availability.weekday]}</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="start_time" className="font-semibold">Horário de entrada *</Label>
<Input id="start_time" type="time" value={form.start_time} onChange={(e) => handleInputChange("start_time", e.target.value)}/>
</div>
<div>
<Label htmlFor="end_time" className="font-semibold">Horário de saída *</Label>
<Input id="end_time" type="time" value={form.end_time} onChange={(e) => handleInputChange("end_time", e.target.value)}/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="duracaoConsulta" className="text-sm font-medium text-gray-700">
Duração Da Consulta (min)
</Label>
<Input type="number" id="duracaoConsulta" value={form.slot_minutes} onChange={(e) => handleInputChange("slot_minutes", e.target.value)} name="duracaoConsulta" required className="mt-1" />
</div>
<div>
<Label htmlFor="modalidadeConsulta" className="text-sm font-medium text-gray-700">
Modalidade De Consulta
</Label>
<Select value={form.appointment_type} onValueChange={(value) => {setModalidadeConsulta(value); handleInputChange("appointment_type", value);}}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="presencial">Presencial </SelectItem>
<SelectItem value="telemedicina">Telemedicina</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-5 gap-4">
<Button type="submit" className="col-start-5 bg-green-600 hover:bg-green-700">Confirmar</Button>
</div>
</div>
</form>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" className="px-4 py-2 bg-gray-200 rounded-md">Fechar</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

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

View File

@ -1,115 +0,0 @@
"use client";
import React from "react";
import { Search, Filter, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export interface FilterOption {
label: string;
value: string;
}
export interface FilterConfig {
key: string; // O nome do estado que vai guardar esse valor (ex: 'specialty')
label: string; // O placeholder do select (ex: 'Especialidade')
options: FilterOption[] | string[]; // Opções do dropdown
}
interface FilterBarProps {
onSearch: (term: string) => void;
searchTerm: string;
searchPlaceholder?: string;
filters?: FilterConfig[];
activeFilters: Record<string, string>;
onFilterChange: (key: string, value: string) => void;
onClearFilters?: () => void;
className?: string;
children?: React.ReactNode; // Para botões extras (ex: "Novo Médico", paginação)
}
export function FilterBar({
onSearch,
searchTerm,
searchPlaceholder = "Pesquisar...",
filters = [],
activeFilters,
onFilterChange,
onClearFilters,
children,
className,
}: FilterBarProps) {
// Verifica se tem algum filtro ativo para mostrar o botão de limpar
const hasActiveFilters =
searchTerm !== "" ||
Object.values(activeFilters).some(val => val !== "all" && val !== "");
return (
<div className={`flex flex-col md:flex-row items-start md:items-center gap-3 bg-card p-4 rounded-lg border ${className}`}>
{/* Barra de Pesquisa */}
<div className="relative w-full md:flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
value={searchTerm}
onChange={(e) => onSearch(e.target.value)}
className="pl-10 w-full bg-muted border-border focus:bg-card transition-colors"
/>
</div>
{/* Filtros Dinâmicos (Selects) */}
<div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
{filters.map((filter) => (
<div key={filter.key} className="w-full sm:w-auto">
<Select
value={activeFilters[filter.key] || "all"}
onValueChange={(value) => onFilterChange(filter.key, value)}
>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder={filter.label} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos: {filter.label}</SelectItem>
{filter.options.map((opt) => {
// Suporta tanto array de strings quanto array de objetos {label, value}
const value = typeof opt === 'string' ? opt : opt.value;
const label = typeof opt === 'string' ? opt : opt.label;
return (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
))}
{/* Botão de Limpar Filtros */}
{hasActiveFilters && onClearFilters && (
<Button
variant="ghost"
size="icon"
onClick={onClearFilters}
className="text-muted-foreground hover:text-destructive"
title="Limpar filtros"
>
<X className="h-4 w-4" />
</Button>
)}
{/* Botões Extras (ex: Novo Médico, Paginação) passados como children */}
{children}
</div>
</div>
);
}

View File

@ -1,145 +1,94 @@
"use client"; 'use client'
import { import {
Dialog, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface Paciente {
id: string;
nome: string;
telefone: string;
cidade: string;
estado: 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;
[key: string]: any; // Para permitir outras propriedades se necessário
}
interface PatientDetailsModalProps { interface PatientDetailsModalProps {
patient: Paciente | null;
isOpen: boolean; isOpen: boolean;
patient: any;
onClose: () => void; onClose: () => void;
} }
export function PatientDetailsModal({ export function PatientDetailsModal({ patient, isOpen, onClose }: PatientDetailsModalProps) {
patient,
isOpen,
onClose,
}: PatientDetailsModalProps) {
if (!patient) return null; if (!patient) return null;
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95%] sm:max-w-lg max-h-[90vh] overflow-y-auto bg-card text-card-foreground border border-border"> <DialogContent className="sm:max-w-[600px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-xl font-bold text-foreground">Detalhes do Paciente</DialogTitle> <DialogTitle>Detalhes do Paciente</DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription>Informações detalhadas sobre o paciente.</DialogDescription>
Informações detalhadas sobre o paciente.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-4 py-2"> <div className="grid grid-cols-2 gap-4">
{/* Grid Principal */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<p className="font-semibold text-foreground">Nome Completo</p> <p className="font-semibold">Nome Completo</p>
<p className="text-muted-foreground">{patient.nome}</p> <p>{patient.nome}</p>
</div>
{/* CORREÇÃO AQUI: Adicionado 'break-all' para quebrar o email */}
<div>
<p className="font-semibold text-foreground">Email</p>
<p className="text-muted-foreground break-all">{patient.email || "N/A"}</p>
</div>
<div>
<p className="font-semibold text-foreground">Telefone</p>
<p className="text-muted-foreground">{patient.telefone}</p>
</div>
<div>
<p className="font-semibold text-foreground">Data de Nascimento</p>
<p className="text-muted-foreground">{patient.birth_date || "N/A"}</p>
</div>
<div>
<p className="font-semibold text-foreground">CPF</p>
<p className="text-muted-foreground">{patient.cpf || "N/A"}</p>
</div>
<div>
<p className="font-semibold text-foreground">Tipo Sanguíneo</p>
<p className="text-muted-foreground">{patient.blood_type || "N/A"}</p>
</div>
<div>
<p className="font-semibold text-foreground">Peso (kg)</p>
<p className="text-muted-foreground">{patient.weight_kg || "0"}</p>
</div>
<div>
<p className="font-semibold text-foreground">Altura (m)</p>
<p className="text-muted-foreground">{patient.height_m || "0"}</p>
</div>
</div>
<hr className="border-border" />
{/* Seção de Endereço */}
<div>
<h4 className="font-semibold mb-3 text-foreground">Endereço</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="font-semibold text-foreground">Rua</p>
<p className="text-muted-foreground">
{patient.street && patient.street !== "N/A"
? `${patient.street}, ${patient.number || ""}`
: "N/A"}
</p>
</div> </div>
<div> <div>
<p className="font-semibold text-foreground">Complemento</p> <p className="font-semibold">Email</p>
<p className="text-muted-foreground">{patient.complement || "N/A"}</p> <p>{patient.email}</p>
</div> </div>
<div> <div>
<p className="font-semibold text-foreground">Bairro</p> <p className="font-semibold">Telefone</p>
<p className="text-muted-foreground">{patient.neighborhood || "N/A"}</p> <p>{patient.telefone}</p>
</div> </div>
<div> <div>
<p className="font-semibold text-foreground">Cidade</p> <p className="font-semibold">Data de Nascimento</p>
<p className="text-muted-foreground">{patient.cidade || "N/A"}</p> <p>{patient.birth_date}</p>
</div> </div>
<div> <div>
<p className="font-semibold text-foreground">Estado</p> <p className="font-semibold">CPF</p>
<p className="text-muted-foreground">{patient.estado || "N/A"}</p> <p>{patient.cpf}</p>
</div> </div>
<div> <div>
<p className="font-semibold text-foreground">CEP</p> <p className="font-semibold">Tipo Sanguíneo</p>
<p className="text-muted-foreground">{patient.cep || "N/A"}</p> <p>{patient.blood_type}</p>
</div>
<div>
<p className="font-semibold">Peso (kg)</p>
<p>{patient.weight_kg}</p>
</div>
<div>
<p className="font-semibold">Altura (m)</p>
<p>{patient.height_m}</p>
</div>
</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>
<p className="font-semibold">Rua</p>
<p>{`${patient.street}, ${patient.number}`}</p>
</div>
<div>
<p className="font-semibold">Complemento</p>
<p>{patient.complement}</p>
</div>
<div>
<p className="font-semibold">Bairro</p>
<p>{patient.neighborhood}</p>
</div>
<div>
<p className="font-semibold">Cidade</p>
<p>{patient.cidade}</p>
</div>
<div>
<p className="font-semibold">Estado</p>
<p>{patient.estado}</p>
</div>
<div>
<p className="font-semibold">CEP</p>
<p>{patient.cep}</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="secondary" onClick={onClose} className="w-full sm:w-auto"> <DialogClose asChild>
Fechar <button type="button" className="px-4 py-2 bg-gray-200 rounded-md">Fechar</button>
</Button> </DialogClose>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -55,7 +55,7 @@ const FontSizeExtension = Extension.create({
}, },
}) })
const Tiptap = ({ content, onChange }: { content: string, onChange: (html: string, json: object) => void }) => { const Tiptap = ({ content, onChange }: { content: string, onChange: (richText: string) => void }) => {
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit.configure(), StarterKit.configure(),
@ -72,7 +72,7 @@ const Tiptap = ({ content, onChange }: { content: string, onChange: (html: strin
}, },
}, },
onUpdate({ editor }) { onUpdate({ editor }) {
onChange(editor.getHTML(), editor.getJSON()) onChange(editor.getHTML())
}, },
immediatelyRender: false, immediatelyRender: false,
}) })
@ -100,28 +100,24 @@ const Tiptap = ({ content, onChange }: { content: string, onChange: (html: strin
<div> <div>
<div className="flex items-center gap-2 p-2 border-b"> <div className="flex items-center gap-2 p-2 border-b">
<button <button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'is-active' : ''} className={editor.isActive('bold') ? 'is-active' : ''}
> >
<Bold className="w-5 h-5" /> <Bold className="w-5 h-5" />
</button> </button>
<button <button
type="button"
onClick={() => editor.chain().focus().toggleItalic().run()} onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'is-active' : ''} className={editor.isActive('italic') ? 'is-active' : ''}
> >
<Italic className="w-5 h-5" /> <Italic className="w-5 h-5" />
</button> </button>
<button <button
type="button"
onClick={() => editor.chain().focus().toggleStrike().run()} onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive('strike') ? 'is-active' : ''} className={editor.isActive('strike') ? 'is-active' : ''}
> >
<Strikethrough className="w-5 h-5" /> <Strikethrough className="w-5 h-5" />
</button> </button>
<button <button
type="button"
onClick={() => editor.chain().focus().toggleUnderline().run()} onClick={() => editor.chain().focus().toggleUnderline().run()}
className={editor.isActive('underline') ? 'is-active' : ''} className={editor.isActive('underline') ? 'is-active' : ''}
> >

View File

@ -1,52 +1,129 @@
"use client"; 'use client'
import * as React from "react"; import * as React from 'react'
import * as ToastPrimitives from "@radix-ui/react-toast"; import * as ToastPrimitives from '@radix-ui/react-toast'
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from 'class-variance-authority'
import { X } from "lucide-react"; import { X } from 'lucide-react'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
const ToastProvider = ToastPrimitives.Provider; const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Viewport>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>>(({ className, ...props }, ref) => <ToastPrimitives.Viewport ref={ref} className={cn("fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", className)} {...props} />); const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName; React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className,
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva("group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", { const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: { variants: {
variant: { variant: {
default: "border bg-background text-foreground", default: 'border bg-background text-foreground',
destructive: "destructive group border-destructive bg-destructive text-foreground", destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
}, },
}); },
)
const Toast = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Root>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>>(({ className, variant, ...props }, ref) => { const Toast = React.forwardRef<
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />; React.ElementRef<typeof ToastPrimitives.Root>,
}); React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
Toast.displayName = ToastPrimitives.Root.displayName; VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Action>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>>(({ className, ...props }, ref) => <ToastPrimitives.Action ref={ref} className={cn("inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", className)} {...props} />); const ToastAction = React.forwardRef<
ToastAction.displayName = ToastPrimitives.Action.displayName; React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className,
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Close>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>>(({ className, ...props }, ref) => ( const ToastClose = React.forwardRef<
<ToastPrimitives.Close ref={ref} className={cn("absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", className)} toast-close="" {...props}> React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</ToastPrimitives.Close> </ToastPrimitives.Close>
)); ))
ToastClose.displayName = ToastPrimitives.Close.displayName; ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Title>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>>(({ className, ...props }, ref) => <ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />); const ToastTitle = React.forwardRef<
ToastTitle.displayName = ToastPrimitives.Title.displayName; React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Description>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>>(({ className, ...props }, ref) => <ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />); const ToastDescription = React.forwardRef<
ToastDescription.displayName = ToastPrimitives.Description.displayName; React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>; type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>; type ToastActionElement = React.ReactElement<typeof ToastAction>
export { type ToastProps, type ToastActionElement, ToastProvider, ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, ToastAction }; export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -1,158 +0,0 @@
"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;
avatarUrl?: string;
}
export default function SidebarUserSection({
userData,
sidebarCollapsed,
handleLogout,
isActive,
avatarUrl,
}: 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" },
];
// Função auxiliar para obter iniciais
const getInitials = (name: string) => {
if (!name) return "U";
return name
.split(" ")
.map((n) => n[0])
.slice(0, 2)
.join("")
.toUpperCase();
};
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" : "cursor-default pointer-events-none"
}`}
>
<Avatar>
<AvatarImage
src={avatarUrl}
alt={userData.user_metadata.full_name}
className="object-cover"
/>
<AvatarFallback className="text-black bg-gray-200 font-semibold">
{getInitials(userData.user_metadata.full_name)}
</AvatarFallback>
</Avatar>
{!sidebarCollapsed && (
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{userData.user_metadata.full_name}
</p>
<p className="text-xs text-white truncate">
{userData.app_metadata.user_role}
</p>
</div>
)}
</div>
</PopoverTrigger>
{/* Card flutuante */}
<PopoverContent
align="center"
side="top"
className="w-64 p-4 shadow-2xl border-2 border-primary/20 bg-card text-card-foreground ring-1 ring-primary/10"
>
<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-primary/10 text-primary border-r-2 border-primary"
: "text-foreground hover:bg-muted"
}`}
>
<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-card text-foreground border-2 border-border flex justify-center items-center p-2 hover:bg-muted"
: "w-full bg-card text-foreground border-2 border-border hover:bg-muted cursor-pointer"
}
onClick={handleLogout}
>
<LogOut
className={
sidebarCollapsed ? "h-5 w-5" : "mr-2 h-4 w-4"
}
/>
{!sidebarCollapsed && "Sair"}
</Button>
</div>
);
}

View File

@ -1,42 +0,0 @@
// Caminho: hooks/useAuth.ts
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Cookies from 'js-cookie';
// Uma interface genérica para as informações do usuário que pegamos do localStorage
interface UserInfo {
id: string;
email: string;
user_metadata: {
full_name?: string;
role?: string; // O perfil escolhido no login
specialty?: string;
department?: string;
};
// Adicione outros campos que possam existir
}
export function useAuth() {
const [user, setUser] = useState<UserInfo | null>(null);
const router = useRouter();
useEffect(() => {
const userInfoString = localStorage.getItem('user_info');
const token = Cookies.get('access_token');
if (userInfoString && token) {
try {
const userInfo = JSON.parse(userInfoString);
setUser(userInfo);
} catch (error) {
console.error("Erro ao parsear user_info do localStorage", error);
router.push('/'); // Redireciona se os dados estiverem corrompidos
}
} else {
// Se não houver token ou info, redireciona para a página inicial/login
router.push('/');
}
}, [router]);
return user; // Retorna o usuário logado ou null enquanto carrega/redireciona
}

View File

@ -1,77 +0,0 @@
// 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();
// só verifica papel se requiredRole existir
if (
requiredRole &&
!fullUserData.roles.some((role: string) =>
requiredRole.includes(role)
)
) {
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;
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,
});
} catch (error) {
console.error("Falha na autenticação do layout:", error);
router.push("/login");
} finally {
setIsLoading(false);
}
};
fetchUserData();
}, [router]); // não depende mais de requiredRole
return { user, isLoading };
}

View File

@ -1,94 +0,0 @@
// lib/normalization.ts
/**
* Mapa de normalização.
* A chave é o termo "sujo" (em minúsculo) e o valor é o termo "Canônico" (Bonito).
*/
const SPECIALTY_MAPPING: Record<string, string> = {
// --- Cardiologia ---
"cardiologista": "Cardiologia",
"cardio": "Cardiologia",
"cardiologia": "Cardiologia",
// --- Dermatologia ---
"dermatologista": "Dermatologia",
"dermato": "Dermatologia",
"dermatologia": "Dermatologia",
// --- Ortopedia ---
"ortopedista": "Ortopedia",
"ortopedia": "Ortopedia",
// --- Ginecologia ---
"ginecologista": "Ginecologia",
"ginecologia": "Ginecologia",
"ginecologistaa": "Ginecologia", // Erro de digitação comum
"gineco": "Ginecologia",
// --- Pediatria ---
"pediatra": "Pediatria",
"pediatria": "Pediatria",
// --- Clínica Geral (Onde estava o erro) ---
"clinico geral": "Clínica Geral",
"clínico geral": "Clínica Geral",
"clinica geral": "Clínica Geral",
"clínica geral": "Clínica Geral", // <--- ADICIONADO
"geral": "Clínica Geral",
"medico geral": "Clínica Geral",
"médico geral": "Clínica Geral",
// --- Neurologia ---
"neurologista": "Neurologia",
"neurologia": "Neurologia",
"neuro": "Neurologia",
"neurocirurgiao": "Neurocirurgia",
"neurocirurgião": "Neurocirurgia",
// --- Limpeza de Lixo / Outros ---
"asdw": "Outros",
"teste": "Outros",
"n/a": "Não Informado", // <--- Transforma o "N/A" da imagem
"na": "Não Informado",
};
/**
* Recebe uma especialidade suja e retorna a versão limpa.
*/
export function normalizeSpecialty(raw: string | null | undefined): string {
if (!raw) return "Não Informado";
// Remove espaços extras e joga para minúsculo
const lower = raw.trim().toLowerCase();
// Se for uma string vazia ou traço
if (lower === "" || lower === "-") return "Não Informado";
// Verifica no mapa
if (SPECIALTY_MAPPING[lower]) {
return SPECIALTY_MAPPING[lower];
}
// Fallback: Capitaliza a primeira letra de cada palavra
// Ex: "cirurgia plastica" -> "Cirurgia Plastica"
return lower.replace(/\b\w/g, (l) => l.toUpperCase());
}
/**
* Extrai uma lista única de especialidades normalizadas.
*/
export function getUniqueSpecialties(items: any[]): string[] {
const specialties = new Set<string>();
items.forEach(item => {
// Normaliza antes de adicionar ao Set
const normalized = normalizeSpecialty(item.specialty);
// Só adiciona se não for "Não Informado" ou "Outros" (Opcional: remova o if se quiser mostrar tudo)
if (normalized && normalized !== "Não Informado") {
specialties.add(normalized);
}
});
return Array.from(specialties).sort();
}

View File

@ -1,52 +1,6 @@
// ARQUIVO: lib/utils.ts
import { clsx, type ClassValue } from 'clsx' import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) 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;
}

10
package-lock.json generated
View File

@ -52,7 +52,7 @@
"input-otp": "1.4.1", "input-otp": "1.4.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lucide-react": "^0.545.0", "lucide-react": "^0.454.0",
"next": "^14.2.33", "next": "^14.2.33",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^18.3.1", "react": "^18.3.1",
@ -3381,12 +3381,12 @@
} }
}, },
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "0.545.0", "version": "0.454.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz",
"integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==", "integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
} }
}, },
"node_modules/magic-string": { "node_modules/magic-string": {

View File

@ -53,7 +53,7 @@
"input-otp": "1.4.1", "input-otp": "1.4.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lucide-react": "^0.545.0", "lucide-react": "^0.454.0",
"next": "^14.2.33", "next": "^14.2.33",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^18.3.1", "react": "^18.3.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,58 +0,0 @@
/**
* 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

@ -1,139 +1,84 @@
// SUBSTITUA TODO O CONTEÚDO DE services/api.mjs POR ESTE CÓDIGO const BASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const API_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const apikey = API_KEY;
var tempToken;
// Caminho: services/api.mjs export async function login() {
const response = await fetch("https://yuanqfswhberkoevtmfr.supabase.co/auth/v1/token?grant_type=password", {
const BASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const API_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
/**
* Função de login que o seu formulário usa.
* Ela continua exatamente como era.
*/
export async function login(email, senha) {
console.log("🔐 Iniciando login...");
const res = await fetch(`${BASE_URL}/auth/v1/token?grant_type=password`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
apikey: API_KEY,
Prefer: "return=representation", Prefer: "return=representation",
apikey: API_KEY, // valor fixo
}, },
body: JSON.stringify({ body: JSON.stringify({ email: "riseup@popcode.com.br", password: "riseup" }),
email: email,
password: senha,
}),
}); });
if (!res.ok) { const data = await response.json();
const msg = await res.text();
console.error("❌ Erro no login:", res.status, msg);
throw new Error(`Erro ao autenticar: ${res.status} - ${msg}`);
}
const data = await res.json();
console.log("✅ Login bem-sucedido:", data);
if (typeof window !== "undefined" && data.access_token) {
localStorage.setItem("token", data.access_token); localStorage.setItem("token", data.access_token);
localStorage.setItem("user_info", JSON.stringify(data.user));
}
return data; return data;
} }
/** let loginPromise = login();
* Função de logout.
*/
async function logout() {
const token = localStorage.getItem("token");
if (!token) return;
try {
await fetch(`${BASE_URL}/auth/v1/logout`, {
method: "POST",
headers: {
apikey: API_KEY,
Authorization: `Bearer ${token}`,
},
});
} catch (error) {
console.error("Falha ao invalidar token no servidor:", error);
} finally {
localStorage.removeItem("token");
localStorage.removeItem("user_info");
}
}
/**
* Função genérica para fazer requisições.
* Agora com a correção para respostas vazias.
*/
async function request(endpoint, options = {}) { async function request(endpoint, options = {}) {
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null; if (loginPromise) {
try {
await loginPromise;
} catch (error) {
console.error("Falha na autenticação inicial:", error);
}
loginPromise = null;
}
const token = localStorage.getItem("token");
const headers = { const headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
apikey: API_KEY, apikey: API_KEY,
...(token && { Authorization: `Bearer ${token}` }), ...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers, ...options.headers,
}; };
const response = await fetch(`${BASE_URL}${endpoint}`, { ...options, headers });
if (!response.ok) {
const errorBody = await response.json().catch(() => response.text());
console.error("Erro na requisição:", response.status, errorBody);
throw new Error(`Erro na API: ${errorBody.message || JSON.stringify(errorBody)}`);
}
// --- 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.
// --- 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 { try {
return JSON.parse(text); const response = await fetch(`${BASE_URL}${endpoint}`, {
} catch { ...options,
return text || null; headers,
}
}
// Exportamos o objeto 'api' com os métodos que os componentes vão usar.
export const api = {
// --- CORREÇÃO 2: PARA CARREGAR O ID DO USUÁRIO ---
getSession: () => request("/auth/v1/user"),
get: (endpoint, options) => request(endpoint, { method: "GET", ...options }),
post: (endpoint, data, options) => request(endpoint, { method: "POST", body: JSON.stringify(data), ...options }),
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) { if (!response.ok) {
const errorBody = await response.json(); let errorBody = `Status: ${response.status}`;
throw new Error(`Erro no upload: ${errorBody.message}`); try {
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
const jsonError = await response.json();
errorBody = jsonError.message || JSON.stringify(jsonError);
} else {
errorBody = await response.text();
} }
return response.json(); } catch (e) {
errorBody = `Status: ${response.status} - Falha ao ler corpo do erro.`;
} }
},
throw new Error(`Erro HTTP: ${response.status} - Detalhes: ${errorBody}`);
}
const contentType = response.headers.get("content-type");
if (response.status === 204 || (contentType && !contentType.includes("application/json")) || !contentType) {
return {};
}
return await response.json();
} catch (error) {
console.error("Erro na requisição:", error);
throw error;
}
}
export const api = {
get: (endpoint) => request(endpoint, { method: "GET" }),
post: (endpoint, data) => request(endpoint, { method: "POST", body: JSON.stringify(data) }),
patch: (endpoint, data) => request(endpoint, { method: "PATCH", body: JSON.stringify(data) }),
delete: (endpoint) => request(endpoint, { method: "DELETE" }),
}; };

View File

@ -1,51 +0,0 @@
import { api } from "./api.mjs";
export const appointmentsService = {
/**
* Busca por horários disponíveis para agendamento.
* @param {object} data - Critérios da busca (ex: { doctor_id, date }).
* @returns {Promise<Array>} - Uma promessa que resolve para uma lista de horários disponíveis.
*/
search_h: (data) => api.post('/functions/v1/get-available-slots', data),
/**
* Lista todos os agendamentos.
* @returns {Promise<Array>} - Uma promessa que resolve para a lista de agendamentos.
*/
list: () => api.get('/rest/v1/appointments'),
/**
* Cria um novo agendamento.
* @param {object} data - Os dados do agendamento a ser criado.
* @returns {Promise<object>} - Uma promessa que resolve para o agendamento criado.
*/
create: (data) => api.post('/rest/v1/appointments', data),
/**
* Busca agendamentos com base em parâmetros de consulta.
* @param {string} queryParams - A string de consulta (ex: 'patient_id=eq.123&status=eq.scheduled').
* @returns {Promise<Array>} - Uma promessa que resolve para a lista de agendamentos encontrados.
*/
search_appointment: (queryParams) => api.get(`/rest/v1/appointments?${queryParams}`),
/**
* Atualiza um agendamento existente.
* @param {string|number} id - O ID do agendamento a ser atualizado.
* @param {object} data - Os novos dados para o agendamento.
* @returns {Promise<object>} - Uma promessa que resolve com a resposta da API.
*/
update: (id, data) => api.patch(`/rest/v1/appointments?id=eq.${id}`, data),
/**
* Deleta um agendamento.
* @param {string|number} id - O ID do agendamento a ser deletado.
* @returns {Promise<object>} - Uma promessa que resolve com a resposta da API.
*/
delete: (id) => api.delete(`/rest/v1/appointments?id=eq.${id}`),
};

View File

@ -1,19 +0,0 @@
import { api } from "./api.mjs";
export const AvailabilityService = {
list: () => api.get("/rest/v1/doctor_availability"),
listById: (id) => api.get(`/rest/v1/doctor_availability?doctor_id=eq.${id}`),
create: (data) => api.post("/rest/v1/doctor_availability", data),
update: (id, data) => api.patch(`/rest/v1/doctor_availability?id=eq.${id}`, data),
delete: (id) => api.delete(`/rest/v1/doctor_availability?id=eq.${id}`),
};
export async function getDisponibilidadeByMedico(idMedico) {
try {
const response = await api.get(`/disponibilidade/${idMedico}`);
return response.data;
} catch (error) {
console.error("Erro ao buscar disponibilidade do médico:", error);
return [];
}
}

View File

@ -3,10 +3,7 @@ import { api } from "./api.mjs";
export const doctorsService = { export const doctorsService = {
list: () => api.get("/rest/v1/doctors"), list: () => api.get("/rest/v1/doctors"),
getById: (id) => api.get(`/rest/v1/doctors?id=eq.${id}`).then(data => data[0]), getById: (id) => api.get(`/rest/v1/doctors?id=eq.${id}`).then(data => data[0]),
async create(data) { create: (data) => api.post("/rest/v1/doctors", data),
// Esta é a função usada no page.tsx para criar médicos
return await api.post("/functions/v1/create-doctor", data);
},
update: (id, data) => api.patch(`/rest/v1/doctors?id=eq.${id}`, data), update: (id, data) => api.patch(`/rest/v1/doctors?id=eq.${id}`, data),
delete: (id) => api.delete(`/rest/v1/doctors?id=eq.${id}`), delete: (id) => api.delete(`/rest/v1/doctors?id=eq.${id}`),
}; };

View File

@ -1,8 +0,0 @@
import { api } from "./api.mjs";
export const exceptionsService = {
list: () => api.get("/rest/v1/doctor_exceptions"),
listById: () => api.get(`/rest/v1/doctor_exceptions?id=eq.${id}`),
create: (data) => api.post("/rest/v1/doctor_exceptions", data),
delete: (id) => api.delete(`/rest/v1/doctor_exceptions?id=eq.${id}`),
};

View File

@ -2,11 +2,7 @@ import { api } from "./api.mjs";
export const patientsService = { export const patientsService = {
list: () => api.get("/rest/v1/patients"), list: () => api.get("/rest/v1/patients"),
getById: (id) => { getById: (id) => api.get(`/rest/v1/patients?id=eq.${id}`),
console.log("getById chamado", id);
return api.get(`/rest/v1/patients?id=eq.${id}`);
},
create: (data) => api.post("/rest/v1/patients", data), create: (data) => api.post("/rest/v1/patients", data),
update: (id, data) => api.patch(`/rest/v1/patients?id=eq.${id}`, data), update: (id, data) => api.patch(`/rest/v1/patients?id=eq.${id}`, data),
delete: (id) => api.delete(`/rest/v1/patients?id=eq.${id}`), delete: (id) => api.delete(`/rest/v1/patients?id=eq.${id}`),

View File

@ -1,42 +0,0 @@
import { api } from "./api.mjs";
const REPORTS_API_URL = "/rest/v1/reports";
export const reportsApi = {
getReports: async (patientId) => {
try {
const data = await api.get(`${REPORTS_API_URL}?patient_id=eq.${patientId}`);
return data;
} catch (error) {
console.error("Failed to fetch reports:", error);
throw error;
}
},
getReportById: async (reportId) => {
try {
const data = await api.get(`${REPORTS_API_URL}?id=eq.${reportId}`);
return data;
} catch (error) {
console.error(`Failed to fetch report ${reportId}:`, error);
throw error;
}
},
createReport: async (reportData) => {
try {
const data = await api.post(REPORTS_API_URL, reportData);
return data;
} catch (error) {
console.error("Failed to create report:", error);
throw error;
}
},
updateReport: async (reportId, reportData) => {
try {
const data = await api.patch(`${REPORTS_API_URL}?id=eq.${reportId}`, reportData);
return data;
} catch (error) {
console.error(`Failed to update report ${reportId}:`, error);
throw error;
}
},
};

View File

@ -1,99 +1,10 @@
import { api } from "./api.mjs"; import { api } from "./api.mjs";
export const usersService = { export const usersService = {
// Função getMe corrigida para chamar a si mesma pelo nome create_user: (data) => api.post(`/functions/v1/create-user`),
async getMe() { list_roles: () => api.get(`/rest/v1/user_roles`),
console.log("getMe chamado"); full_data: (id) => {
const sessionData = await api.getSession(); const endpoint = `/functions/v1/user-info?user_id=${id}`;
if (!sessionData?.id) { return api.get(endpoint);
console.error("Sessão não encontrada ou usuário sem ID.", sessionData); },
throw new Error("Usuário não autenticado."); summary_data: () => api.get(`/auth/v1/user`)
} }
// Chamando a outra função do serviço pelo nome explícito
return usersService.full_data(sessionData.id);
},
async list_roles() {
return await api.get(`/rest/v1/user_roles?select=id,user_id,role,created_at`);
},
async create_user(data) {
// Esta é a função usada no page.tsx para criar usuários que não são médicos
return await api.post(`/functions/v1/create-user-with-password`, data);
},
// --- NOVA FUNÇÃO ADICIONADA AQUI ---
// Esta função chama o endpoint público de registro de paciente.
async registerPatient(data) {
// POR QUÊ? Este endpoint é público e não requer token JWT, resolvendo o erro 401.
return await api.post("/functions/v1/register-patient", data);
},
// --- FIM DA NOVA FUNÇÃO ---
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");
const [profile] = await api.get(`/rest/v1/profiles?id=eq.${user_id}`);
const [role] = await api.get(`/rest/v1/user_roles?user_id=eq.${user_id}`);
const permissions = {
isAdmin: role?.role === "admin",
isManager: role?.role === "gestor",
isDoctor: role?.role === "medico",
isSecretary: role?.role === "secretaria",
isAdminOrManager: role?.role === "admin" || role?.role === "gestor" ? true : false,
};
return {
user: {
id: user_id,
email: profile?.email ?? "—",
email_confirmed_at: null,
created_at: profile?.created_at ?? "—",
last_sign_in_at: null,
},
profile: {
id: profile?.id ?? user_id,
full_name: profile?.full_name ?? "—",
email: profile?.email ?? "—",
phone: profile?.phone ?? "—",
avatar_url: profile?.avatar_url ?? null,
disabled: profile?.disabled ?? false,
created_at: profile?.created_at ?? null,
updated_at: profile?.updated_at ?? null,
},
roles: [role?.role ?? "—"],
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.");
}
},
};