Merge branch 'Stage' of https://github.com/m1guelmcf/MedConnect into retirar-relatorios

This commit is contained in:
StsDanilo 2025-11-26 19:13:17 -03:00
commit 5de7d4b471
25 changed files with 4841 additions and 3309 deletions

View File

@ -31,7 +31,7 @@ interface EnrichedAppointment {
} }
export default function DoctorAppointmentsPage() { export default function DoctorAppointmentsPage() {
const { user, isLoading: isAuthLoading } = useAuthLayout({ requiredRole: ['medico'] }); const { user, isLoading: isAuthLoading } = useAuthLayout({ requiredRole: "medico" });
const [allAppointments, setAllAppointments] = useState<EnrichedAppointment[]>([]); const [allAppointments, setAllAppointments] = useState<EnrichedAppointment[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -111,13 +111,22 @@ export default function DoctorAppointmentsPage() {
return format(date, "EEEE, 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']) => { const getStatusVariant = (status: EnrichedAppointment['status']) => {
switch (status) { switch (status) {
case "confirmed": case "checked_in": return "default"; case "confirmed": case "checked_in": return "text-foreground bg-blue-100 hover:bg-blue-150";
case "completed": return "secondary"; case "completed": return "text-foreground bg-green-100 hover:bg-green-150";
case "cancelled": case "no_show": return "destructive"; case "cancelled": case "no_show": return "text-foreground bg-red-200 hover:bg-red-250";
case "requested": return "outline"; case "requested": return "text-foreground bg-yellow-100 hover:bg-yellow-150";
default: return "outline"; default: return "border-gray bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90";
} }
}; };
@ -191,8 +200,8 @@ export default function DoctorAppointmentsPage() {
{/* Coluna 2: Status e Telefone */} {/* Coluna 2: Status e Telefone */}
<div className="col-span-1 flex flex-col items-center gap-2"> <div className="col-span-1 flex flex-col items-center gap-2">
<Badge variant={getStatusVariant(appointment.status)} className="capitalize text-xs">{appointment.status.replace('_', ' ')}</Badge> <Badge variant="outline" className={getStatusVariant(appointment.status)}>{statusPT[appointment.status].replace('_', ' ')}</Badge>
<div className="flex items-center text-sm text-muted-foreground"> <div className="flex items-center text-sm text-muted-foreground">
<Phone className="mr-2 h-4 w-4" /> <Phone className="mr-2 h-4 w-4" />
{appointment.patientPhone} {appointment.patientPhone}
</div> </div>

View File

@ -1,9 +1,24 @@
"use client"; "use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 { Calendar, Clock, User, Trash2 } from "lucide-react"; import { Calendar, Clock, User, Trash2 } 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 Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -23,21 +38,96 @@ import { exceptionsService } from "@/services/exceptionApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs"; import { doctorsService } from "@/services/doctorsApi.mjs";
import { usersService } from "@/services/usersApi.mjs"; import { usersService } from "@/services/usersApi.mjs";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import WeeklyScheduleCard from "@/components/ui/WeeklyScheduleCard";
// (As interfaces permanecem as mesmas) type Availability = {
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; }; id: string;
type Schedule = { weekday: object; }; doctor_id: string;
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; } weekday: string;
interface UserPermissions { isAdmin: boolean; isManager: boolean; isDoctor: boolean; isSecretary: boolean; isAdminOrManager: boolean; } start_time: string;
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; } end_time: string;
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; } slot_minutes: number;
appointment_type: string;
active: boolean;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string | null;
};
// --- NOVA INTERFACE PARA A CONSULTA COM NOME DO PACIENTE --- type Schedule = {
interface EnrichedAppointment { 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; id: string;
patientName: string; email: string;
scheduled_at: string; email_confirmed_at: string | null;
[key: string]: any; 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; // id da exceção
doctor_id: string;
date: string; // formato YYYY-MM-DD
start_time: string | null; // null = dia inteiro
end_time: string | null; // null = dia inteiro
kind: "bloqueio" | "disponibilidade"; // tipos conhecidos
reason: string | null; // pode ser null
created_at: string; // timestamp ISO
created_by: string;
} }
export default function PatientDashboard() { export default function PatientDashboard() {
@ -156,20 +246,22 @@ export default function PatientDashboard() {
return schedule; return schedule;
} }
useEffect(() => { useEffect(() => {
if (availability) { if (availability) {
const formatted = formatAvailability(availability); const formatted = formatAvailability(availability);
setSchedule(formatted); setSchedule(formatted);
} }
}, [availability]); }, [availability]);
return ( return (
<Sidebar> <Sidebar>
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1> <h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600">Bem-vindo ao seu portal de consultas médicas</p> <p className="text-gray-600">
</div> Bem-vindo ao seu portal de consultas médicas
</p>
</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 "PRÓXIMA CONSULTA" CORRIGIDO PARA MOSTRAR NOME DO PACIENTE ▼▼▼ */} {/* ▼▼▼ CARD "PRÓXIMA CONSULTA" CORRIGIDO PARA MOSTRAR NOME DO PACIENTE ▼▼▼ */}
@ -211,17 +303,17 @@ export default function PatientDashboard() {
</Card> </Card>
{/* ▲▲▲ FIM DO CARD ATUALIZADO ▲▲▲ */} {/* ▲▲▲ FIM DO CARD ATUALIZADO ▲▲▲ */}
<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>
<User className="h-4 w-4 text-muted-foreground" /> <User className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">100%</div> <div className="text-2xl font-bold">100%</div>
<p className="text-xs text-muted-foreground">Dados completos</p> <p className="text-xs text-muted-foreground">Dados completos</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* O restante do código permanece o mesmo */} {/* O restante do código permanece o mesmo */}
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
@ -267,31 +359,7 @@ export default function PatientDashboard() {
<CardTitle>Horário Semanal</CardTitle> <CardTitle>Horário Semanal</CardTitle>
<CardDescription>Confira rapidamente a sua disponibilidade da semana</CardDescription> <CardDescription>Confira rapidamente a sua disponibilidade da semana</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 grid md:grid-cols-7 gap-2"> <CardContent>{loggedDoctor && <WeeklyScheduleCard doctorId={loggedDoctor.id} />}</CardContent>
{["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-blue-50 rounded-lg">
<div>
<p className="font-medium capitalize">{weekdaysPT[day]}</p>
</div>
<div className="text-center">
{times.length > 0 ? (
times.map((t, i) => (
<p key={i} className="text-sm text-gray-600">
{formatTime(t.start)} <br /> {formatTime(t.end)}
</p>
))
) : (
<p className="text-sm text-gray-400 italic">Sem horário</p>
)}
</div>
</div>
</div>
);
})}
</CardContent>
</Card> </Card>
</div> </div>
<div className="grid md:grid-cols-1 gap-6"> <div className="grid md:grid-cols-1 gap-6">
@ -311,8 +379,8 @@ export default function PatientDashboard() {
timeZone: "UTC" timeZone: "UTC"
}); });
const startTime = formatTime(ex.start_time); const startTime = formatTime(ex.start_time);
const endTime = formatTime(ex.end_time); const endTime = formatTime(ex.end_time);
return ( return (
<div key={ex.id} className="space-y-4"> <div key={ex.id} className="space-y-4">

View File

@ -6,7 +6,13 @@ import Link from "next/link";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AvailabilityService } from "@/services/availabilityApi.mjs"; import { AvailabilityService } from "@/services/availabilityApi.mjs";
import { usersService } from "@/services/usersApi.mjs"; import { usersService } from "@/services/usersApi.mjs";
@ -14,163 +20,203 @@ import { doctorsService } from "@/services/doctorsApi.mjs";
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; import {
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; 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 { Edit, Trash2 } from "lucide-react";
import { AvailabilityEditModal } from "@/components/ui/availability-edit-modal"; import { AvailabilityEditModal } from "@/components/ui/availability-edit-modal";
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 Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
// ... (Interfaces de tipo omitidas para brevidade, pois não foram alteradas) // ... (Interfaces de tipo omitidas para brevidade, pois não foram alteradas)
interface UserPermissions { interface UserPermissions {
isAdmin: boolean; isAdmin: boolean;
isManager: boolean; isManager: boolean;
isDoctor: boolean; isDoctor: boolean;
isSecretary: boolean; isSecretary: boolean;
isAdminOrManager: boolean; isAdminOrManager: boolean;
} }
interface UserData { interface UserData {
user: { user: {
id: string; id: string;
email: string; email: string;
email_confirmed_at: string | null; email_confirmed_at: string | null;
created_at: string | null; created_at: string | null;
last_sign_in_at: string | null; last_sign_in_at: string | null;
}; };
profile: { profile: {
id: string; id: string;
full_name: string; full_name: string;
email: string; email: string;
phone: string; phone: string;
avatar_url: string | null; avatar_url: string | null;
disabled: boolean; disabled: boolean;
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
}; };
roles: string[]; roles: string[];
permissions: UserPermissions; permissions: UserPermissions;
} }
type Doctor = { type Doctor = {
id: string; id: string;
user_id: string | null; user_id: string | null;
crm: string; crm: string;
crm_uf: string; crm_uf: string;
specialty: string; specialty: string;
full_name: string; full_name: string;
cpf: string; cpf: string;
email: string; email: string;
phone_mobile: string | null; phone_mobile: string | null;
phone2: string | null; phone2: string | null;
cep: string | null; cep: string | null;
street: string | null; street: string | null;
number: string | null; number: string | null;
complement: string | null; complement: string | null;
neighborhood: string | null; neighborhood: string | null;
city: string | null; city: string | null;
state: string | null; state: string | null;
birth_date: string | null; birth_date: string | null;
rg: string | null; rg: string | null;
active: boolean; active: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
created_by: string; created_by: string;
updated_by: string | null; updated_by: string | null;
max_days_in_advance: number; max_days_in_advance: number;
rating: number | null; rating: number | null;
} };
type Availability = { type Availability = {
id: string; id: string;
doctor_id: string; doctor_id: string;
weekday: string; weekday: string;
start_time: string; start_time: string;
end_time: string; end_time: string;
slot_minutes: number; slot_minutes: number;
appointment_type: string; appointment_type: string;
active: boolean; active: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
created_by: string; created_by: string;
updated_by: string | null; updated_by: string | null;
}; };
export default function AvailabilityPage() { export default function AvailabilityPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const router = useRouter(); const router = useRouter();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [schedule, setSchedule] = useState<Record<string, { start: string; end: string }[]>>({}); const [schedule, setSchedule] = useState<
const formatTime = (time?: string | null) => time?.split(":")?.slice(0, 2).join(":") ?? ""; Record<string, { start: string; end: string }[]>
const [userData, setUserData] = useState<UserData>(); >({});
const [availability, setAvailability] = useState<any | null>(null); const formatTime = (time?: string | null) =>
const [doctorId, setDoctorId] = useState<string>(); time?.split(":")?.slice(0, 2).join(":") ?? "";
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [userData, setUserData] = useState<UserData>();
const [modalidadeConsulta, setModalidadeConsulta] = useState<string>(""); const [availability, setAvailability] = useState<any | null>(null);
const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null); const [doctorId, setDoctorId] = useState<string>();
const [isModalOpen, setIsModalOpen] = useState(false); 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 selectAvailability = (
const selected = availability.filter((a: Availability) => schedule: { start: string; end: string },
a.start_time === schedule.start && day: string
a.end_time === schedule.end && ) => {
a.weekday === day const selected = availability.filter(
); (a: Availability) =>
setSelectedAvailability(selected[0]); 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) => { const handleOpenModal = (
selectAvailability(schedule, day) schedule: { start: string; end: string },
setIsModalOpen(true); 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);
const handleCloseModal = () => { try {
setSelectedAvailability(null); const res = await AvailabilityService.update(formData.id, apiPayload);
setIsModalOpen(false); console.log(res);
};
const handleEdit = async (formData:{ start_time: "", end_time: "", slot_minutes: "", appointment_type: "", id:""}) => { let message = "disponibilidade editada com sucesso";
if (isLoading) return; try {
setIsLoading(true); if (!res[0].id) {
throw new Error(
const apiPayload = { `${res.error} ${res.message}` || "A API retornou erro"
start_time: formData.start_time, );
end_time: formData.end_time, } else {
slot_minutes: formData.slot_minutes, console.log(message);
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()
} }
}; } 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 // Mapa de tradução
const weekdaysPT: Record<string, string> = { const weekdaysPT: Record<string, string> = {
@ -183,95 +229,96 @@ export default function AvailabilityPage() {
saturday: "Sábado", saturday: "Sábado",
}; };
const fetchData = async () => { 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 { try {
const res = await AvailabilityService.create(apiPayload); const loggedUser = await usersService.getMe();
console.log(res); 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();
let message = "disponibilidade cadastrada com sucesso"; // Filtra já com a variável local
try { const filteredAvail = availabilityList.filter(
if (!res[0].id) { (disp: { doctor_id: string }) => disp.doctor_id === doctor?.id
throw new Error(`${res.error} ${res.message}` || "A API retornou erro"); );
} else { setAvailability(filteredAvail);
console.log(message); } catch (e: any) {
} alert(`${e?.error} ${e?.message}`);
} catch {} }
};
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({ toast({
title: "Sucesso", title: "Sucesso",
@ -284,14 +331,18 @@ export default function AvailabilityPage() {
description: err?.message || "Não foi possível criar a disponibilidade", description: err?.message || "Não foi possível criar a disponibilidade",
}); });
} finally { } finally {
fetchData()
setIsLoading(false); setIsLoading(false);
} }
}; };
const openDeleteDialog = (schedule: { start: string; end: string;}, day: string) => { const openDeleteDialog = (
selectAvailability(schedule, day) schedule: { start: string; end: string },
setDeleteDialogOpen(true); day: string
}; ) => {
selectAvailability(schedule, day);
setDeleteDialogOpen(true);
};
const handleDeleteAvailability = async (AvailabilityId: string) => { const handleDeleteAvailability = async (AvailabilityId: string) => {
try { try {
@ -318,101 +369,176 @@ export default function AvailabilityPage() {
description: e?.message || "Não foi possível deletar a disponibilidade", description: e?.message || "Não foi possível deletar a disponibilidade",
}); });
} }
fetchData()
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setSelectedAvailability(null); setSelectedAvailability(null);
}; };
return ( return (
<Sidebar> <Sidebar>
<div className="space-y-6 flex-1 overflow-y-auto p-6"> <div className="space-y-6 flex-1 overflow-y-auto p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Definir Disponibilidade</h1> <h1 className="text-2xl font-bold text-gray-900">
<p className="text-gray-600">Defina sua disponibilidade para consultas </p> Definir Disponibilidade
</div> </h1>
<p className="text-gray-600">
Defina sua disponibilidade para consultas{" "}
</p>
</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 </h2>
<div className="space-y-6">
{/* **AJUSTE DE RESPONSIVIDADE: DIAS DA SEMANA** */}
<div>
<Label className="text-sm font-medium text-gray-700">
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-blue-600"
/>
<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-blue-600"
/>
<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-blue-600"
/>
<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-blue-600"
/>
<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-blue-600"
/>
<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-blue-600"
/>
<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-blue-600"
/>
<span className="whitespace-nowrap text-sm">Domingo</span>
</label>
</div> </div>
</div>
<form className="space-y-6" onSubmit={handleSubmit}> {/* **AJUSTE DE RESPONSIVIDADE: HORÁRIO E DURAÇÃO** */}
<div className="bg-white rounded-lg border border-gray-200 p-6"> {/* Ajustado para 1 coluna em móvel, 2 em tablet e 5 em desktop (mantendo o que já existia com ajustes) */}
<h2 className="text-lg font-semibold text-gray-900 mb-6">Dados </h2> <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 text-gray-700"
>
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 text-gray-700"
>
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 text-gray-700"
>
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 className="space-y-6"> <div>
{/* **AJUSTE DE RESPONSIVIDADE: DIAS DA SEMANA** */} <Label
<div> htmlFor="modalidadeConsulta"
<Label className="text-sm font-medium text-gray-700">Dia Da Semana</Label> className="text-sm font-medium text-gray-700"
{/* 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"> Modalidade De Consulta
<label className="flex items-center gap-1"> </Label>
<input type="radio" name="weekday" value="monday" className="text-blue-600" /> <Select
<span className="whitespace-nowrap text-sm">Segunda</span> onValueChange={(value) => setModalidadeConsulta(value)}
</label> value={modalidadeConsulta}
<label className="flex items-center gap-1"> >
<input type="radio" name="weekday" value="tuesday" className="text-blue-600" /> <SelectTrigger className="mt-1">
<span className="whitespace-nowrap text-sm">Terça</span> <SelectValue placeholder="Selecione" />
</label> </SelectTrigger>
<label className="flex items-center gap-1"> <SelectContent>
<input type="radio" name="weekday" value="wednesday" className="text-blue-600" /> <SelectItem value="presencial">Presencial </SelectItem>
<span className="whitespace-nowrap text-sm">Quarta</span> <SelectItem value="telemedicina">Telemedicina</SelectItem>
</label> </SelectContent>
<label className="flex items-center gap-1"> </Select>
<input type="radio" name="weekday" value="thursday" className="text-blue-600" /> </div>
<span className="whitespace-nowrap text-sm">Quinta</span> </div>
</label> </div>
<label className="flex items-center gap-1">
<input type="radio" name="weekday" value="friday" className="text-blue-600" />
<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-blue-600" />
<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-blue-600" />
<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 text-gray-700">
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 text-gray-700">
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 text-gray-700">
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 text-gray-700">
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** */} {/* **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 */} {/* Alinha à direita em telas maiores e empilha (com o botão primário no final) em telas menores */}
@ -453,7 +579,7 @@ export default function AvailabilityPage() {
<div key={i}> <div key={i}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<p className="text-sm text-gray-600 cursor-pointer p-1 rounded hover:text-accent-foreground hover:bg-gray-200 transition-colors duration-150"> <p className="text-sm text-gray-600 cursor-pointer rounded hover:text-accent-foreground hover:bg-gray-200 transition-colors duration-150">
{formatTime(t.start)} - {formatTime(t.end)} {formatTime(t.start)} - {formatTime(t.end)}
</p> </p>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

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

View File

@ -1,8 +1,6 @@
@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);
@ -75,33 +73,33 @@
} }
.high-contrast { .high-contrast {
--background: oklch(0 0 0); --background: oklch(0 0 0);
--foreground: oklch(1 0.5 100); --foreground: oklch(1 0.5 100);
--card: oklch(0 0 0); --card: oklch(0 0 0);
--card-foreground: oklch(1 0.5 100); --card-foreground: oklch(1 0.5 100);
--popover: oklch(0 0 0); --popover: oklch(0 0 0);
--popover-foreground: oklch(1 0.5 100); --popover-foreground: oklch(1 0.5 100);
--primary: oklch(1 0.5 100); --primary: oklch(1 0.5 100);
--primary-foreground: oklch(0 0 0); --primary-foreground: oklch(0 0 0);
--secondary: oklch(0 0 0); --secondary: oklch(0 0 0);
--secondary-foreground: oklch(1 0.5 100); --secondary-foreground: oklch(1 0.5 100);
--muted: oklch(0 0 0); --muted: oklch(0 0 0);
--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.5 0.3 30); --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);
--ring: oklch(1 0.5 100); --ring: oklch(1 0.5 100);
--sidebar: oklch(0 0 0); --sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(1 0.5 100); --sidebar-foreground: oklch(1 0.5 100);
--sidebar-primary: oklch(1 0.5 100); --sidebar-primary: oklch(1 0.5 100);
--sidebar-primary-foreground: oklch(0 0 0); --sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0 0 0); --sidebar-accent: oklch(0 0 0);
--sidebar-accent-foreground: oklch(1 0.5 100); --sidebar-accent-foreground: oklch(1 0.5 100);
--sidebar-border: oklch(1 0.5 100); --sidebar-border: oklch(1 0.5 100);
--sidebar-ring: oklch(1 0.5 100); --sidebar-ring: oklch(1 0.5 100);
} }
@theme inline { @theme inline {

View File

@ -1,6 +1,12 @@
"use client"; "use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 { Clock, Plus, User } from "lucide-react"; // Removi 'Calendar' que não estava sendo usado import { Clock, Plus, User } from "lucide-react"; // Removi 'Calendar' que não estava sendo usado
import Link from "next/link"; import Link from "next/link";
@ -11,13 +17,13 @@ import Sidebar from "@/components/Sidebar";
import { api } from "services/api.mjs"; // <-- ADICIONEI ESTE IMPORT import { api } from "services/api.mjs"; // <-- ADICIONEI ESTE IMPORT
export default function ManagerDashboard() { export default function ManagerDashboard() {
// 🔹 Estados para usuários // 🔹 Estados para usuários
const [firstUser, setFirstUser] = useState<any>(null); const [firstUser, setFirstUser] = useState<any>(null);
const [loadingUser, setLoadingUser] = useState(true); const [loadingUser, setLoadingUser] = useState(true);
// 🔹 Estados para médicos // 🔹 Estados para médicos
const [doctors, setDoctors] = useState<any[]>([]); const [doctors, setDoctors] = useState<any[]>([]);
const [loadingDoctors, setLoadingDoctors] = useState(true); const [loadingDoctors, setLoadingDoctors] = useState(true);
// 🔹 Buscar primeiro usuário (LÓGICA ATUALIZADA) // 🔹 Buscar primeiro usuário (LÓGICA ATUALIZADA)
useEffect(() => { useEffect(() => {
@ -62,148 +68,175 @@ export default function ManagerDashboard() {
} }
} }
fetchFirstUser(); fetchFirstUser();
}, []); }, []);
// 🔹 Buscar 3 primeiros médicos // 🔹 Buscar 3 primeiros médicos
useEffect(() => { useEffect(() => {
async function fetchDoctors() { async function fetchDoctors() {
try { try {
const data = await doctorsService.list(); // ajuste se seu service tiver outro método const data = await doctorsService.list(); // ajuste se seu service tiver outro método
if (Array.isArray(data)) { if (Array.isArray(data)) {
setDoctors(data.slice(0, 3)); // pega os 3 primeiros setDoctors(data.slice(0, 3)); // pega os 3 primeiros
}
} catch (error) {
console.error("Erro ao carregar médicos:", error);
} finally {
setLoadingDoctors(false);
}
} }
} catch (error) {
console.error("Erro ao carregar médicos:", error);
} finally {
setLoadingDoctors(false);
}
}
fetchDoctors(); fetchDoctors();
}, []); }, []);
return ( return (
<Sidebar> <Sidebar>
<div className="space-y-6"> <div className="space-y-6">
{/* Cabeçalho */} {/* Cabeçalho */}
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1> <h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600">Bem-vindo ao seu portal de consultas médicas</p> <p className="text-gray-600">
</div> Bem-vindo ao seu portal de consultas médicas
</p>
</div>
{/* Cards principais */} {/* 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 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">Gestão de usuários</CardTitle> <CardTitle className="text-sm font-medium">
<Clock className="h-4 w-4 text-muted-foreground" /> Gestão de usuários
</CardHeader> </CardTitle>
<CardContent> <Clock className="h-4 w-4 text-muted-foreground" />
{loadingUser ? ( </CardHeader>
<div className="text-gray-500 text-sm">Carregando usuário...</div> <CardContent>
) : firstUser ? ( {loadingUser ? (
<> <div className="text-gray-500 text-sm">
<div className="text-2xl font-bold">{firstUser.full_name || "Sem nome"}</div> Carregando usuário...
<p className="text-xs text-muted-foreground">
{firstUser.email || "Sem e-mail cadastrado"}
</p>
</>
) : (
<div className="text-sm text-gray-500">Nenhum usuário encontrado</div>
)}
</CardContent>
</Card>
{/* Card 3 — Perfil */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Perfil</CardTitle>
<User className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">100%</div>
<p className="text-xs text-muted-foreground">Dados completos</p>
</CardContent>
</Card>
</div> </div>
) : firstUser ? (
{/* Cards secundários */} <>
<div className="grid md:grid-cols-2 gap-6"> <div className="text-2xl font-bold">
{/* Card — Ações rápidas */} {firstUser.full_name || "Sem nome"}
<Card> </div>
<CardHeader> <p className="text-xs text-muted-foreground">
<CardTitle>Ações Rápidas</CardTitle> {firstUser.email || "Sem e-mail cadastrado"}
<CardDescription>Acesse rapidamente as principais funcionalidades</CardDescription> </p>
</CardHeader> </>
<CardContent className="space-y-4"> ) : (
<Link href="/manager/home"> <div className="text-sm text-gray-500">
<Button className="w-full justify-start"> Nenhum usuário encontrado
<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 bg-transparent">
<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 bg-transparent">
<Plus className="mr-2 h-4 w-4" />
Adicionar Novo Médico
</Button>
</Link>
<Link href="/manager/usuario/novo">
<Button variant="outline" className="w-full justify-start bg-transparent">
<Plus className="mr-2 h-4 w-4" />
Criar novo Usuário
</Button>
</Link>
</CardContent>
</Card>
{/* Card — Gestão de Médicos */}
<Card>
<CardHeader>
<CardTitle>Gestão de Médicos</CardTitle>
<CardDescription>Médicos cadastrados recentemente</CardDescription>
</CardHeader>
<CardContent>
{loadingDoctors ? (
<p className="text-sm text-gray-500">Carregando médicos...</p>
) : doctors.length === 0 ? (
<p className="text-sm text-gray-500">Nenhum médico cadastrado.</p>
) : (
<div className="space-y-4">
{doctors.map((doc, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-green-50 rounded-lg border border-green-100"
>
<div>
<p className="font-medium">{doc.full_name || "Sem nome"}</p>
<p className="text-sm text-gray-600">
{doc.specialty || "Sem especialidade"}
</p>
</div>
<div className="text-right">
<p className="font-medium text-green-700">
{doc.active ? "Ativo" : "Inativo"}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div> </div>
</div> )}
</Sidebar> </CardContent>
); </Card>
{/* Card 3 — Perfil */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Perfil</CardTitle>
<User className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">100%</div>
<p className="text-xs text-muted-foreground">Dados completos</p>
</CardContent>
</Card>
</div>
{/* Cards secundários */}
<div className="grid md:grid-cols-2 gap-6">
{/* Card — Ações rápidas */}
<Card>
<CardHeader>
<CardTitle>Ações Rápidas</CardTitle>
<CardDescription>
Acesse rapidamente as principais funcionalidades
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Link href="/manager/home">
<Button className="w-full justify-start bg-blue-600 text-white hover:bg-blue-700">
<User className="mr-2 h-4 w-4 text-white" />
Gestão de Médicos
</Button>
</Link>
<Link href="/manager/usuario">
<Button
variant="outline"
className="w-full justify-start bg-transparent"
>
<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 bg-transparent"
>
<Plus className="mr-2 h-4 w-4" />
Adicionar Novo Médico
</Button>
</Link>
<Link href="/manager/usuario/novo">
<Button
variant="outline"
className="w-full justify-start bg-transparent"
>
<Plus className="mr-2 h-4 w-4" />
Criar novo Usuário
</Button>
</Link>
</CardContent>
</Card>
{/* Card — Gestão de Médicos */}
<Card>
<CardHeader>
<CardTitle>Gestão de Médicos</CardTitle>
<CardDescription>
Médicos cadastrados recentemente
</CardDescription>
</CardHeader>
<CardContent>
{loadingDoctors ? (
<p className="text-sm text-gray-500">Carregando médicos...</p>
) : doctors.length === 0 ? (
<p className="text-sm text-gray-500">
Nenhum médico cadastrado.
</p>
) : (
<div className="space-y-4">
{doctors.map((doc, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-green-50 rounded-lg border border-green-100"
>
<div>
<p className="font-medium">
{doc.full_name || "Sem nome"}
</p>
<p className="text-sm text-gray-600">
{doc.specialty || "Sem especialidade"}
</p>
</div>
<div className="text-right">
<p className="font-medium text-green-700">
{doc.active ? "Ativo" : "Inativo"}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
</Sidebar>
);
} }

View File

@ -0,0 +1,185 @@
"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-gray-500">Carregando dados...</div>
</Sidebar>
);
}
if (!doctors || !availabilities) {
return (
<Sidebar>
<div className="p-6 text-red-600 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 text-gray-900">Disponibilidade dos Médicos</h1>
<p className="text-gray-600">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-gray-700 font-medium">
Página {page} de {totalPages}
</span>
<Button variant="outline" onClick={goNext} disabled={page === totalPages}>
Próxima
</Button>
</div>
)}
</div>
</Sidebar>
);
}

View File

@ -1,79 +1,91 @@
"use client"; "use client";
import React, { useEffect, useState, useCallback, useMemo } from "react" import React, { useEffect, useState, useCallback, useMemo } from "react";
import Link from "next/link" 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 } from "lucide-react" import { Edit, Trash2, Eye, Calendar, 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"; // Imports dos Serviços
import { doctorsService } from "@/services/doctorsApi.mjs";
import Sidebar from "@/components/Sidebar"; 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;
full_name: string; full_name: string;
specialty: string; specialty: string;
crm: string; crm: string;
phone_mobile: string | null; phone_mobile: string | null;
city: string | null; city: string | null;
state: string | null; state: string | null;
status?: string; 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;
ultimo_atendimento?: string; ultimo_atendimento?: string;
proximo_atendimento?: string; proximo_atendimento?: string;
error?: string; error?: string;
} }
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 para Filtros --- // --- Estados de Filtro e Busca ---
const [specialtyFilter, setSpecialtyFilter] = useState("all"); const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all"); const [filters, setFilters] = useState({
specialty: "all",
status: "all"
});
// --- Estados para Paginação --- // --- Estados de Paginação ---
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
const [currentPage, setCurrentPage] = useState(1); 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)
const dataWithStatus = data.map((doc, index) => ({ const dataWithStatus = data.map((doc, index) => ({
...doc, ...doc,
status: index % 3 === 0 ? "Inativo" : index % 2 === 0 ? "Férias" : "Ativo", status: index % 3 === 0 ? "Inativo" : index % 2 === 0 ? "Férias" : "Ativo",
})); }));
setDoctors(dataWithStatus || []); setDoctors(dataWithStatus || []);
setCurrentPage(1); // 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.");
@ -83,16 +95,99 @@ export default function DoctorsPage() {
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchDoctors(); fetchDoctors();
}, [fetchDoctors]); }, [fetchDoctors]);
const openDetailsDialog = async (doctor: Doctor) => { // 2. Gerar lista única de especialidades (Normalizada)
const uniqueSpecialties = useMemo(() => {
return getUniqueSpecialties(doctors);
}, [doctors]);
// 3. Lógica de Filtragem Centralizada
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: doctor.specialty, especialidade: normalizeSpecialty(doctor.specialty), // Exibe normalizado
contato: { celular: doctor.phone_mobile ?? undefined }, contato: { celular: doctor.phone_mobile ?? undefined },
endereco: { cidade: doctor.city ?? undefined, estado: doctor.state ?? undefined }, endereco: { cidade: doctor.city ?? undefined, estado: doctor.state ?? undefined },
status: doctor.status || "Ativo", status: doctor.status || "Ativo",
@ -103,6 +198,11 @@ export default function DoctorsPage() {
}); });
}; };
const openDeleteDialog = (doctorId: number) => {
setDoctorToDeleteId(doctorId);
setDeleteDialogOpen(true);
};
const handleDelete = async () => { const handleDelete = async () => {
if (doctorToDeleteId === null) return; if (doctorToDeleteId === null) return;
setLoading(true); setLoading(true);
@ -119,128 +219,58 @@ export default function DoctorsPage() {
} }
}; };
const openDeleteDialog = (doctorId: number) => { return (
setDoctorToDeleteId(doctorId); <Sidebar>
setDeleteDialogOpen(true); <div className="space-y-6 px-2 sm:px-4 md:px-6">
}; {/* Cabeçalho */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-gray-900">
Médicos Cadastrados
</h1>
<p className="text-sm text-gray-500">
Gerencie todos os profissionais de saúde.
</p>
</div>
</div>
const uniqueSpecialties = useMemo(() => { {/* --- NOVO COMPONENTE DE FILTRO --- */}
const specialties = doctors.map((doctor) => doctor.specialty).filter(Boolean); <FilterBar
return [...new Set(specialties)]; searchTerm={searchTerm}
}, [doctors]); onSearch={handleSearch}
activeFilters={filters}
const filteredDoctors = doctors.filter((doctor) => { onFilterChange={handleFilterChange}
const specialtyMatch = specialtyFilter === "all" || doctor.specialty === specialtyFilter; onClearFilters={handleClearFilters}
const statusMatch = statusFilter === "all" || doctor.status === statusFilter; searchPlaceholder="Buscar por nome, CRM ou telefone..."
return specialtyMatch && statusMatch; filters={[
}); {
key: "specialty",
const totalPages = Math.ceil(filteredDoctors.length / itemsPerPage); label: "Especialidade",
const indexOfLastItem = currentPage * itemsPerPage; options: uniqueSpecialties
const indexOfFirstItem = indexOfLastItem - itemsPerPage; },
const currentItems = filteredDoctors.slice(indexOfFirstItem, indexOfLastItem); {
const paginate = (pageNumber: number) => setCurrentPage(pageNumber); key: "status",
label: "Status",
const goToPrevPage = () => { options: ["Ativo", "Férias", "Inativo"]
setCurrentPage((prev) => Math.max(1, prev - 1)); }
}; ]}
>
const goToNextPage = () => { {/* Seletor de Itens por Página (Filho do FilterBar) */}
setCurrentPage((prev) => Math.min(totalPages, prev + 1)); <div className="hidden lg:block">
};
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);
};
return (
<Sidebar>
<div className="space-y-6 px-2 sm:px-4 md:px-6">
{/* Cabeçalho */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-gray-900">Médicos Cadastrados</h1>
<p className="text-sm text-gray-500">Gerencie todos os profissionais de saúde.</p>
</div>
</div>
{/* Filtros e Itens por Página */}
<div className="flex flex-wrap items-center gap-3 bg-white p-3 sm:p-4 rounded-lg border border-gray-200">
<div className="flex items-center gap-2 w-full md:w-auto">
<span className="text-sm font-medium text-foreground">Especialidade</span>
<Select value={specialtyFilter} onValueChange={setSpecialtyFilter}>
<SelectTrigger className="w-[160px] sm:w-[180px]">
<SelectValue placeholder="Especialidade" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas</SelectItem>
{uniqueSpecialties.map((specialty) => (
<SelectItem key={specialty} value={specialty}>
{specialty}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 w-full md:w-auto">
<span className="text-sm font-medium text-foreground">Status</span>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[160px] sm:w-[180px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="Ativo">Ativo</SelectItem>
<SelectItem value="Férias">Férias</SelectItem>
<SelectItem value="Inativo">Inativo</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 w-full md:w-auto">
<span className="text-sm font-medium text-foreground">Itens por página</span>
<Select onValueChange={handleItemsPerPageChange} defaultValue={String(itemsPerPage)}> <Select onValueChange={handleItemsPerPageChange} defaultValue={String(itemsPerPage)}>
<SelectTrigger className="w-[140px]"> <SelectTrigger className="w-[70px]">
<SelectValue placeholder="Itens por pág." /> <SelectValue placeholder="10" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="5">5 por página</SelectItem> <SelectItem value="5">5</SelectItem>
<SelectItem value="10">10 por página</SelectItem> <SelectItem value="10">10</SelectItem>
<SelectItem value="20">20 por página</SelectItem> <SelectItem value="20">20</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Button variant="outline" className="ml-auto w-full md:w-auto"> </FilterBar>
<Filter className="w-4 h-4 mr-2" />
Filtro avançado
</Button>
</div>
{/* Tabela de Médicos (Visível em Telas Médias e Maiores) */} {/* Tabela de Médicos */}
<div className="bg-white rounded-lg border border-gray-200 shadow-md overflow-hidden hidden md:block"> <div className="bg-white rounded-lg border border-gray-200 shadow-md overflow-hidden hidden md:block">
{loading ? ( {loading ? (
<div className="p-8 text-center text-gray-500"> <div className="p-8 text-center text-gray-500">
@ -272,10 +302,22 @@ export default function DoctorsPage() {
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{currentItems.map((doctor) => ( {currentItems.map((doctor) => (
<tr key={doctor.id} className="hover:bg-gray-50 transition"> <tr key={doctor.id} className="hover:bg-gray-50 transition">
<td className="px-4 py-3 font-medium text-gray-900">{doctor.full_name}</td> <td className="px-4 py-3 font-medium text-gray-900">
{doctor.full_name}
</td>
<td className="px-4 py-3 text-gray-500 hidden sm:table-cell">{doctor.crm}</td> <td className="px-4 py-3 text-gray-500 hidden sm:table-cell">{doctor.crm}</td>
<td className="px-4 py-3 text-gray-500 hidden md:table-cell">{doctor.specialty}</td> <td className="px-4 py-3 text-gray-500 hidden md:table-cell">
<td className="px-4 py-3 text-gray-500 hidden lg:table-cell">{doctor.status || "N/A"}</td> {/* Exibe Especialidade Normalizada */}
{normalizeSpecialty(doctor.specialty)}
</td>
<td className="px-4 py-3 text-gray-500 hidden lg:table-cell">
<span className={`px-2 py-1 rounded-full text-xs ${
doctor.status === 'Ativo' ? 'bg-green-100 text-green-800' :
doctor.status === 'Inativo' ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'
}`}>
{doctor.status || "N/A"}
</span>
</td>
<td className="px-4 py-3 text-gray-500 hidden xl:table-cell"> <td className="px-4 py-3 text-gray-500 hidden xl:table-cell">
{(doctor.city || doctor.state) {(doctor.city || doctor.state)
? `${doctor.city || ""}${doctor.city && doctor.state ? '/' : ''}${doctor.state || ""}` ? `${doctor.city || ""}${doctor.city && doctor.state ? '/' : ''}${doctor.state || ""}`
@ -284,7 +326,7 @@ export default function DoctorsPage() {
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<div className="text-blue-600 cursor-pointer inline-block">Ações</div> <div className="text-blue-600 cursor-pointer inline-block hover:underline">Ações</div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openDetailsDialog(doctor)}> <DropdownMenuItem onClick={() => openDetailsDialog(doctor)}>
@ -316,7 +358,7 @@ export default function DoctorsPage() {
)} )}
</div> </div>
{/* Cards de Médicos (Visível Apenas em Telas Pequenas) */} {/* Cards de Médicos (Mobile) */}
<div className="bg-white rounded-lg border border-gray-200 shadow-md p-4 block md:hidden"> <div className="bg-white rounded-lg border border-gray-200 shadow-md p-4 block md:hidden">
{loading ? ( {loading ? (
<div className="p-8 text-center text-gray-500"> <div className="p-8 text-center text-gray-500">
@ -335,14 +377,26 @@ export default function DoctorsPage() {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{currentItems.map((doctor) => ( {currentItems.map((doctor) => (
<div key={doctor.id} className="bg-white-50 rounded-lg p-4 flex justify-between items-center border border-white-200"> <div key={doctor.id} className="bg-gray-50 rounded-lg p-4 flex justify-between items-center border border-gray-100">
<div> <div>
<div className="font-semibold text-gray-900">{doctor.full_name}</div> <div className="font-semibold text-gray-900">{doctor.full_name}</div>
<div className="text-sm text-gray-600">{doctor.specialty}</div> <div className="text-xs text-gray-500 mb-1">{doctor.phone_mobile}</div>
<div className="text-sm text-gray-600">{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-green-100 text-green-800' :
doctor.status === 'Inativo' ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'
}`}>
{doctor.status || "N/A"}
</span>
</div>
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<div className="text-blue-600 cursor-pointer inline-block">Ações</div> <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<span className="sr-only">Abrir menu</span>
<div className="font-bold text-gray-500">...</div>
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openDetailsDialog(doctor)}> <DropdownMenuItem onClick={() => openDetailsDialog(doctor)}>
@ -355,10 +409,6 @@ export default function DoctorsPage() {
Editar Editar
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem>
<Calendar className="mr-2 h-4 w-4" />
Marcar consulta
</DropdownMenuItem>
<DropdownMenuItem className="text-red-600" onClick={() => openDeleteDialog(doctor.id)}> <DropdownMenuItem className="text-red-600" onClick={() => openDeleteDialog(doctor.id)}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Excluir Excluir
@ -371,42 +421,42 @@ export default function DoctorsPage() {
)} )}
</div> </div>
{/* Paginação */} {/* Paginação */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex flex-wrap justify-center items-center gap-2 mt-4 p-4 bg-white rounded-lg border border-gray-200 shadow-md"> <div className="flex flex-wrap justify-center items-center gap-2 mt-4 p-4 bg-white rounded-lg border border-gray-200 shadow-md">
<button <button
onClick={goToPrevPage} onClick={goToPrevPage}
disabled={currentPage === 1} disabled={currentPage === 1}
className="flex items-center px-4 py-2 rounded-md font-medium transition-colors text-sm bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed border border-gray-300" className="flex items-center px-4 py-2 rounded-md font-medium transition-colors text-sm bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed border border-gray-300"
> >
{"< Anterior"} {"< Anterior"}
</button> </button>
{visiblePageNumbers.map((number) => ( {visiblePageNumbers.map((number) => (
<button <button
key={number} key={number}
onClick={() => paginate(number)} onClick={() => paginate(number)}
className={`px-4 py-2 rounded-md font-medium transition-colors text-sm border border-gray-300 ${ className={`px-4 py-2 rounded-md font-medium transition-colors text-sm border border-gray-300 ${
currentPage === number currentPage === number
? "bg-green-600 text-white shadow-md border-green-600" ? "bg-blue-600 text-white shadow-md border-blue-600"
: "bg-gray-100 text-gray-700 hover:bg-gray-200" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`} }`}
> >
{number} {number}
</button> </button>
))} ))}
<button <button
onClick={goToNextPage} onClick={goToNextPage}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="flex items-center px-4 py-2 rounded-md font-medium transition-colors text-sm bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed border border-gray-300" className="flex items-center px-4 py-2 rounded-md font-medium transition-colors text-sm bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed border border-gray-300"
> >
{"Próximo >"} {"Próximo >"}
</button> </button>
</div> </div>
)} )}
{/* Dialogs de Exclusão e Detalhes */} {/* Dialogs (Exclusão e Detalhes) */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
@ -423,58 +473,78 @@ export default function DoctorsPage() {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<AlertDialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}> <AlertDialog
<AlertDialogContent className="max-w-[95%] sm:max-w-lg"> open={detailsDialogOpen}
<AlertDialogHeader> onOpenChange={setDetailsDialogOpen}
<AlertDialogTitle className="text-2xl">{doctorDetails?.nome}</AlertDialogTitle> >
<AlertDialogDescription className="text-left text-gray-700"> <AlertDialogContent className="max-w-[95%] sm:max-w-lg">
{doctorDetails && ( <AlertDialogHeader>
<div className="space-y-3 text-left"> <AlertDialogTitle className="text-2xl">
<h3 className="font-semibold mt-2">Informações Principais</h3> {doctorDetails?.nome}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-y-2 gap-x-4 text-sm"> </AlertDialogTitle>
<div> <AlertDialogDescription className="text-left text-gray-700">
<strong>CRM:</strong> {doctorDetails.crm} {doctorDetails && (
</div> <div className="space-y-3 text-left">
<div> <h3 className="font-semibold mt-2">
<strong>Especialidade:</strong> {doctorDetails.especialidade} Informações Principais
</div> </h3>
<div> <div className="grid grid-cols-1 sm:grid-cols-2 gap-y-2 gap-x-4 text-sm">
<strong>Celular:</strong> {doctorDetails.contato.celular || "N/A"} <div>
</div> <strong>CRM:</strong> {doctorDetails.crm}
<div> </div>
<strong>Localização:</strong> {`${doctorDetails.endereco.cidade || "N/A"}/${doctorDetails.endereco.estado || "N/A"}`} <div>
</div> <strong>Especialidade:</strong>{" "}
</div> {doctorDetails.especialidade}
</div>
<div>
<strong>Celular:</strong>{" "}
{doctorDetails.contato.celular || "N/A"}
</div>
<div>
<strong>Localização:</strong>{" "}
{`${doctorDetails.endereco.cidade || "N/A"}/${
doctorDetails.endereco.estado || "N/A"
}`}
</div>
</div>
<h3 className="font-semibold mt-4">Atendimento e Convênio</h3> <h3 className="font-semibold mt-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-y-2 gap-x-4 text-sm"> Atendimento e Convênio
<div> </h3>
<strong>Convênio:</strong> {doctorDetails.convenio || "N/A"} <div className="grid grid-cols-1 sm:grid-cols-2 gap-y-2 gap-x-4 text-sm">
</div> <div>
<div> <strong>Convênio:</strong>{" "}
<strong>VIP:</strong> {doctorDetails.vip ? "Sim" : "Não"} {doctorDetails.convenio || "N/A"}
</div> </div>
<div> <div>
<strong>Status:</strong> {doctorDetails.status || "N/A"} <strong>VIP:</strong>{" "}
</div> {doctorDetails.vip ? "Sim" : "Não"}
<div> </div>
<strong>Último atendimento:</strong> {doctorDetails.ultimo_atendimento || "N/A"} <div>
</div> <strong>Status:</strong> {doctorDetails.status || "N/A"}
<div> </div>
<strong>Próximo atendimento:</strong> {doctorDetails.proximo_atendimento || "N/A"} <div>
</div> <strong>Último atendimento:</strong>{" "}
</div> {doctorDetails.ultimo_atendimento || "N/A"}
</div> </div>
)} <div>
{doctorDetails === null && !loading && <div className="text-red-600">Detalhes não disponíveis.</div>} <strong>Próximo atendimento:</strong>{" "}
</AlertDialogDescription> {doctorDetails.proximo_atendimento || "N/A"}
</AlertDialogHeader> </div>
<AlertDialogFooter> </div>
<AlertDialogCancel>Fechar</AlertDialogCancel> </div>
</AlertDialogFooter> )}
</AlertDialogContent> {doctorDetails === null && !loading && (
</AlertDialog> <div className="text-red-600">Detalhes não disponíveis.</div>
</div> )}
</Sidebar> </AlertDialogDescription>
); </AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Fechar</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</Sidebar>
);
} }

View File

@ -3,10 +3,30 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, 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 {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 } from "lucide-react"; import { Edit, Trash2, Eye, Calendar, Filter, Loader2 } from "lucide-react";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { patientsService } from "@/services/patientsApi.mjs"; import { patientsService } from "@/services/patientsApi.mjs";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
@ -68,16 +88,14 @@ export default function PacientesPage() {
status: p.status ?? undefined, status: p.status ?? undefined,
})); }));
setAllPatients(mapped); setAllPatients(mapped);
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
setError(e?.message || "Erro ao buscar pacientes"); setError(e?.message || "Erro ao buscar pacientes");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, }, []);
[]
);
// 2. Efeito para aplicar filtros e calcular a lista filtrada (chama-se quando allPatients ou filtros mudam) // 2. Efeito para aplicar filtros e calcular a lista filtrada (chama-se quando allPatients ou filtros mudam)
useEffect(() => { useEffect(() => {
@ -106,12 +124,11 @@ export default function PacientesPage() {
setPage(1); setPage(1);
}, [allPatients, searchTerm, convenioFilter, vipFilter]); }, [allPatients, searchTerm, convenioFilter, vipFilter]);
// 3. Efeito inicial para buscar os pacientes // 3. Efeito inicial para buscar os pacientes
useEffect(() => { useEffect(() => {
fetchAllPacientes(); fetchAllPacientes();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// --- LÓGICA DE AÇÕES (DELETAR / VER DETALHES) --- // --- LÓGICA DE AÇÕES (DELETAR / VER DETALHES) ---
@ -126,33 +143,39 @@ export default function PacientesPage() {
} }
}; };
const handleDeletePatient = async (patientId: string) => { const handleDeletePatient = async (patientId: string) => {
try { try {
await patientsService.delete(patientId); await patientsService.delete(patientId);
// Atualiza a lista completa para refletir a exclusão // Atualiza a lista completa para refletir a exclusão
setAllPatients((prev) => prev.filter((p) => String(p.id) !== String(patientId))); setAllPatients((prev) =>
} catch (e: any) { prev.filter((p) => String(p.id) !== String(patientId))
alert(`Erro ao deletar paciente: ${e?.message || 'Erro desconhecido'}`); );
} } catch (e: any) {
setDeleteDialogOpen(false); alert(`Erro ao deletar paciente: ${e?.message || "Erro desconhecido"}`);
setPatientToDelete(null); }
}; setDeleteDialogOpen(false);
setPatientToDelete(null);
};
const openDeleteDialog = (patientId: string) => { const openDeleteDialog = (patientId: string) => {
setPatientToDelete(patientId); setPatientToDelete(patientId);
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
}; };
return ( return (
<Sidebar> <Sidebar>
<div className="space-y-6 px-2 sm:px-4 md:px-8"> <div className="space-y-6 px-2 sm:px-4 md:px-8">
{/* Header (Responsividade OK) */} {/* 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 text-foreground">Pacientes</h1> <h1 className="text-xl md:text-2xl font-bold text-foreground">
<p className="text-muted-foreground text-sm md:text-base">Gerencie as informações de seus pacientes</p> Pacientes
</div> </h1>
</div> <p className="text-muted-foreground text-sm md:text-base">
Gerencie as informações de seus pacientes
</p>
</div>
</div>
{/* Bloco de Filtros (Responsividade APLICADA) */} {/* Bloco de Filtros (Responsividade APLICADA) */}
{/* Adicionado flex-wrap para permitir que os itens quebrem para a linha de baixo */} {/* Adicionado flex-wrap para permitir que os itens quebrem para a linha de baixo */}
@ -169,22 +192,26 @@ export default function PacientesPage() {
className="w-full sm:flex-grow sm:max-w-[300px] p-2 border rounded-md text-sm" className="w-full sm:flex-grow sm:max-w-[300px] p-2 border rounded-md text-sm"
/> />
{/* Convênio - Ocupa a largura total em telas pequenas, depois se ajusta */} {/* 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]"> <div className="flex items-center gap-2 w-full sm:w-auto sm:flex-grow sm:max-w-[200px]">
<span className="text-sm font-medium text-foreground whitespace-nowrap hidden md:block">Convênio</span> <span className="text-sm font-medium text-foreground whitespace-nowrap hidden md:block">
<Select value={convenioFilter} onValueChange={setConvenioFilter}> Convênio
<SelectTrigger className="w-full sm:w-40"> {/* w-full para mobile, w-40 para sm+ */} </span>
<SelectValue placeholder="Convênio" /> <Select value={convenioFilter} onValueChange={setConvenioFilter}>
</SelectTrigger> <SelectTrigger className="w-full sm:w-40">
<SelectContent> {" "}
<SelectItem value="all">Todos</SelectItem> {/* w-full para mobile, w-40 para sm+ */}
<SelectItem value="Particular">Particular</SelectItem> <SelectValue placeholder="Convênio" />
<SelectItem value="SUS">SUS</SelectItem> </SelectTrigger>
<SelectItem value="Unimed">Unimed</SelectItem> <SelectContent>
{/* Adicione outros convênios conforme necessário */} <SelectItem value="all">Todos</SelectItem>
</SelectContent> <SelectItem value="Particular">Particular</SelectItem>
</Select> <SelectItem value="SUS">SUS</SelectItem>
</div> <SelectItem value="Unimed">Unimed</SelectItem>
{/* Adicione outros convênios conforme necessário */}
</SelectContent>
</Select>
</div>
{/* VIP - Ocupa a largura total em telas pequenas, depois se ajusta */} {/* VIP - Ocupa a largura total em telas pequenas, depois se ajusta */}
<div className="flex items-center gap-2 w-full sm:w-auto sm:flex-grow sm:max-w-[150px]"> <div className="flex items-center gap-2 w-full sm:w-auto sm:flex-grow sm:max-w-[150px]">
@ -272,32 +299,40 @@ export default function PacientesPage() {
Ver detalhes Ver detalhes
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/secretary/pacientes/${patient.id}/editar`} className="flex items-center w-full"> <Link
<Edit className="w-4 h-4 mr-2" /> href={`/secretary/pacientes/${patient.id}/editar`}
Editar className="flex items-center w-full"
</Link> >
</DropdownMenuItem> <Edit className="w-4 h-4 mr-2" />
Editar
</Link>
</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 className="text-red-600" onClick={() => openDeleteDialog(String(patient.id))}> <DropdownMenuItem
<Trash2 className="w-4 h-4 mr-2" /> className="text-red-600"
Excluir onClick={() =>
</DropdownMenuItem> openDeleteDialog(String(patient.id))
</DropdownMenuContent> }
</DropdownMenu> >
</td> <Trash2 className="w-4 h-4 mr-2" />
</tr> Excluir
)) </DropdownMenuItem>
)} </DropdownMenuContent>
</tbody> </DropdownMenu>
</table> </td>
)} </tr>
</div> ))
</div> )}
</tbody>
</table>
)}
</div>
</div>
{/* --- SEÇÃO DE CARDS (VISÍVEL APENAS EM TELAS MENORES QUE MD) --- */} {/* --- 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+ */} {/* Garantir que os cards apareçam em telas menores e se escondam em MD+ */}

View File

@ -4,26 +4,27 @@ import React, { useEffect, useState, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Eye, Filter, Loader2 } from "lucide-react"; import { Input } from "@/components/ui/input"; // <--- 1. Importação Adicionada
import { Plus, Eye, Filter, Loader2, Search } from "lucide-react"; // <--- 1. Ícone Search Adicionado
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { api, login } from "services/api.mjs"; import { api, login } from "services/api.mjs";
import { usersService } from "services/usersApi.mjs"; import { usersService } from "services/usersApi.mjs";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
interface FlatUser { interface FlatUser {
id: string; id: string;
user_id: string; user_id: string;
full_name?: string; full_name?: string;
email: string; email: string;
phone?: string | null; phone?: string | null;
role: string; role: string;
} }
interface UserInfoResponse { interface UserInfoResponse {
user: any; user: any;
profile: any; profile: any;
roles: string[]; roles: string[];
permissions: Record<string, boolean>; permissions: Record<string, boolean>;
} }
export default function UsersPage() { export default function UsersPage() {
@ -31,265 +32,297 @@ export default function UsersPage() {
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>( const [userDetails, setUserDetails] = useState<UserInfoResponse | null>(null);
null
); // --- Estados de Filtro ---
const [searchTerm, setSearchTerm] = useState(""); // <--- 2. Estado da busca
const [selectedRole, setSelectedRole] = useState<string>("all"); const [selectedRole, setSelectedRole] = useState<string>("all");
// --- Lógica de Paginação INÍCIO --- // --- Lógica de Paginação INÍCIO ---
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const handleItemsPerPageChange = (value: string) => { const handleItemsPerPageChange = (value: string) => {
setItemsPerPage(Number(value)); setItemsPerPage(Number(value));
setCurrentPage(1); setCurrentPage(1);
}; };
// --- Lógica de Paginação FIM --- // --- 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 rolesData: any[] = await usersService.list_roles();
const rolesArray = Array.isArray(rolesData) ? rolesData : []; const rolesArray = Array.isArray(rolesData) ? rolesData : [];
const profilesData: any[] = await api.get( const profilesData: any[] = await api.get(
`/rest/v1/profiles?select=id,full_name,email,phone` `/rest/v1/profiles?select=id,full_name,email,phone`
); );
const profilesById = new Map<string, any>(); const profilesById = new Map<string, any>();
if (Array.isArray(profilesData)) { if (Array.isArray(profilesData)) {
for (const p of profilesData) { for (const p of profilesData) {
if (p?.id) profilesById.set(p.id, p); if (p?.id) profilesById.set(p.id, p);
}
}
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) {
console.error("Erro ao buscar usuários:", err);
setError("Não foi possível carregar os usuários. Veja console.");
setUsers([]);
} finally {
setLoading(false);
} }
}, []); }
useEffect(() => { const mapped: FlatUser[] = rolesArray.map((roleItem) => {
const init = async () => { const uid = roleItem.user_id;
try { const profile = profilesById.get(uid);
await login(); return {
} catch (e) { id: uid,
console.warn("login falhou no init:", e); user_id: uid,
} full_name: profile?.full_name ?? "—",
await fetchUsers(); email: profile?.email ?? "—",
phone: profile?.phone ?? "—",
role: roleItem.role ?? "—",
}; };
init(); });
}, [fetchUsers]);
const openDetailsDialog = async (flatUser: FlatUser) => { setUsers(mapped);
setDetailsDialogOpen(true); setCurrentPage(1);
setUserDetails(null); } catch (err: any) {
console.error("Erro ao buscar usuários:", err);
setError("Não foi possível carregar os usuários. Veja console.");
setUsers([]);
} finally {
setLoading(false);
}
}, []);
try { useEffect(() => {
const data = await usersService.full_data(flatUser.user_id); const init = async () => {
setUserDetails(data); try {
} catch (err: any) { await login();
console.error("Erro ao carregar detalhes:", err); } catch (e) {
setUserDetails({ console.warn("login falhou no init:", e);
user: { id: flatUser.user_id, email: flatUser.email }, }
profile: { full_name: flatUser.full_name, phone: flatUser.phone }, await fetchUsers();
roles: [flatUser.role],
permissions: {},
});
}
}; };
init();
}, [fetchUsers]);
const filteredUsers = const openDetailsDialog = async (flatUser: FlatUser) => {
selectedRole && selectedRole !== "all" setDetailsDialogOpen(true);
? users.filter((u) => u.role === selectedRole) setUserDetails(null);
: users;
const indexOfLastItem = currentPage * itemsPerPage; try {
const indexOfFirstItem = indexOfLastItem - itemsPerPage; const data = await usersService.full_data(flatUser.user_id);
const currentItems = filteredUsers.slice(indexOfFirstItem, indexOfLastItem); setUserDetails(data);
} catch (err: any) {
console.error("Erro ao carregar detalhes:", err);
setUserDetails({
user: { id: flatUser.user_id, email: flatUser.email },
profile: { full_name: flatUser.full_name, phone: flatUser.phone },
roles: [flatUser.role],
permissions: {},
});
}
};
const paginate = (pageNumber: number) => setCurrentPage(pageNumber); // --- 3. Lógica de Filtragem Atualizada ---
const filteredUsers = users.filter((u) => {
// Filtro por Papel (Role)
const roleMatch = selectedRole === "all" || u.role === selectedRole;
const totalPages = Math.ceil(filteredUsers.length / itemsPerPage); // 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 goToPrevPage = () => { const searchMatch = !searchTerm || nameMatch || emailMatch || phoneMatch;
setCurrentPage((prev) => Math.max(1, prev - 1));
};
const goToNextPage = () => { return roleMatch && searchMatch;
setCurrentPage((prev) => Math.min(totalPages, prev + 1)); });
};
const getVisiblePageNumbers = (totalPages: number, currentPage: number) => { const indexOfLastItem = currentPage * itemsPerPage;
const pages: number[] = []; const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const maxVisiblePages = 5; const currentItems = filteredUsers.slice(indexOfFirstItem, indexOfLastItem);
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) { const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
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++) { const totalPages = Math.ceil(filteredUsers.length / itemsPerPage);
pages.push(i);
}
return pages;
};
const visiblePageNumbers = getVisiblePageNumbers(totalPages, currentPage); const goToPrevPage = () => {
setCurrentPage((prev) => Math.max(1, prev - 1));
};
return ( const goToNextPage = () => {
<Sidebar> setCurrentPage((prev) => Math.min(totalPages, prev + 1));
<div className="space-y-6 px-2 sm:px-4 md:px-8"> };
{/* Header */} const getVisiblePageNumbers = (totalPages: number, currentPage: number) => {
<div className="flex flex-wrap items-center justify-between gap-3"> const pages: number[] = [];
<div> const maxVisiblePages = 5;
<h1 className="text-2xl font-bold text-gray-900">Usuários</h1> const halfRange = Math.floor(maxVisiblePages / 2);
<p className="text-sm text-gray-500">Gerencie usuários.</p> let startPage = Math.max(1, currentPage - halfRange);
</div> let endPage = Math.min(totalPages, currentPage + halfRange);
<Link href="/manager/usuario/novo" className="w-full sm:w-auto">
<Button className="w-full sm:w-auto bg-green-600 hover:bg-green-700">
<Plus className="w-4 h-4 mr-2" /> Novo Usuário
</Button>
</Link>
</div>
{/* Filtro e Itens por Página */} if (endPage - startPage + 1 < maxVisiblePages) {
<div className="flex flex-wrap items-center gap-3 bg-white p-4 rounded-lg border border-gray-200"> if (endPage === totalPages) {
startPage = Math.max(1, totalPages - maxVisiblePages + 1);
}
if (startPage === 1) {
endPage = Math.min(totalPages, maxVisiblePages);
}
}
{/* Select de Filtro por Papel - Ajustado para resetar a página */} for (let i = startPage; i <= endPage; i++) {
<div className="flex items-center gap-2 w-full md:w-auto"> pages.push(i);
<span className="text-sm font-medium text-foreground whitespace-nowrap"> }
Filtrar por papel return pages;
</span> };
<Select
onValueChange={(value) => { const visiblePageNumbers = getVisiblePageNumbers(totalPages, currentPage);
setSelectedRole(value);
setCurrentPage(1); return (
<Sidebar>
<div className="space-y-6 px-2 sm:px-4 md:px-8">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-gray-900">Usuários</h1>
<p className="text-sm text-gray-500">Gerencie usuários.</p>
</div>
<Link href="/manager/usuario/novo" className="w-full sm:w-auto">
<Button className="w-full sm:w-auto bg-blue-600 hover:bg-blue-700">
<Plus className="w-4 h-4 mr-2" /> Novo Usuário
</Button>
</Link>
</div>
{/* --- 4. Filtro (Barra de Pesquisa + Selects) --- */}
<div className="flex flex-col md:flex-row items-start md:items-center gap-3 bg-white p-4 rounded-lg border border-gray-200">
{/* 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-gray-400" />
<Input
placeholder="Buscar por nome, e-mail ou telefone..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1); // Reseta a paginação ao pesquisar
}} }}
value={selectedRole}> className="pl-10 w-full bg-gray-50 border-gray-200 focus:bg-white transition-colors"
/>
<SelectTrigger className="w-full sm:w-[180px]"> {/* w-full para mobile, w-[180px] para sm+ */}
<SelectValue placeholder="Filtrar por papel" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="gestor">Gestor</SelectItem>
<SelectItem value="medico">Médico</SelectItem>
<SelectItem value="secretaria">Secretária</SelectItem>
<SelectItem value="user">Usuário</SelectItem>
</SelectContent>
</Select>
</div> </div>
{/* Select de Itens por Página */} <div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
<div className="flex items-center gap-2 w-full md:w-auto"> {/* Select de Filtro por Papel */}
<span className="text-sm font-medium text-foreground whitespace-nowrap"> <div className="flex items-center gap-2 w-full md:w-auto">
Itens por página <Select
</span> onValueChange={(value) => {
<Select setSelectedRole(value);
onValueChange={handleItemsPerPageChange} setCurrentPage(1);
defaultValue={String(itemsPerPage)} }}
> value={selectedRole}>
<SelectTrigger className="w-full sm:w-[140px]"> {/* w-full para mobile, w-[140px] para sm+ */}
<SelectValue placeholder="Itens por pág." /> <SelectTrigger className="w-full sm:w-[150px]">
</SelectTrigger> <SelectValue placeholder="Papel" />
<SelectContent> </SelectTrigger>
<SelectItem value="5">5 por página</SelectItem> <SelectContent>
<SelectItem value="10">10 por página</SelectItem> <SelectItem value="all">Todos</SelectItem>
<SelectItem value="20">20 por página</SelectItem> <SelectItem value="admin">Admin</SelectItem>
</SelectContent> <SelectItem value="gestor">Gestor</SelectItem>
</Select> <SelectItem value="medico">Médico</SelectItem>
<SelectItem value="secretaria">Secretária</SelectItem>
<SelectItem value="user">Usuário</SelectItem>
</SelectContent>
</Select>
</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">
<Filter className="w-4 h-4 mr-2" />
Filtros
</Button>
</div> </div>
<Button variant="outline" className="ml-auto w-full md:w-auto">
<Filter className="w-4 h-4 mr-2" />
Filtro avançado
</Button>
</div> </div>
{/* Fim do Filtro e Itens por Página */} {/* Fim do Filtro */}
{/* Tabela/Lista */} {/* Tabela/Lista */}
<div className="bg-white rounded-lg border border-gray-200 shadow-md overflow-x-auto"> <div className="bg-white rounded-lg border border-gray-200 shadow-md overflow-x-auto">
{loading ? ( {loading ? (
<div className="p-8 text-center text-gray-500"> <div className="p-8 text-center text-gray-500">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-3 text-green-600" /> <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-red-600">{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-gray-500"> <div className="p-8 text-center text-gray-500">
Nenhum usuário encontrado com os filtros aplicados. Nenhum usuário encontrado com os filtros aplicados.
</div> </div>
) : ( ) : (
<> <>
{/* Tabela para Telas Médias e Grandes */} {/* Tabela para Telas Médias e Grandes */}
<table className="min-w-full divide-y divide-gray-200 hidden md:table"> <table className="min-w-full divide-y divide-gray-200 hidden md:table">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nome</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">E-mail</th> Nome
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Telefone</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Cargo</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Ações</th> E-mail
</tr> </th>
</thead> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
<tbody className="bg-white divide-y divide-gray-200"> Telefone
{currentItems.map((u) => ( </th>
<tr key={u.id} className="hover:bg-gray-50"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
<td className="px-6 py-4 text-sm text-gray-900"> Cargo
{u.full_name} </th>
</td> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
<td className="px-6 py-4 text-sm text-gray-500 break-all"> Ações
{u.email} </th>
</td> </tr>
<td className="px-6 py-4 text-sm text-gray-500"> </thead>
{u.phone} <tbody className="bg-white divide-y divide-gray-200">
</td> {currentItems.map((u) => (
<td className="px-6 py-4 text-sm text-gray-500 capitalize"> <tr key={u.id} className="hover:bg-gray-50">
{u.role} <td className="px-6 py-4 text-sm text-gray-900">
</td> {u.full_name}
<td className="px-6 py-4 text-right"> </td>
<Button <td className="px-6 py-4 text-sm text-gray-500 break-all">
variant="outline" {u.email}
size="icon" </td>
onClick={() => openDetailsDialog(u)} <td className="px-6 py-4 text-sm text-gray-500">
title="Visualizar" {u.phone}
> </td>
<Eye className="h-4 w-4" /> <td className="px-6 py-4 text-sm text-gray-500 capitalize">
</Button> {u.role}
</td> </td>
</tr> <td className="px-6 py-4 text-right">
))} <Button
</tbody> variant="outline"
</table> size="icon"
onClick={() => openDetailsDialog(u)}
title="Visualizar"
>
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
{/* Layout em Cards/Lista para Telas Pequenas */} {/* Layout em Cards/Lista para Telas Pequenas */}
<div className="md:hidden divide-y divide-gray-200"> <div className="md:hidden divide-y divide-gray-200">
@ -299,7 +332,10 @@ export default function UsersPage() {
<div className="text-sm font-medium text-gray-900 truncate"> <div className="text-sm font-medium text-gray-900 truncate">
{u.full_name || "—"} {u.full_name || "—"}
</div> </div>
<div className="text-sm text-gray-500 capitalize"> <div className="text-xs text-gray-500 truncate">
{u.email}
</div>
<div className="text-sm text-gray-500 capitalize mt-1">
{u.role || "—"} {u.role || "—"}
</div> </div>
</div> </div>
@ -317,102 +353,110 @@ export default function UsersPage() {
))} ))}
</div> </div>
{/* Paginação */} {/* Paginação */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex flex-wrap justify-center items-center gap-2 mt-4 p-4 border-t border-gray-200"> <div className="flex flex-wrap justify-center items-center gap-2 mt-4 p-4 border-t border-gray-200">
{/* 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-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed border border-gray-300"
>
{"< Anterior"}
</button>
{/* Botão Anterior */} {/* Números das Páginas */}
<button {visiblePageNumbers.map((number) => (
onClick={goToPrevPage} <button
disabled={currentPage === 1} key={number}
className="flex items-center px-4 py-2 rounded-md font-medium transition-colors text-sm bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed border border-gray-300" onClick={() => paginate(number)}
> className={`px-4 py-2 rounded-md font-medium transition-colors text-sm border border-gray-300 ${
{"< Anterior"} currentPage === number
</button> ? "bg-blue-600 text-white shadow-md border-blue-600"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{number}
</button>
))}
{/* Números das Páginas */} {/* Botão Próximo */}
{visiblePageNumbers.map((number) => ( <button
<button onClick={goToNextPage}
key={number} disabled={currentPage === totalPages}
onClick={() => paginate(number)} className="flex items-center px-4 py-2 rounded-md font-medium transition-colors text-sm bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed border border-gray-300"
className={`px-4 py-2 rounded-md font-medium transition-colors text-sm border border-gray-300 ${currentPage === number >
? "bg-green-600 text-white shadow-md border-green-600" {"Próximo >"}
: "bg-gray-100 text-gray-700 hover:bg-gray-200" </button>
}`}
>
{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-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed border border-gray-300"
>
{"Próximo >"}
</button>
</div>
)}
</>
)}
</div> </div>
)}
</>
)}
</div>
{/* Modal de Detalhes */} {/* Modal de Detalhes */}
<AlertDialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}> <AlertDialog
<AlertDialogContent> open={detailsDialogOpen}
<AlertDialogHeader> onOpenChange={setDetailsDialogOpen}
<AlertDialogTitle className="text-2xl"> >
{userDetails?.profile?.full_name || "Detalhes do Usuário"} <AlertDialogContent>
</AlertDialogTitle> <AlertDialogHeader>
<AlertDialogDescription> <AlertDialogTitle className="text-2xl">
{!userDetails ? ( {userDetails?.profile?.full_name || "Detalhes do Usuário"}
<div className="p-4 text-center text-gray-500"> </AlertDialogTitle>
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-3 text-green-600" /> <AlertDialogDescription>
Buscando dados completos... {!userDetails ? (
</div> <div className="p-4 text-center text-gray-500">
) : ( <Loader2 className="w-6 h-6 animate-spin mx-auto mb-3 text-green-600" />
<div className="space-y-3 pt-2 text-left text-gray-700"> Buscando dados completos...
<div> </div>
<strong>ID:</strong> {userDetails.user.id} ) : (
</div> <div className="space-y-3 pt-2 text-left text-gray-700">
<div> <div>
<strong>E-mail:</strong> {userDetails.user.email} <strong>ID:</strong> {userDetails.user.id}
</div> </div>
<div> <div>
<strong>Nome completo:</strong>{" "} <strong>E-mail:</strong> {userDetails.user.email}
{userDetails.profile.full_name} </div>
</div> <div>
<div> <strong>Nome completo:</strong>{" "}
<strong>Telefone:</strong> {userDetails.profile.phone} {userDetails.profile.full_name}
</div> </div>
<div> <div>
<strong>Roles:</strong>{" "} <strong>Telefone:</strong> {userDetails.profile.phone}
{userDetails.roles?.join(", ")} </div>
</div> <div>
<div className="pt-2"> <strong>Roles:</strong> {userDetails.roles?.join(", ")}
<strong className="block mb-1">Permissões:</strong> </div>
<ul className="list-disc list-inside space-y-0.5 text-sm"> <div className="pt-2">
{Object.entries( <strong className="block mb-1">Permissões:</strong>
userDetails.permissions || {} <ul className="list-disc list-inside space-y-0.5 text-sm">
).map(([k, v]) => ( {Object.entries(userDetails.permissions || {}).map(
<li key={k}> ([k, v]) => (
{k}: <span className={`font-semibold ${v ? 'text-green-600' : 'text-red-600'}`}>{v ? "Sim" : "Não"}</span> <li key={k}>
</li> {k}:{" "}
))} <span
</ul> className={`font-semibold ${
</div> v ? "text-green-600" : "text-red-600"
</div> }`}
)} >
</AlertDialogDescription> {v ? "Sim" : "Não"}
</AlertDialogHeader> </span>
<AlertDialogFooter> </li>
<AlertDialogCancel>Fechar</AlertDialogCancel> )
</AlertDialogFooter> )}
</AlertDialogContent> </ul>
</AlertDialog> </div>
</div> </div>
</Sidebar> )}
); </AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Fechar</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</Sidebar>
);
} }

View File

@ -100,7 +100,7 @@ export default function InicialPage() {
<Link href="/login"> <Link href="/login">
<Button <Button
variant="outline" variant="outline"
className="rounded-full px-6 py-2 border-2 border-[#007BFF] text-[#007BFF] hover:bg-[#007BFF] hover:text-white transition" className="rounded-full px-6 py-2 border-2 border-[#007BFF] text-[#007BFF] hover:bg-[#007BFF] hover:text-white transition cursor-pointer"
> >
Login Login
</Button> </Button>

View File

@ -1,8 +1,14 @@
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,
import Sidebar from "@/components/Sidebar" CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Calendar, Clock, User, Plus } from "lucide-react";
import Link from "next/link";
import Sidebar from "@/components/Sidebar";
export default function PatientDashboard() { export default function PatientDashboard() {
return ( return (
@ -10,13 +16,17 @@ export default function PatientDashboard() {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1> <h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600">Bem-vindo ao seu portal de consultas médicas</p> <p className="text-gray-600">
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">Próxima Consulta</CardTitle> <CardTitle className="text-sm font-medium">
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>
@ -27,12 +37,16 @@ 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">Consultas Este Mês</CardTitle> <CardTitle className="text-sm font-medium">
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">2 realizadas, 1 agendada</p> <p className="text-xs text-muted-foreground">
2 realizadas, 1 agendada
</p>
</CardContent> </CardContent>
</Card> </Card>
@ -52,23 +66,31 @@ export default function PatientDashboard() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Ações Rápidas</CardTitle> <CardTitle>Ações Rápidas</CardTitle>
<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="/patient/schedule"> <Link href="/patient/schedule">
<Button className="w-full justify-start"> <Button className="w-full justify-start bg-blue-600 text-white hover:bg-blue-700">
<Plus className="mr-2 h-4 w-4" /> <User className="mr-2 h-4 w-4 text-white" />
Agendar Nova Consulta Agendar Nova Consulta
</Button> </Button>
</Link> </Link>
<Link href="/patient/appointments"> <Link href="/patient/appointments">
<Button variant="outline" className="w-full justify-start bg-transparent"> <Button
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 Minhas Consultas Ver Minhas Consultas
</Button> </Button>
</Link> </Link>
<Link href="/patient/profile"> <Link href="/patient/profile">
<Button variant="outline" className="w-full justify-start bg-transparent"> <Button
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" />
Atualizar Dados Atualizar Dados
</Button> </Button>
@ -109,5 +131,5 @@ export default function PatientDashboard() {
</div> </div>
</div> </div>
</Sidebar> </Sidebar>
) );
} }

View File

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

View File

@ -1,11 +1,25 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 { Dialog } from "@/components/ui/dialog"; import { Dialog } from "@/components/ui/dialog";
import { Calendar, Clock, MapPin, Phone, User, Trash2, Pencil } from "lucide-react"; import {
Calendar,
Clock,
MapPin,
Phone,
User,
Trash2,
Pencil,
} from "lucide-react";
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 { appointmentsService } from "@/services/appointmentsApi.mjs";
@ -14,214 +28,298 @@ import { doctorsService } from "@/services/doctorsApi.mjs";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
export default function SecretaryAppointments() { export default function SecretaryAppointments() {
const [appointments, setAppointments] = useState<any[]>([]); const [appointments, setAppointments] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [selectedAppointment, setSelectedAppointment] = useState<any>(null); const [selectedAppointment, setSelectedAppointment] = useState<any>(null);
// Estados dos Modais // Estados dos Modais
const [deleteModal, setDeleteModal] = useState(false); const [deleteModal, setDeleteModal] = useState(false);
const [editModal, setEditModal] = useState(false); const [editModal, setEditModal] = useState(false);
// Estado para o formulário de edição // Estado para o formulário de edição
const [editFormData, setEditFormData] = useState({ const [editFormData, setEditFormData] = useState({
date: "", date: "",
time: "", time: "",
status: "", status: "",
});
const fetchData = async () => {
setIsLoading(true);
try {
// 1. DEFINIR O PARÂMETRO DE ORDENAÇÃO
// 'scheduled_at.desc' ordena pela data do agendamento, em ordem descendente (mais recentes primeiro).
const queryParams = "order=scheduled_at.desc";
const [appointmentList, patientList, doctorList] = await Promise.all([
// 2. USAR A FUNÇÃO DE BUSCA COM O PARÂMETRO DE ORDENAÇÃO
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(() => {
fetchData();
}, []); // Array vazio garante que a busca ocorra apenas uma vez, no carregamento da página.
// --- LÓGICA DE EDIÇÃO ---
const handleEdit = (appointment: any) => {
setSelectedAppointment(appointment);
const appointmentDate = new Date(appointment.scheduled_at);
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 fetchData = async () => { const confirmEdit = async () => {
setIsLoading(true); if (
try { !selectedAppointment ||
// 1. DEFINIR O PARÂMETRO DE ORDENAÇÃO !editFormData.date ||
// 'scheduled_at.desc' ordena pela data do agendamento, em ordem descendente (mais recentes primeiro). !editFormData.time ||
const queryParams = 'order=scheduled_at.desc'; !editFormData.status
) {
toast.error("Todos os campos são obrigatórios para a edição.");
return;
}
const [appointmentList, patientList, doctorList] = await Promise.all([ try {
// 2. USAR A FUNÇÃO DE BUSCA COM O PARÂMETRO DE ORDENAÇÃO const newScheduledAt = new Date(
appointmentsService.search_appointment(queryParams), `${editFormData.date}T${editFormData.time}:00Z`
patientsService.list(), ).toISOString();
doctorsService.list(), const updatePayload = {
]); scheduled_at: newScheduledAt,
status: editFormData.status,
};
const patientMap = new Map(patientList.map((p: any) => [p.id, p])); await appointmentsService.update(selectedAppointment.id, updatePayload);
const doctorMap = new Map(doctorList.map((d: any) => [d.id, d]));
const enrichedAppointments = appointmentList.map((apt: any) => ({ // 3. RECARREGAR OS DADOS APÓS A EDIÇÃO
...apt, // Isso garante que a lista permaneça ordenada corretamente se a data for alterada.
patient: patientMap.get(apt.patient_id) || { full_name: "Paciente não encontrado" }, fetchData();
doctor: doctorMap.get(apt.doctor_id) || { full_name: "Médico não encontrado", specialty: "N/A" },
}));
setAppointments(enrichedAppointments); setEditModal(false);
} catch (error) { toast.success("Consulta atualizada com sucesso!");
console.error("Falha ao buscar agendamentos:", error); } catch (error) {
toast.error("Não foi possível carregar a lista de agendamentos."); console.error("Erro ao atualizar consulta:", error);
} finally { toast.error("Não foi possível atualizar a consulta.");
setIsLoading(false); }
} };
};
useEffect(() => { // --- LÓGICA DE DELEÇÃO ---
fetchData(); const handleDelete = (appointment: any) => {
}, []); // Array vazio garante que a busca ocorra apenas uma vez, no carregamento da página. setSelectedAppointment(appointment);
setDeleteModal(true);
};
// --- LÓGICA DE EDIÇÃO --- const confirmDelete = async () => {
const handleEdit = (appointment: any) => { if (!selectedAppointment) return;
setSelectedAppointment(appointment); try {
const appointmentDate = new Date(appointment.scheduled_at); 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.");
}
};
setEditFormData({ const getStatusBadge = (status: string) => {
date: appointmentDate.toISOString().split('T')[0], switch (status) {
time: appointmentDate.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }), case "requested":
status: appointment.status, return (
}); <Badge className="bg-yellow-100 text-yellow-800">Solicitada</Badge>
setEditModal(true); );
}; case "confirmed":
return <Badge className="bg-blue-100 text-blue-800">Confirmada</Badge>;
case "checked_in":
return (
<Badge className="bg-indigo-100 text-indigo-800">Check-in</Badge>
);
case "completed":
return <Badge className="bg-green-100 text-green-800">Realizada</Badge>;
case "cancelled":
return <Badge className="bg-red-100 text-red-800">Cancelada</Badge>;
case "no_show":
return (
<Badge className="bg-gray-100 text-gray-800">Não Compareceu</Badge>
);
default:
return <Badge variant="secondary">{status}</Badge>;
}
};
const confirmEdit = async () => { const timeSlots = [
if (!selectedAppointment || !editFormData.date || !editFormData.time || !editFormData.status) { "08:00",
toast.error("Todos os campos são obrigatórios para a edição."); "08:30",
return; "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",
];
const appointmentStatuses = [
"requested",
"confirmed",
"checked_in",
"completed",
"cancelled",
"no_show",
];
try { return (
const newScheduledAt = new Date(`${editFormData.date}T${editFormData.time}:00Z`).toISOString(); <Sidebar>
const updatePayload = { <div className="space-y-6">
scheduled_at: newScheduledAt, <div className="flex justify-between items-center">
status: editFormData.status, <div>
}; <h1 className="text-3xl font-bold text-gray-900">
Consultas Agendadas
</h1>
<p className="text-gray-600">Gerencie as consultas dos pacientes</p>
</div>
<Link href="/secretary/schedule">
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Calendar className="mr-2 h-4 w-4 text-white" />
Agendar Nova Consulta
</Button>
</Link>
</div>
await appointmentsService.update(selectedAppointment.id, updatePayload); <div className="grid gap-6">
{isLoading ? (
// 3. RECARREGAR OS DADOS APÓS A EDIÇÃO <p>Carregando consultas...</p>
// Isso garante que a lista permaneça ordenada corretamente se a data for alterada. ) : appointments.length > 0 ? (
fetchData(); appointments.map((appointment) => (
<Card key={appointment.id}>
setEditModal(false); <CardHeader>
toast.success("Consulta atualizada com sucesso!"); <div className="flex justify-between items-start">
} catch (error) {
console.error("Erro ao atualizar consulta:", error);
toast.error("Não foi possível atualizar a consulta.");
}
};
// --- LÓGICA DE DELEÇÃO ---
const handleDelete = (appointment: any) => {
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.");
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case "requested": return <Badge className="bg-yellow-100 text-yellow-800">Solicitada</Badge>;
case "confirmed": return <Badge className="bg-blue-100 text-blue-800">Confirmada</Badge>;
case "checked_in": return <Badge className="bg-indigo-100 text-indigo-800">Check-in</Badge>;
case "completed": return <Badge className="bg-green-100 text-green-800">Realizada</Badge>;
case "cancelled": return <Badge className="bg-red-100 text-red-800">Cancelada</Badge>;
case "no_show": return <Badge className="bg-gray-100 text-gray-800">Não Compareceu</Badge>;
default: return <Badge variant="secondary">{status}</Badge>;
}
};
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"];
const appointmentStatuses = ["requested", "confirmed", "checked_in", "completed", "cancelled", "no_show"];
return (
<Sidebar>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">Consultas Agendadas</h1> <CardTitle className="text-lg">
<p className="text-gray-600">Gerencie as consultas dos pacientes</p> {appointment.doctor.full_name}
</CardTitle>
<CardDescription>
{appointment.doctor.specialty}
</CardDescription>
</div> </div>
<Link href="/secretary/schedule"> {getStatusBadge(appointment.status)}
<Button><Calendar className="mr-2 h-4 w-4" /> Agendar Nova Consulta</Button> </div>
</Link> </CardHeader>
</div> <CardContent>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-3">
<div className="flex items-center text-sm text-gray-800 font-medium">
<User className="mr-2 h-4 w-4 text-gray-600" />
{appointment.patient.full_name}
</div>
<div className="flex items-center text-sm text-gray-600">
<Calendar className="mr-2 h-4 w-4" />
{new Date(appointment.scheduled_at).toLocaleDateString(
"pt-BR",
{ timeZone: "UTC" }
)}
</div>
<div className="flex items-center text-sm text-gray-600">
<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 className="space-y-3">
<div className="flex items-center text-sm text-gray-600">
<MapPin className="mr-2 h-4 w-4" />
{appointment.doctor.location || "Local a definir"}
</div>
<div className="flex items-center text-sm text-gray-600">
<Phone className="mr-2 h-4 w-4" />
{appointment.doctor.phone || "N/A"}
</div>
</div>
</div>
<div className="grid gap-6"> <div className="flex gap-2 mt-4 pt-4 border-t">
{isLoading ? <p>Carregando consultas...</p> : appointments.length > 0 ? ( <Button
appointments.map((appointment) => ( variant="outline"
<Card key={appointment.id}> size="sm"
<CardHeader> onClick={() => handleEdit(appointment)}
<div className="flex justify-between items-start"> >
<div> <Pencil className="mr-2 h-4 w-4" />
<CardTitle className="text-lg">{appointment.doctor.full_name}</CardTitle> Editar
<CardDescription>{appointment.doctor.specialty}</CardDescription> </Button>
</div> <Button
{getStatusBadge(appointment.status)} variant="outline"
</div> size="sm"
</CardHeader> className="text-red-600 hover:text-red-700 hover:bg-red-50 bg-transparent"
<CardContent> onClick={() => handleDelete(appointment)}
<div className="grid md:grid-cols-2 gap-4"> >
<div className="space-y-3"> <Trash2 className="mr-2 h-4 w-4" />
<div className="flex items-center text-sm text-gray-800 font-medium"> Deletar
<User className="mr-2 h-4 w-4 text-gray-600" /> </Button>
{appointment.patient.full_name} </div>
</div> </CardContent>
<div className="flex items-center text-sm text-gray-600"> </Card>
<Calendar className="mr-2 h-4 w-4" /> ))
{new Date(appointment.scheduled_at).toLocaleDateString("pt-BR", { timeZone: "UTC" })} ) : (
</div> <p>Nenhuma consulta encontrada.</p>
<div className="flex items-center text-sm text-gray-600"> )}
<Clock className="mr-2 h-4 w-4" /> </div>
{new Date(appointment.scheduled_at).toLocaleTimeString("pt-BR", { hour: '2-digit', minute: '2-digit', timeZone: "UTC" })} </div>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center text-sm text-gray-600">
<MapPin className="mr-2 h-4 w-4" />
{appointment.doctor.location || "Local a definir"}
</div>
<div className="flex items-center text-sm text-gray-600">
<Phone className="mr-2 h-4 w-4" />
{appointment.doctor.phone || "N/A"}
</div>
</div>
</div>
<div className="flex gap-2 mt-4 pt-4 border-t"> {/* MODAL DE EDIÇÃO */}
<Button variant="outline" size="sm" onClick={() => handleEdit(appointment)}> <Dialog open={editModal} onOpenChange={setEditModal}>
<Pencil className="mr-2 h-4 w-4" /> {/* ... (código do modal de edição) ... */}
Editar </Dialog>
</Button>
<Button variant="outline" size="sm" className="text-red-600 hover:text-red-700 hover:bg-red-50 bg-transparent" onClick={() => handleDelete(appointment)}>
<Trash2 className="mr-2 h-4 w-4" />
Deletar
</Button>
</div>
</CardContent>
</Card>
))
) : (
<p>Nenhuma consulta encontrada.</p>
)}
</div>
</div>
{/* MODAL DE EDIÇÃO */} {/* Modal de Deleção */}
<Dialog open={editModal} onOpenChange={setEditModal}> <Dialog open={deleteModal} onOpenChange={setDeleteModal}>
{/* ... (código do modal de edição) ... */} {/* ... (código do modal de deleção) ... */}
</Dialog> </Dialog>
</Sidebar>
{/* Modal de Deleção */} );
<Dialog open={deleteModal} onOpenChange={setDeleteModal}>
{/* ... (código do modal de deleção) ... */}
</Dialog>
</Sidebar>
);
} }

View File

@ -1,8 +1,11 @@
"use client"; "use client";
import { Card, CardContent, CardDescription, import {
CardHeader, Card,
CardTitle, CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar, Clock, User, Plus } from "lucide-react"; import { Calendar, Clock, User, Plus } from "lucide-react";
@ -13,289 +16,290 @@ import { appointmentsService } from "@/services/appointmentsApi.mjs";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
export default function SecretaryDashboard() { export default function SecretaryDashboard() {
// Estados // Estados
const [patients, setPatients] = useState<any[]>([]); const [patients, setPatients] = useState<any[]>([]);
const [loadingPatients, setLoadingPatients] = useState(true); const [loadingPatients, setLoadingPatients] = useState(true);
const [firstConfirmed, setFirstConfirmed] = useState<any>(null); const [firstConfirmed, setFirstConfirmed] = useState<any>(null);
const [nextAgendada, setNextAgendada] = useState<any>(null); const [nextAgendada, setNextAgendada] = useState<any>(null);
const [loadingAppointments, setLoadingAppointments] = useState(true); const [loadingAppointments, setLoadingAppointments] = useState(true);
// 🔹 Buscar pacientes // 🔹 Buscar pacientes
useEffect(() => { useEffect(() => {
async function fetchPatients() { async function fetchPatients() {
try { try {
const data = await patientsService.list(); const data = await patientsService.list();
if (Array.isArray(data)) { if (Array.isArray(data)) {
setPatients(data.slice(0, 3)); setPatients(data.slice(0, 3));
}
} catch (error) {
console.error("Erro ao carregar pacientes:", error);
} finally {
setLoadingPatients(false);
}
} }
fetchPatients(); } catch (error) {
}, []); console.error("Erro ao carregar pacientes:", error);
} finally {
setLoadingPatients(false);
}
}
fetchPatients();
}, []);
// 🔹 Buscar consultas (confirmadas + 1ª do mês) // 🔹 Buscar consultas (confirmadas + 1ª do mês)
useEffect(() => { useEffect(() => {
async function fetchAppointments() { async function fetchAppointments() {
try { try {
const hoje = new Date(); const hoje = new Date();
const inicioMes = new Date(hoje.getFullYear(), hoje.getMonth(), 1); const inicioMes = new Date(hoje.getFullYear(), hoje.getMonth(), 1);
const fimMes = new Date(hoje.getFullYear(), hoje.getMonth() + 1, 0); const fimMes = new Date(hoje.getFullYear(), hoje.getMonth() + 1, 0);
// Mesmo parâmetro de ordenação da página /secretary/appointments // Mesmo parâmetro de ordenação da página /secretary/appointments
const queryParams = "order=scheduled_at.desc"; const queryParams = "order=scheduled_at.desc";
const data = await appointmentsService.search_appointment(queryParams); const data = await appointmentsService.search_appointment(queryParams);
if (!Array.isArray(data) || data.length === 0) { if (!Array.isArray(data) || data.length === 0) {
setFirstConfirmed(null); setFirstConfirmed(null);
setNextAgendada(null); setNextAgendada(null);
return; 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(); // 🩵 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;
});
return ( confirmadas.sort(
<Sidebar> (a: any, b: any) =>
<div className="space-y-6"> new Date(a.scheduled_at || a.date).getTime() -
{/* Cabeçalho */} new Date(b.scheduled_at || b.date).getTime()
<div> );
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600">Bem-vindo ao seu portal de consultas médicas</p> 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 (
<Sidebar>
<div className="space-y-6">
{/* Cabeçalho */}
<div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600">
Bem-vindo ao seu portal de consultas médicas
</p>
</div>
{/* Cards principais */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Próxima Consulta Confirmada */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Próxima Consulta Confirmada
</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loadingAppointments ? (
<div className="text-gray-500 text-sm">
Carregando próxima consulta...
</div> </div>
) : firstConfirmed ? (
{/* Cards principais */} <>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="text-2xl font-bold">
{/* Próxima Consulta Confirmada */} {new Date(
<Card> firstConfirmed.scheduled_at || firstConfirmed.date
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> ).toLocaleDateString("pt-BR")}
<CardTitle className="text-sm font-medium"> </div>
Próxima Consulta Confirmada <p className="text-xs text-muted-foreground">
</CardTitle> {firstConfirmed.doctor_name
<Calendar className="h-4 w-4 text-muted-foreground" /> ? `Dr(a). ${firstConfirmed.doctor_name}`
</CardHeader> : "Médico não informado"}{" "}
<CardContent> -{" "}
{loadingAppointments ? ( {new Date(firstConfirmed.scheduled_at).toLocaleTimeString(
<div className="text-gray-500 text-sm"> "pt-BR",
Carregando próxima consulta... {
</div> hour: "2-digit",
) : firstConfirmed ? ( minute: "2-digit",
<> }
<div className="text-2xl font-bold"> )}
{new Date( </p>
firstConfirmed.scheduled_at || firstConfirmed.date </>
).toLocaleDateString("pt-BR")} ) : (
</div> <div className="text-sm text-gray-500">
<p className="text-xs text-muted-foreground"> Nenhuma consulta confirmada encontrada
{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-gray-500">
Nenhuma consulta confirmada encontrada
</div>
)}
</CardContent>
</Card>
{/* Consultas Este Mês */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Consultas Este Mês
</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loadingAppointments ? (
<div className="text-gray-500 text-sm">
Carregando consultas...
</div>
) : nextAgendada ? (
<>
<div className="text-lg font-bold text-gray-900">
{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-gray-500">
Nenhuma consulta agendada neste mês
</div>
)}
</CardContent>
</Card>
{/* Perfil */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Perfil</CardTitle>
<User className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">100%</div>
<p className="text-xs text-muted-foreground">Dados completos</p>
</CardContent>
</Card>
</div> </div>
)}
</CardContent>
</Card>
{/* Cards Secundários */} {/* Consultas Este Mês */}
<div className="grid md:grid-cols-2 gap-6"> <Card>
{/* Ações rápidas */} <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Card> <CardTitle className="text-sm font-medium">
<CardHeader> Consultas Este Mês
<CardTitle>Ações Rápidas</CardTitle> </CardTitle>
<CardDescription> <Clock className="h-4 w-4 text-muted-foreground" />
Acesse rapidamente as principais funcionalidades </CardHeader>
</CardDescription> <CardContent>
</CardHeader> {loadingAppointments ? (
<CardContent className="space-y-4"> <div className="text-gray-500 text-sm">
<Link href="/secretary/schedule"> Carregando consultas...
<Button className="w-full justify-start">
<Plus className="mr-2 h-4 w-4" />
Agendar Nova Consulta
</Button>
</Link>
<Link href="/secretary/appointments">
<Button
variant="outline"
className="w-full justify-start bg-transparent"
>
<Calendar className="mr-2 h-4 w-4" />
Ver Consultas
</Button>
</Link>
<Link href="/secretary/pacientes">
<Button
variant="outline"
className="w-full justify-start bg-transparent"
>
<User className="mr-2 h-4 w-4" />
Gerenciar Pacientes
</Button>
</Link>
</CardContent>
</Card>
{/* Pacientes */}
<Card>
<CardHeader>
<CardTitle>Pacientes</CardTitle>
<CardDescription>
Últimos pacientes cadastrados
</CardDescription>
</CardHeader>
<CardContent>
{loadingPatients ? (
<p className="text-sm text-gray-500">
Carregando pacientes...
</p>
) : patients.length === 0 ? (
<p className="text-sm text-gray-500">
Nenhum paciente cadastrado.
</p>
) : (
<div className="space-y-4">
{patients.map((patient, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-blue-50 rounded-lg border border-blue-100"
>
<div>
<p className="font-medium text-gray-900">
{patient.full_name || "Sem nome"}
</p>
<p className="text-sm text-gray-600">
{patient.phone_mobile ||
patient.phone1 ||
"Sem telefone"}
</p>
</div>
<div className="text-right">
<p className="font-medium text-blue-700">
{patient.convenio || "Particular"}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div> </div>
</div> ) : nextAgendada ? (
</Sidebar> <>
); <div className="text-lg font-bold text-gray-900">
{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-gray-500">
Nenhuma consulta agendada neste mês
</div>
)}
</CardContent>
</Card>
{/* Perfil */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Perfil</CardTitle>
<User className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">100%</div>
<p className="text-xs text-muted-foreground">Dados completos</p>
</CardContent>
</Card>
</div>
{/* Cards Secundários */}
<div className="grid md:grid-cols-2 gap-6">
{/* Ações rápidas */}
<Card>
<CardHeader>
<CardTitle>Ações Rápidas</CardTitle>
<CardDescription>
Acesse rapidamente as principais funcionalidades
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Link href="/secretary/schedule">
<Button className="w-full justify-start bg-blue-600 text-white hover:bg-blue-700">
<User className="mr-2 h-4 w-4 text-white" />
Agendar Nova Consulta
</Button>
</Link>
<Link href="/secretary/appointments">
<Button
variant="outline"
className="w-full justify-start bg-transparent"
>
<Calendar className="mr-2 h-4 w-4" />
Ver Consultas
</Button>
</Link>
<Link href="/secretary/pacientes">
<Button
variant="outline"
className="w-full justify-start bg-transparent"
>
<User className="mr-2 h-4 w-4" />
Gerenciar Pacientes
</Button>
</Link>
</CardContent>
</Card>
{/* Pacientes */}
<Card>
<CardHeader>
<CardTitle>Pacientes</CardTitle>
<CardDescription>Últimos pacientes cadastrados</CardDescription>
</CardHeader>
<CardContent>
{loadingPatients ? (
<p className="text-sm text-gray-500">Carregando pacientes...</p>
) : patients.length === 0 ? (
<p className="text-sm text-gray-500">
Nenhum paciente cadastrado.
</p>
) : (
<div className="space-y-4">
{patients.map((patient, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-blue-50 rounded-lg border border-blue-100"
>
<div>
<p className="font-medium text-gray-900">
{patient.full_name || "Sem nome"}
</p>
<p className="text-sm text-gray-600">
{patient.phone_mobile ||
patient.phone1 ||
"Sem telefone"}
</p>
</div>
<div className="text-right">
<p className="font-medium text-blue-700">
{patient.convenio || "Particular"}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
</Sidebar>
);
} }

File diff suppressed because it is too large Load Diff

View File

@ -5,270 +5,327 @@ import type React from "react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter, usePathname } from "next/navigation"; import { useRouter, usePathname } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Cookies from "js-cookie"; // Mantido apenas para a limpeza de segurança no logout import Cookies from "js-cookie";
import { api } from "@/services/api.mjs"; import { api } from "@/services/api.mjs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import {
import { LogOut, ChevronLeft, ChevronRight, Home, CalendarCheck2, ClipboardPlus, CalendarClock, Users, SquareUser, ClipboardList, Stethoscope, ClipboardMinus } from "lucide-react"; Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
LogOut,
ChevronLeft,
ChevronRight,
Home,
CalendarCheck2,
ClipboardPlus,
CalendarClock,
Users,
SquareUser,
ClipboardList,
Stethoscope,
ClipboardMinus,
} from "lucide-react";
import SidebarUserSection from "@/components/ui/userToolTip"; import SidebarUserSection from "@/components/ui/userToolTip";
interface UserData { 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;
};
identities: {
identity_id: string;
id: string; id: string;
email: string; user_id: string;
app_metadata: { provider: string;
user_role: string; }[];
}; is_anonymous: boolean;
user_metadata: {
cpf: string;
email_verified: boolean;
full_name: string;
phone_mobile: string;
role: string;
};
identities: {
identity_id: string;
id: string;
user_id: string;
provider: string;
}[];
is_anonymous: boolean;
} }
interface MenuItem { interface MenuItem {
href: string; href: string;
icon: React.ElementType; icon: React.ElementType;
label: string; label: string;
} }
interface SidebarProps { interface SidebarProps {
children: React.ReactNode; children: React.ReactNode;
} }
export default function Sidebar({ children }: SidebarProps) { export default function Sidebar({ children }: SidebarProps) {
const [userData, setUserData] = useState<UserData>(); const [userData, setUserData] = useState<UserData>();
const [role, setRole] = useState<string>(); const [role, setRole] = useState<string>();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showLogoutDialog, setShowLogoutDialog] = useState(false); const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
useEffect(() => { useEffect(() => {
const userInfoString = localStorage.getItem("user_info"); const userInfoString = localStorage.getItem("user_info");
// --- ALTERAÇÃO 1: Buscando o token no localStorage --- const token = localStorage.getItem("token");
const token = localStorage.getItem("token");
if (userInfoString && token) { if (userInfoString && token) {
const userInfo = JSON.parse(userInfoString); const userInfo = JSON.parse(userInfoString);
setUserData({ setUserData({
id: userInfo.id ?? "", id: userInfo.id ?? "",
email: userInfo.email ?? "", email: userInfo.email ?? "",
app_metadata: { app_metadata: {
user_role: userInfo.app_metadata?.user_role ?? "patient", user_role: userInfo.app_metadata?.user_role ?? "patient",
}, },
user_metadata: { user_metadata: {
cpf: userInfo.user_metadata?.cpf ?? "", cpf: userInfo.user_metadata?.cpf ?? "",
email_verified: userInfo.user_metadata?.email_verified ?? false, email_verified: userInfo.user_metadata?.email_verified ?? false,
full_name: userInfo.user_metadata?.full_name ?? "", full_name: userInfo.user_metadata?.full_name ?? "",
phone_mobile: userInfo.user_metadata?.phone_mobile ?? "", phone_mobile: userInfo.user_metadata?.phone_mobile ?? "",
role: userInfo.user_metadata?.role ?? "", role: userInfo.user_metadata?.role ?? "",
}, },
identities: identities:
userInfo.identities?.map((identity: any) => ({ userInfo.identities?.map((identity: any) => ({
identity_id: identity.identity_id ?? "", identity_id: identity.identity_id ?? "",
id: identity.id ?? "", id: identity.id ?? "",
user_id: identity.user_id ?? "", user_id: identity.user_id ?? "",
provider: identity.provider ?? "", provider: identity.provider ?? "",
})) ?? [], })) ?? [],
is_anonymous: userInfo.is_anonymous ?? false, is_anonymous: userInfo.is_anonymous ?? false,
}); });
setRole(userInfo.user_metadata?.role); setRole(userInfo.user_metadata?.role);
} else { } else {
// O redirecionamento para /login já estava correto. Ótimo! router.push("/login");
router.push("/login"); }
} }, [router]);
}, [router]);
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
if (window.innerWidth < 1024) { if (window.innerWidth < 1024) {
setSidebarCollapsed(true); setSidebarCollapsed(true);
} else { } else {
setSidebarCollapsed(false); setSidebarCollapsed(false);
} }
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const handleLogout = () => setShowLogoutDialog(true);
// --- ALTERAÇÃO 2: A função de logout agora é MUITO mais simples ---
const confirmLogout = async () => {
try {
// Chama a função centralizada para fazer o logout no servidor
await api.logout();
} catch (error) {
// O erro já é logado dentro da função api.logout, não precisamos fazer nada aqui
} finally {
// A responsabilidade do componente é apenas limpar o estado local e redirecionar
localStorage.removeItem("user_info");
localStorage.removeItem("token");
Cookies.remove("access_token"); // Limpeza de segurança
setShowLogoutDialog(false);
router.push("/"); // Redireciona para a home
}
}; };
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const cancelLogout = () => setShowLogoutDialog(false); const handleLogout = () => setShowLogoutDialog(true);
const SetMenuItems = (role: any) => { const confirmLogout = async () => {
const patientItems: MenuItem[] = [ try {
{ href: "/patient/dashboard", icon: Home, label: "Dashboard" }, await api.logout();
{ } catch (error) {
href: "/patient/schedule", } finally {
icon: CalendarClock, localStorage.removeItem("user_info");
label: "Agendar Consulta", localStorage.removeItem("token");
}, Cookies.remove("access_token");
{
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[] = [ setShowLogoutDialog(false);
{ href: "/doctor/dashboard", icon: Home, label: "Dashboard" }, router.push("/");
{ 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[] = [ const cancelLogout = () => setShowLogoutDialog(false);
{ href: "/secretary/dashboard", icon: Home, label: "Dashboard" },
{ const SetMenuItems = (role: any) => {
href: "/secretary/appointments", const patientItems: MenuItem[] = [
icon: CalendarCheck2, { href: "/patient/dashboard", icon: Home, label: "Dashboard" },
label: "Consultas", {
}, href: "/patient/schedule",
{ icon: CalendarClock,
href: "/secretary/schedule", label: "Agendar Consulta",
icon: CalendarClock, },
label: "Agendar Consulta", {
}, href: "/patient/appointments",
{ icon: CalendarCheck2,
href: "/secretary/pacientes", label: "Minhas Consultas",
icon: Users, },
label: "Gestão de Pacientes", { 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[] = [ const managerItems: MenuItem[] = [
{ href: "/manager/dashboard", icon: Home, label: "Dashboard" }, { href: "/manager/dashboard", icon: Home, label: "Dashboard" },
{ href: "/manager/usuario", icon: Users, label: "Gestão de Usuários" }, { href: "/manager/usuario", icon: Users, label: "Gestão de Usuários" },
{ href: "/manager/home", icon: Stethoscope, label: "Gestão de Médicos" }, { href: "/manager/home", icon: Stethoscope, label: "Gestão de Médicos" },
{ href: "/manager/pacientes", icon: Users, label: "Gestão de Pacientes" }, { href: "/manager/pacientes", icon: Users, label: "Gestão de Pacientes" },
{ href: "/secretary/appointments", icon: CalendarCheck2, label: "Consultas" }, //adicionar botão de voltar pra pagina anterior { href: "/secretary/appointments", icon: CalendarCheck2, label: "Consultas" },
{ href: "/manager/disponibilidade", icon: ClipboardList, label: "Disponibilidade" },
]; ];
let menuItems: MenuItem[]; switch (role) {
switch (role) { case "gestor":
case "gestor": case "admin":
menuItems = managerItems; return managerItems;
break; case "medico":
case "admin": return doctorItems;
menuItems = managerItems; case "secretaria":
break; return secretaryItems;
case "medico": case "paciente":
menuItems = doctorItems; default:
break; return patientItems;
case "secretaria":
menuItems = secretaryItems;
break;
case "paciente":
menuItems = patientItems;
break;
default:
menuItems = patientItems;
break;
}
return menuItems;
};
const menuItems = SetMenuItems(role);
if (!userData) {
return <div className="flex h-screen w-full items-center justify-center">Carregando...</div>;
} }
};
const menuItems = SetMenuItems(role);
if (!userData) {
return ( return (
<div className="min-h-screen bg-gray-50 flex"> <div className="flex h-screen w-full items-center justify-center">
<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"}`}> Carregando...
<div className="p-4 border-b border-gray-200 flex items-center justify-between"> </div>
{!sidebarCollapsed && (
<div className="flex items-center gap-2">
{/* 🛑 SUBSTITUIÇÃO: Usando a tag <img> com o caminho da logo */}
<img
src="/Logo MedConnect.png" // Use o arquivo da logo (ou /android-chrome-512x512.png)
alt="Logo MediConnect"
className="w-12 h-12 object-contain" // Define o tamanho para w-8 h-8 (32px)
/>
<span className="font-semibold text-gray-900">MedConnect</span>
</div>
)}
<Button 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>
<nav className="flex-1 p-2 overflow-y-auto">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link key={item.label} href={item.href}>
<div className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${isActive ? "bg-blue-50 text-blue-600 border-r-2 border-blue-600" : "text-gray-600 hover:bg-gray-50"}`}>
<Icon className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && <span className="font-medium">{item.label}</span>}
</div>
</Link>
);
})}
</nav>
<SidebarUserSection userData={userData} sidebarCollapsed={false} handleLogout={handleLogout} isActive={role === "paciente" ? false : true}></SidebarUserSection>
</div>
<div className={`flex-1 flex flex-col transition-all duration-300 w-full ${sidebarCollapsed ? "ml-16" : "ml-64"}`}>
<header className="bg-gray-50 px-4 md:px-6 py-4 flex items-center justify-between"></header>
<main className="flex-1 p-4 md:p-6">{children}</main>
</div>
<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>
); );
}
return (
<div className="min-h-screen bg-gray-50 flex">
<div
className={`fixed top-0 h-screen flex flex-col z-30 transition-all duration-300
${sidebarCollapsed ? "w-16" : "w-64"}
bg-[#123965] text-white`}
>
{/* TOPO */}
<div className="p-4 border-b border-white/10 flex items-center justify-between">
{!sidebarCollapsed && (
<div className="flex items-center gap-2">
<div className="bg-white 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-white text-lg">
MedConnect
</span>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-1 text-white hover:bg-white/10 cursor-pointer"
>
{sidebarCollapsed ? (
<ChevronRight className="w-5 h-5" />
) : (
<ChevronLeft className="w-5 h-5" />
)}
</Button>
</div>
{/* MENU */}
<nav className="flex-1 p-3 overflow-y-auto">
{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-white/20 text-white font-semibold"
: "text-white/80 hover:bg-white/10 hover:text-white"
}
`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && (
<span className="font-medium">{item.label}</span>
)}
</div>
</Link>
);
})}
</nav>
{/* PERFIL ORIGINAL + NOME BRANCO */}
<div className="mt-auto p-3 border-t border-white/10">
<SidebarUserSection
userData={userData}
sidebarCollapsed={sidebarCollapsed}
handleLogout={handleLogout}
isActive={role !== "paciente"}
/>
</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>
</div>
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Confirmar Saída</DialogTitle>
<DialogDescription>
Deseja realmente sair do sistema? Você precisará fazer login
novamente.
</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

@ -9,14 +9,35 @@ import { AvailabilityService } from "@/services/availabilityApi.mjs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
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 { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Calendar as CalendarShadcn } from "@/components/ui/calendar"; import { Calendar as CalendarShadcn } from "@/components/ui/calendar";
import { format, addDays } from "date-fns"; import { format, addDays } from "date-fns";
import { User, StickyNote, Calendar } from "lucide-react"; import { User, StickyNote, Check, ChevronsUpDown } from "lucide-react";
import {smsService } from "@/services/Sms.mjs" import { smsService } from "@/services/Sms.mjs";;
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
// Componentes do Combobox (Barra de Pesquisa)
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export default function ScheduleForm() { export default function ScheduleForm() {
// Estado do usuário e role // Estado do usuário e role
@ -26,8 +47,12 @@ export default function ScheduleForm() {
// Listas e seleções // Listas e seleções
const [patients, setPatients] = useState<any[]>([]); const [patients, setPatients] = useState<any[]>([]);
const [selectedPatient, setSelectedPatient] = useState(""); const [selectedPatient, setSelectedPatient] = useState("");
const [openPatientCombobox, setOpenPatientCombobox] = useState(false);
const [doctors, setDoctors] = useState<any[]>([]); const [doctors, setDoctors] = useState<any[]>([]);
const [selectedDoctor, setSelectedDoctor] = useState(""); const [selectedDoctor, setSelectedDoctor] = useState("");
const [openDoctorCombobox, setOpenDoctorCombobox] = useState(false); // Novo estado para médico
const [selectedDate, setSelectedDate] = useState(""); const [selectedDate, setSelectedDate] = useState("");
const [selectedTime, setSelectedTime] = useState(""); const [selectedTime, setSelectedTime] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
@ -39,17 +64,32 @@ export default function ScheduleForm() {
const [tipoConsulta] = useState("presencial"); const [tipoConsulta] = useState("presencial");
const [duracao] = useState("30"); const [duracao] = useState("30");
const [disponibilidades, setDisponibilidades] = useState<any[]>([]); const [disponibilidades, setDisponibilidades] = useState<any[]>([]);
const [availabilityCounts, setAvailabilityCounts] = useState<Record<string, number>>({}); const [availabilityCounts, setAvailabilityCounts] = useState<
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null); Record<string, number>
>({});
const [tooltip, setTooltip] = useState<{
x: number;
y: number;
text: string;
} | null>(null);
const calendarRef = useRef<HTMLDivElement | null>(null); const calendarRef = useRef<HTMLDivElement | null>(null);
// Funções auxiliares // Funções auxiliares
const getWeekdayNumber = (weekday: string) => const getWeekdayNumber = (weekday: string) =>
["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] [
.indexOf(weekday.toLowerCase()) + 1; "monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
].indexOf(weekday.toLowerCase()) + 1;
const getBrazilDate = (date: Date) => const getBrazilDate = (date: Date) =>
new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0)); new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0)
);
// 🔹 Buscar dados do usuário e role // 🔹 Buscar dados do usuário e role
useEffect(() => { useEffect(() => {
@ -78,7 +118,10 @@ export default function ScheduleForm() {
setDoctors(data || []); setDoctors(data || []);
} catch (err) { } catch (err) {
console.error("Erro ao buscar médicos:", err); console.error("Erro ao buscar médicos:", err);
toast({ title: "Erro", description: "Não foi possível carregar médicos." }); toast({
title: "Erro",
description: "Não foi possível carregar médicos.",
});
} finally { } finally {
setLoadingDoctors(false); setLoadingDoctors(false);
} }
@ -101,7 +144,10 @@ export default function ScheduleForm() {
} }
}, []); }, []);
const computeAvailabilityCountsPreview = async (doctorId: string, dispList: any[]) => { const computeAvailabilityCountsPreview = async (
doctorId: string,
dispList: any[]
) => {
try { try {
const today = new Date(); const today = new Date();
const start = format(today, "yyyy-MM-dd"); const start = format(today, "yyyy-MM-dd");
@ -123,7 +169,9 @@ export default function ScheduleForm() {
const d = addDays(today, i); const d = addDays(today, i);
const key = format(d, "yyyy-MM-dd"); const key = format(d, "yyyy-MM-dd");
const dayOfWeek = d.getDay() === 0 ? 7 : d.getDay(); const dayOfWeek = d.getDay() === 0 ? 7 : d.getDay();
const dailyDisp = dispList.filter((p) => getWeekdayNumber(p.weekday) === dayOfWeek); const dailyDisp = dispList.filter(
(p) => getWeekdayNumber(p.weekday) === dayOfWeek
);
if (dailyDisp.length === 0) { if (dailyDisp.length === 0) {
counts[key] = 0; counts[key] = 0;
continue; continue;
@ -135,7 +183,8 @@ export default function ScheduleForm() {
const startMin = sh * 60 + sm; const startMin = sh * 60 + sm;
const endMin = eh * 60 + em; const endMin = eh * 60 + em;
const slot = p.slot_minutes || 30; const slot = p.slot_minutes || 30;
if (endMin >= startMin) possible += Math.floor((endMin - startMin) / slot) + 1; if (endMin >= startMin)
possible += Math.floor((endMin - startMin) / slot) + 1;
}); });
const occupied = apptsByDate[key] || 0; const occupied = apptsByDate[key] || 0;
counts[key] = Math.max(0, possible - occupied); counts[key] = Math.max(0, possible - occupied);
@ -161,166 +210,157 @@ export default function ScheduleForm() {
}, [selectedDoctor, loadDoctorDisponibilidades]); }, [selectedDoctor, loadDoctorDisponibilidades]);
// 🔹 Buscar horários disponíveis // 🔹 Buscar horários disponíveis
const fetchAvailableSlots = useCallback(async (doctorId: string, date: string) => { const fetchAvailableSlots = useCallback(
if (!doctorId || !date) return; async (doctorId: string, date: string) => {
setLoadingSlots(true); if (!doctorId || !date) return;
setAvailableTimes([]); setLoadingSlots(true);
try { setAvailableTimes([]);
const disponibilidades = await AvailabilityService.listById(doctorId); try {
const consultas = await appointmentsService.search_appointment( const disponibilidades = await AvailabilityService.listById(doctorId);
`doctor_id=eq.${doctorId}&scheduled_at=gte.${date}T00:00:00Z&scheduled_at=lt.${date}T23:59:59Z` 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 diaJS = new Date(date).getDay();
const disponibilidadeDia = disponibilidades.find( const diaAPI = diaJS === 0 ? 7 : diaJS;
(d: any) => getWeekdayNumber(d.weekday) === diaAPI const disponibilidadeDia = disponibilidades.find(
); (d: any) => getWeekdayNumber(d.weekday) === diaAPI
if (!disponibilidadeDia) { );
toast({ title: "Nenhuma disponibilidade", description: "Nenhum horário para este dia." }); if (!disponibilidadeDia) {
return setAvailableTimes([]); 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);
} }
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(() => { useEffect(() => {
if (selectedDoctor && selectedDate) fetchAvailableSlots(selectedDoctor, selectedDate); if (selectedDoctor && selectedDate)
fetchAvailableSlots(selectedDoctor, selectedDate);
}, [selectedDoctor, selectedDate, fetchAvailableSlots]); }, [selectedDoctor, selectedDate, fetchAvailableSlots]);
// 🔹 Submeter agendamento // 🔹 Submeter agendamento
// 🔹 Submeter agendamento
// 🔹 Submeter agendamento
// 🔹 Submeter agendamento
// 🔹 Submeter agendamento
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const isSecretaryLike = ["secretaria", "admin", "gestor"].includes(role); const handleSubmit = async (e: React.FormEvent) => {
const patientId = isSecretaryLike ? selectedPatient : userId; e.preventDefault();
if (!patientId || !selectedDoctor || !selectedDate || !selectedTime) { const isSecretaryLike = ["secretaria", "admin", "gestor"].includes(role);
toast({ title: "Campos obrigatórios", description: "Preencha todos os campos." }); const patientId = isSecretaryLike ? selectedPatient : userId;
return;
}
try { if (!patientId || !selectedDoctor || !selectedDate || !selectedTime) {
const body = { toast({ title: "Campos obrigatórios", description: "Preencha todos os campos." });
doctor_id: selectedDoctor, return;
patient_id: patientId, }
scheduled_at: `${selectedDate}T${selectedTime}:00`,
duration_minutes: Number(duracao),
notes,
appointment_type: tipoConsulta,
};
// ✅ mantém o fluxo original de criação (funcional) try {
await appointmentsService.create(body); const body = {
doctor_id: selectedDoctor,
patient_id: patientId,
scheduled_at: `${selectedDate}T${selectedTime}:00`,
duration_minutes: Number(duracao),
notes,
appointment_type: tipoConsulta,
};
const dateFormatted = selectedDate.split("-").reverse().join("/"); await appointmentsService.create(body);
toast({ const dateFormatted = selectedDate.split("-").reverse().join("/");
title: "Consulta agendada!",
description: `Consulta marcada para ${dateFormatted} às ${selectedTime} com o(a) médico(a) ${
doctors.find((d) => d.id === selectedDoctor)?.full_name || ""
}.`,
});
let phoneNumber = "+5511999999999"; // fallback toast({
title: "Consulta agendada!",
description: `Consulta marcada para ${dateFormatted} às ${selectedTime} com o(a) médico(a) ${
doctors.find((d) => d.id === selectedDoctor)?.full_name || ""
}.`,
});
try { let phoneNumber = "+5511999999999";
if (isSecretaryLike) {
// Secretária/admin → telefone do paciente selecionado
const patient = patients.find((p: any) => p.id === patientId);
// Pacientes criados no sistema podem ter phone ou phone_mobile try {
const rawPhone = patient?.phone || patient?.phone_mobile || null; if (isSecretaryLike) {
const patient = patients.find((p: any) => p.id === patientId);
const rawPhone = patient?.phone || patient?.phone_mobile || null;
if (rawPhone) phoneNumber = rawPhone;
} else {
const me = await usersService.getMe();
const rawPhone =
me?.profile?.phone ||
(typeof me?.profile === "object" && "phone_mobile" in me.profile ? (me.profile as any).phone_mobile : null) ||
(typeof me === "object" && "user_metadata" in me ? (me as any).user_metadata?.phone : null) ||
null;
if (rawPhone) phoneNumber = rawPhone;
}
if (rawPhone) phoneNumber = rawPhone; // 🔹 Normaliza para formato internacional (+55)
} else { if (phoneNumber) {
// Paciente → telefone vem do perfil do próprio usuário logado phoneNumber = phoneNumber.replace(/\D/g, "");
const me = await usersService.getMe(); if (!phoneNumber.startsWith("55")) phoneNumber = `55${phoneNumber}`;
phoneNumber = `+${phoneNumber}`;
}
const rawPhone =
me?.profile?.phone ||
(typeof me?.profile === "object" && "phone_mobile" in me.profile ? (me.profile as any).phone_mobile : null) ||
(typeof me === "object" && "user_metadata" in me ? (me as any).user_metadata?.phone : null) ||
null;
if (rawPhone) phoneNumber = rawPhone;
}
// 🔹 Normaliza para formato internacional (+55)
if (phoneNumber) {
phoneNumber = phoneNumber.replace(/\D/g, "");
if (!phoneNumber.startsWith("55")) phoneNumber = `55${phoneNumber}`;
phoneNumber = `+${phoneNumber}`;
}
console.log("📞 Telefone usado:", phoneNumber);
} catch (err) {
console.warn("⚠️ Não foi possível obter telefone do paciente:", err);
}
// 💬 envia o SMS de confirmação
// 💬 Envia o SMS de lembrete (sem mostrar nada ao paciente)
// 💬 Envia o SMS de lembrete (somente loga no console, não mostra no sistema)
try {
const smsRes = await smsService.sendSms({
phone_number: phoneNumber,
message: `Lembrete: sua consulta é em ${dateFormatted} às ${selectedTime} na Clínica MediConnect.`,
patient_id: patientId,
});
if (smsRes?.success) {
console.log("✅ SMS enviado com sucesso:", smsRes.message_sid);
} else {
console.warn("⚠️ Falha no envio do SMS:", smsRes);
}
} catch (smsErr) {
console.error("❌ Erro ao enviar SMS:", smsErr);
}
// 🧹 limpa os campos
setSelectedDoctor("");
setSelectedDate("");
setSelectedTime("");
setNotes("");
setSelectedPatient("");
} catch (err) {
console.error("❌ Erro ao agendar consulta:", err);
toast({ title: "Erro", description: "Falha ao agendar consulta." });
}
};
console.log("📞 Telefone usado:", phoneNumber);
} catch (err) {
console.warn("⚠️ Não foi possível obter telefone do paciente:", err);
}
// 💬 envia o SMS de confirmação
// 💬 Envia o SMS de lembrete (sem mostrar nada ao paciente)
// 💬 Envia o SMS de lembrete (somente loga no console, não mostra no sistema)
try {
const smsRes = await smsService.sendSms({
phone_number: phoneNumber,
message: `Lembrete: sua consulta é em ${dateFormatted} às ${selectedTime} na Clínica MediConnect.`,
patient_id: patientId,
});
if (smsRes?.success) {
console.log("✅ SMS enviado com sucesso:", smsRes.message_sid);
} else {
console.warn("⚠️ Falha no envio do SMS:", smsRes);
}
} catch (smsErr) {
console.error("❌ Erro ao enviar SMS:", smsErr);
}
// 🧹 limpa os campos
setSelectedDoctor("");
setSelectedDate("");
setSelectedTime("");
setNotes("");
setSelectedPatient("");
} catch (err) {
console.error("❌ Erro ao agendar consulta:", err);
toast({ title: "Erro", description: "Falha ao agendar consulta." });
}
};
// 🔹 Tooltip no calendário // 🔹 Tooltip no calendário
useEffect(() => { useEffect(() => {
@ -360,72 +400,147 @@ try {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <form onSubmit={handleSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-3"> <div className="space-y-4"> {/* Ajuste: maior espaçamento vertical geral */}
{/* Se secretária/gestor/admin → mostrar campo Paciente */}
{/* Se secretária/gestor/admin → COMBOBOX de Paciente */}
{["secretaria", "gestor", "admin"].includes(role) && ( {["secretaria", "gestor", "admin"].includes(role) && (
<div> <div className="flex flex-col gap-2"> {/* Ajuste: gap entre Label e Input */}
<Label>Paciente</Label> <Label>Paciente</Label>
<Select value={selectedPatient} onValueChange={setSelectedPatient}> <Popover open={openPatientCombobox} onOpenChange={setOpenPatientCombobox}>
<SelectTrigger> <PopoverTrigger asChild>
<SelectValue placeholder="Selecione o paciente" /> <Button
</SelectTrigger> variant="outline"
<SelectContent> role="combobox"
{patients.map((p) => ( aria-expanded={openPatientCombobox}
<SelectItem key={p.id} value={p.id}>{p.full_name}</SelectItem> className="w-full justify-between"
))} >
</SelectContent> {selectedPatient
</Select> ? patients.find((p) => p.id === selectedPatient)?.full_name
: "Selecione o paciente..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0">
<Command>
<CommandInput placeholder="Buscar paciente..." />
<CommandList>
<CommandEmpty>Nenhum paciente encontrado.</CommandEmpty>
<CommandGroup>
{patients.map((patient) => (
<CommandItem
key={patient.id}
value={patient.full_name}
onSelect={() => {
setSelectedPatient(patient.id === selectedPatient ? "" : patient.id);
setOpenPatientCombobox(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedPatient === patient.id ? "opacity-100" : "opacity-0"
)}
/>
{patient.full_name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div> </div>
)} )}
<div> {/* COMBOBOX de Médico (Nova funcionalidade) */}
<div className="flex flex-col gap-2"> {/* Ajuste: gap entre Label e Input */}
<Label>Médico</Label> <Label>Médico</Label>
<Select value={selectedDoctor} onValueChange={setSelectedDoctor}> <Popover open={openDoctorCombobox} onOpenChange={setOpenDoctorCombobox}>
<SelectTrigger> <PopoverTrigger asChild>
<SelectValue placeholder="Selecione o médico" /> <Button
</SelectTrigger> variant="outline"
<SelectContent> role="combobox"
{loadingDoctors ? ( aria-expanded={openDoctorCombobox}
<SelectItem value="loading" disabled>Carregando...</SelectItem> className="w-full justify-between"
) : ( disabled={loadingDoctors}
doctors.map((d) => ( >
<SelectItem key={d.id} value={d.id}> {loadingDoctors
{d.full_name} {d.specialty} ? "Carregando médicos..."
</SelectItem> : selectedDoctor
)) ? doctors.find((d) => d.id === selectedDoctor)?.full_name
)} : "Selecione o médico..."}
</SelectContent> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Select> </Button>
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0">
<Command>
<CommandInput placeholder="Buscar médico..." />
<CommandList>
<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);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedDoctor === doctor.id ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{doctor.full_name}</span>
<span className="text-xs text-muted-foreground">{doctor.specialty}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div> </div>
<div> <div className="flex flex-col gap-2">
<Label>Data</Label> <Label>Data</Label>
<div ref={calendarRef} className="rounded-lg border p-2"> <div ref={calendarRef} className="rounded-lg border p-2">
<CalendarShadcn <CalendarShadcn
mode="single" mode="single"
disabled={!selectedDoctor} disabled={!selectedDoctor}
selected={selectedDate ? new Date(selectedDate + "T12:00:00") : undefined} selected={
selectedDate
? new Date(selectedDate + "T12:00:00")
: undefined
}
onSelect={(date) => { onSelect={(date) => {
if (!date) return; if (!date) return;
const formatted = format(new Date(date.getTime() + 12 * 60 * 60 * 1000), "yyyy-MM-dd"); const formatted = format(
new Date(date.getTime() + 12 * 60 * 60 * 1000),
"yyyy-MM-dd"
);
setSelectedDate(formatted); setSelectedDate(formatted);
}} }}
/> />
</div> </div>
</div> </div>
<div> <div className="flex flex-col gap-2">
<Label>Observações</Label> <Label>Observações</Label>
<Textarea <Textarea
placeholder="Instruções para o médico..." placeholder="Instruções para o médico..."
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
className="mt-2" className="mt-1"
/> />
</div> </div>
</div> </div>
<div className="space-y-3"> <div className="space-y-4"> {/* Ajuste: Espaçamento no lado direito também */}
<Card className="shadow-md rounded-xl bg-blue-50 border border-blue-200"> <Card className="shadow-md rounded-xl bg-blue-50 border border-blue-200">
<CardHeader> <CardHeader>
<CardTitle className="text-blue-700">Resumo</CardTitle> <CardTitle className="text-blue-700">Resumo</CardTitle>
@ -436,7 +551,8 @@ try {
<User className="h-4 w-4 text-blue-600" /> <User className="h-4 w-4 text-blue-600" />
<div className="text-xs"> <div className="text-xs">
{selectedDoctor {selectedDoctor
? doctors.find((d) => d.id === selectedDoctor)?.full_name ? doctors.find((d) => d.id === selectedDoctor)
?.full_name
: "Médico"} : "Médico"}
</div> </div>
</div> </div>
@ -447,7 +563,10 @@ try {
<div className="space-y-2"> <div className="space-y-2">
<Label>Horário</Label> <Label>Horário</Label>
<Select onValueChange={setSelectedTime} disabled={loadingSlots || availableTimes.length === 0}> <Select
onValueChange={setSelectedTime}
disabled={loadingSlots || availableTimes.length === 0}
>
<SelectTrigger> <SelectTrigger>
<SelectValue <SelectValue
placeholder={ placeholder={
@ -461,7 +580,9 @@ try {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{availableTimes.map((h) => ( {availableTimes.map((h) => (
<SelectItem key={h} value={h}>{h}</SelectItem> <SelectItem key={h} value={h}>
{h}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>

View File

@ -0,0 +1,105 @@
"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-gray-500 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-blue-50 rounded-lg">
<p className="font-medium capitalize">{weekdaysPT[day]}</p>
<div className="text-center">
{times.length > 0 ? (
times.map((t, i) => (
<p key={i} className="text-sm text-gray-600">
{formatTime(t.start)} <br /> {formatTime(t.end)}
</p>
))
) : (
<p className="text-sm text-gray-400 italic">Sem horário</p>
)}
</div>
</div>
</div>
);
})
)}
</div>
);
}

View File

@ -0,0 +1,115 @@
"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-white p-4 rounded-lg border border-gray-200 ${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-gray-400" />
<Input
placeholder={searchPlaceholder}
value={searchTerm}
onChange={(e) => onSearch(e.target.value)}
className="pl-10 w-full bg-gray-50 border-gray-200 focus:bg-white 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-gray-500 hover:text-red-600"
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,94 +1,145 @@
'use client' "use client";
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose Dialog,
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({ patient, isOpen, onClose }: PatientDetailsModalProps) { export function PatientDetailsModal({
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="sm:max-w-[600px]"> <DialogContent className="max-w-[95%] sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Detalhes do Paciente</DialogTitle> <DialogTitle className="text-xl font-bold">Detalhes do Paciente</DialogTitle>
<DialogDescription>Informações detalhadas sobre o paciente.</DialogDescription> <DialogDescription>
Informações detalhadas sobre o paciente.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4"> <div className="space-y-4 py-2">
{/* Grid Principal */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<p className="font-semibold">Nome Completo</p> <p className="font-semibold text-gray-900">Nome Completo</p>
<p>{patient.nome}</p> <p className="text-gray-700">{patient.nome}</p>
</div> </div>
{/* CORREÇÃO AQUI: Adicionado 'break-all' para quebrar o email */}
<div> <div>
<p className="font-semibold">Email</p> <p className="font-semibold text-gray-900">Email</p>
<p>{patient.email}</p> <p className="text-gray-700 break-all">{patient.email || "N/A"}</p>
</div> </div>
<div> <div>
<p className="font-semibold">Telefone</p> <p className="font-semibold text-gray-900">Telefone</p>
<p>{patient.telefone}</p> <p className="text-gray-700">{patient.telefone}</p>
</div> </div>
<div> <div>
<p className="font-semibold">Data de Nascimento</p> <p className="font-semibold text-gray-900">Data de Nascimento</p>
<p>{patient.birth_date}</p> <p className="text-gray-700">{patient.birth_date || "N/A"}</p>
</div> </div>
<div> <div>
<p className="font-semibold">CPF</p> <p className="font-semibold text-gray-900">CPF</p>
<p>{patient.cpf}</p> <p className="text-gray-700">{patient.cpf || "N/A"}</p>
</div> </div>
<div> <div>
<p className="font-semibold">Tipo Sanguíneo</p> <p className="font-semibold text-gray-900">Tipo Sanguíneo</p>
<p>{patient.blood_type}</p> <p className="text-gray-700">{patient.blood_type || "N/A"}</p>
</div> </div>
<div> <div>
<p className="font-semibold">Peso (kg)</p> <p className="font-semibold text-gray-900">Peso (kg)</p>
<p>{patient.weight_kg}</p> <p className="text-gray-700">{patient.weight_kg || "0"}</p>
</div> </div>
<div> <div>
<p className="font-semibold">Altura (m)</p> <p className="font-semibold text-gray-900">Altura (m)</p>
<p>{patient.height_m}</p> <p className="text-gray-700">{patient.height_m || "0"}</p>
</div> </div>
</div> </div>
<div className="border-t pt-4 mt-4">
<h3 className="font-semibold mb-2">Endereço</h3> <hr className="border-gray-200" />
<div className="grid grid-cols-2 gap-4">
{/* Seção de Endereço */}
<div>
<h4 className="font-semibold mb-3 text-gray-900">Endereço</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<p className="font-semibold">Rua</p> <p className="font-semibold text-gray-900">Rua</p>
<p>{`${patient.street}, ${patient.number}`}</p> <p className="text-gray-700">
{patient.street && patient.street !== "N/A"
? `${patient.street}, ${patient.number || ""}`
: "N/A"}
</p>
</div> </div>
<div> <div>
<p className="font-semibold">Complemento</p> <p className="font-semibold text-gray-900">Complemento</p>
<p>{patient.complement}</p> <p className="text-gray-700">{patient.complement || "N/A"}</p>
</div> </div>
<div> <div>
<p className="font-semibold">Bairro</p> <p className="font-semibold text-gray-900">Bairro</p>
<p>{patient.neighborhood}</p> <p className="text-gray-700">{patient.neighborhood || "N/A"}</p>
</div> </div>
<div> <div>
<p className="font-semibold">Cidade</p> <p className="font-semibold text-gray-900">Cidade</p>
<p>{patient.cidade}</p> <p className="text-gray-700">{patient.cidade || "N/A"}</p>
</div> </div>
<div> <div>
<p className="font-semibold">Estado</p> <p className="font-semibold text-gray-900">Estado</p>
<p>{patient.estado}</p> <p className="text-gray-700">{patient.estado || "N/A"}</p>
</div> </div>
<div> <div>
<p className="font-semibold">CEP</p> <p className="font-semibold text-gray-900">CEP</p>
<p>{patient.cep}</p> <p className="text-gray-700">{patient.cep || "N/A"}</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<DialogClose asChild> <Button variant="secondary" onClick={onClose} className="w-full sm:w-auto">
<button type="button" className="px-4 py-2 bg-gray-200 rounded-md">Fechar</button> Fechar
</DialogClose> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

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

94
lib/normalization.ts Normal file
View File

@ -0,0 +1,94 @@
// 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();
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 33 KiB