Merge branch 'Stage' of https://github.com/m1guelmcf/MedConnect into ajustes-agendamentio

This commit is contained in:
StsDanilo 2025-11-27 09:10:54 -03:00
commit 9fd9c05040
19 changed files with 1688 additions and 1557 deletions

View File

@ -162,7 +162,7 @@ export default function DoctorAppointmentsPage() {
<Card> <Card>
<CardHeader><CardTitle className="flex items-center"><CalendarIcon className="mr-2 h-5 w-5" />Filtrar por Data</CardTitle><CardDescription>Selecione um dia para ver os detalhes.</CardDescription></CardHeader> <CardHeader><CardTitle className="flex items-center"><CalendarIcon className="mr-2 h-5 w-5" />Filtrar por Data</CardTitle><CardDescription>Selecione um dia para ver os detalhes.</CardDescription></CardHeader>
<CardContent className="flex justify-center p-2"> <CardContent className="flex justify-center p-2">
<CalendarShadcn mode="single" selected={selectedDate} onSelect={setSelectedDate} modifiers={{ booked: bookedDays }} modifiersClassNames={{ booked: "bg-primary/20" }} className="rounded-md border p-2" locale={ptBR}/> <CalendarShadcn mode="single" selected={selectedDate} onSelect={setSelectedDate} modifiers={{ booked: bookedDays }} modifiersClassNames={{ booked: "bg-primary/20" }} className="rounded-md border p-2" locale={ptBR} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -24,6 +24,15 @@ import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
// --- IMPORTS ADICIONADOS PARA A CORREÇÃO ---
import { useAuthLayout } from "@/hooks/useAuthLayout";
import { patientsService } from "@/services/patientsApi.mjs";
// --- FIM DOS IMPORTS ADICIONADOS ---
import { appointmentsService } from "@/services/appointmentsApi.mjs";
import { format, parseISO, isAfter, isSameMonth, startOfToday } from "date-fns";
import { ptBR } from "date-fns/locale";
import { AvailabilityService } from "@/services/availabilityApi.mjs"; import { AvailabilityService } from "@/services/availabilityApi.mjs";
import { exceptionsService } from "@/services/exceptionApi.mjs"; import { exceptionsService } from "@/services/exceptionApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs"; import { doctorsService } from "@/services/doctorsApi.mjs";
@ -122,63 +131,88 @@ interface Exception {
} }
export default function PatientDashboard() { export default function PatientDashboard() {
const [loggedDoctor, setLoggedDoctor] = useState<Doctor>(); // --- USA O HOOK DE AUTENTICAÇÃO PARA PEGAR O USUÁRIO LOGADO ---
const { user } = useAuthLayout({ requiredRole: ['medico'] });
const [loggedDoctor, setLoggedDoctor] = useState<Doctor | null>(null);
const [userData, setUserData] = useState<UserData>(); const [userData, setUserData] = useState<UserData>();
const [availability, setAvailability] = useState<any | null>(null); const [availability, setAvailability] = useState<any | null>(null);
const [exceptions, setExceptions] = useState<Exception[]>([]); const [exceptions, setExceptions] = useState<Exception[]>([]);
const [schedule, setSchedule] = useState< const [schedule, setSchedule] = useState<Record<string, { start: string; end: string }[]>>({});
Record<string, { start: string; end: string }[]> const formatTime = (time?: string | null) => time?.split(":")?.slice(0, 2).join(":") ?? "";
>({});
const formatTime = (time?: string | null) =>
time?.split(":")?.slice(0, 2).join(":") ?? "";
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [exceptionToDelete, setExceptionToDelete] = useState<string | null>( const [exceptionToDelete, setExceptionToDelete] = useState<string | null>(null);
null
);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Mapa de tradução // --- ESTADOS PARA OS CARDS ATUALIZADOS ---
const weekdaysPT: Record<string, string> = { const [nextAppointment, setNextAppointment] = useState<EnrichedAppointment | null>(null);
sunday: "Domingo", const [monthlyCount, setMonthlyCount] = useState<number>(0);
monday: "Segunda",
tuesday: "Terça",
wednesday: "Quarta",
thursday: "Quinta",
friday: "Sexta",
saturday: "Sábado",
};
const weekdaysPT: Record<string, string> = { sunday: "Domingo", monday: "Segunda", tuesday: "Terça", wednesday: "Quarta", thursday: "Quinta", friday: "Sexta", saturday: "Sábado" };
// ▼▼▼ LÓGICA DE BUSCA CORRIGIDA E ATUALIZADA ▼▼▼
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (!user?.id) return; // Aguarda o usuário ser carregado
try { try {
// Encontra o perfil de médico correspondente ao usuário logado
const doctorsList: Doctor[] = await doctorsService.list(); const doctorsList: Doctor[] = await doctorsService.list();
const doctor = doctorsList[0]; const currentDoctor = doctorsList.find(doc => doc.user_id === user.id);
// Salva no estado if (!currentDoctor) {
setLoggedDoctor(doctor); setError("Perfil de médico não encontrado para este usuário.");
return;
}
setLoggedDoctor(currentDoctor);
// Busca disponibilidade // Busca todos os dados necessários em paralelo
const availabilityList = await AvailabilityService.list(); const [appointmentsList, patientsList, availabilityList, exceptionsList] = await Promise.all([
appointmentsService.list(),
patientsService.list(),
AvailabilityService.list(),
exceptionsService.list()
]);
// Filtra já com a variável local // Mapeia pacientes por ID para consulta rápida
const filteredAvail = availabilityList.filter( const patientsMap = new Map(patientsList.map((p: any) => [p.id, p.full_name]));
(disp: { doctor_id: string }) => disp.doctor_id === doctor?.id
// Filtra e enriquece as consultas APENAS do médico logado
const doctorAppointments = appointmentsList
.filter((apt: any) => apt.doctor_id === currentDoctor.id)
.map((apt: any): EnrichedAppointment => ({
...apt,
patientName: patientsMap.get(apt.patient_id) || "Paciente Desconhecido",
}));
// 1. Lógica para "Próxima Consulta"
const today = startOfToday();
const upcomingAppointments = doctorAppointments
.filter(apt => isAfter(parseISO(apt.scheduled_at), today))
.sort((a, b) => new Date(a.scheduled_at).getTime() - new Date(b.scheduled_at).getTime());
setNextAppointment(upcomingAppointments[0] || null);
// 2. Lógica para "Consultas Este Mês" (apenas ativas)
const activeStatuses = ['confirmed', 'requested', 'checked_in'];
const currentMonthAppointments = doctorAppointments.filter(apt =>
isSameMonth(parseISO(apt.scheduled_at), new Date()) && activeStatuses.includes(apt.status)
); );
setAvailability(filteredAvail); setMonthlyCount(currentMonthAppointments.length);
// Busca e filtra o restante dos dados
setAvailability(availabilityList.filter((d: any) => d.doctor_id === currentDoctor.id));
setExceptions(exceptionsList.filter((e: any) => e.doctor_id === currentDoctor.id));
// Busca exceções
const exceptionsList = await exceptionsService.list();
const filteredExc = exceptionsList.filter((exc: { doctor_id: string }) => exc.doctor_id === doctor?.id);
setExceptions(filteredExc);
} catch (e: any) { } catch (e: any) {
alert(`${e?.error} ${e?.message}`); setError(e?.message || "Erro ao buscar dados do dashboard");
console.error("Erro no dashboard:", e);
} }
}; };
fetchData(); fetchData();
}, []); }, [user]); // A busca de dados agora depende do usuário logado
// ▲▲▲ FIM DA LÓGICA DE BUSCA ATUALIZADA ▲▲▲
// Função auxiliar para filtrar o id do doctor correspondente ao user logado
function findDoctorById(id: string, doctors: Doctor[]) { function findDoctorById(id: string, doctors: Doctor[]) {
return doctors.find((doctor) => doctor.user_id === id); return doctors.find((doctor) => doctor.user_id === id);
} }
@ -190,57 +224,25 @@ export default function PatientDashboard() {
const handleDeleteException = async (ExceptionId: string) => { const handleDeleteException = async (ExceptionId: string) => {
try { try {
alert(ExceptionId);
const res = await exceptionsService.delete(ExceptionId); const res = await exceptionsService.delete(ExceptionId);
if (res && res.error) { throw new Error(res.message || "A API retornou um erro"); }
let message = "Exceção deletada com sucesso"; toast({ title: "Sucesso", description: "Exceção deletada com sucesso" });
try { setExceptions((prev: Exception[]) => prev.filter((p) => String(p.id) !== String(ExceptionId)));
if (res) {
throw new Error(
`${res.error} ${res.message}` || "A API retornou erro"
);
} else {
console.log(message);
}
} catch {}
toast({
title: "Sucesso",
description: message,
});
setExceptions((prev: Exception[]) =>
prev.filter((p) => String(p.id) !== String(ExceptionId))
);
} catch (e: any) { } catch (e: any) {
toast({ toast({ title: "Erro", description: e?.message || "Não foi possível deletar a exceção" });
title: "Erro",
description: e?.message || "Não foi possível deletar a exceção",
});
} }
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setExceptionToDelete(null); setExceptionToDelete(null);
}; };
function formatAvailability(data: Availability[]) { function formatAvailability(data: Availability[]) {
// Agrupar os horários por dia da semana if (!data) return {};
const schedule = data.reduce((acc: any, item) => { const schedule = data.reduce((acc: any, item) => {
const { weekday, start_time, end_time } = item; const { weekday, start_time, end_time } = item;
if (!acc[weekday]) acc[weekday] = [];
// Se o dia ainda não existe, cria o array acc[weekday].push({ start: start_time, end: end_time });
if (!acc[weekday]) {
acc[weekday] = [];
}
// Adiciona o horário do dia
acc[weekday].push({
start: start_time,
end: end_time,
});
return acc; return acc;
}, {} as Record<string, { start: string; end: string }[]>); }, {} as Record<string, { start: string; end: string }[]>);
return schedule; return schedule;
} }
@ -262,31 +264,44 @@ export default function PatientDashboard() {
</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 "PRÓXIMA CONSULTA" CORRIGIDO PARA MOSTRAR NOME DO PACIENTE ▼▼▼ */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium">Próxima Consulta</CardTitle>
Próxima Consulta
</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" /> <Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">02 out</div> {nextAppointment ? (
<p className="text-xs text-muted-foreground">Dr. Silva - 14:30</p> <>
<div className="text-2xl font-bold capitalize">
{format(parseISO(nextAppointment.scheduled_at), "dd MMM", { locale: ptBR })}
</div>
<p className="text-xs text-muted-foreground">
{nextAppointment.patientName} - {format(parseISO(nextAppointment.scheduled_at), "HH:mm")}
</p>
</>
) : (
<>
<div className="text-2xl font-bold">Nenhuma</div>
<p className="text-xs text-muted-foreground">Sem próximas consultas</p>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* ▲▲▲ FIM DO CARD ATUALIZADO ▲▲▲ */}
{/* ▼▼▼ CARD "CONSULTAS ESTE MÊS" CORRIGIDO PARA CONTAGEM CORRETA ▼▼▼ */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium">Consultas Este Mês</CardTitle>
Consultas Este Mês
</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" /> <Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">4</div> <div className="text-2xl font-bold">{monthlyCount}</div>
<p className="text-xs text-muted-foreground">4 agendadas</p> <p className="text-xs text-muted-foreground">{monthlyCount === 1 ? '1 agendada' : `${monthlyCount} agendadas`}</p>
</CardContent> </CardContent>
</Card> </Card>
{/* ▲▲▲ 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">
@ -300,18 +315,17 @@ export default function PatientDashboard() {
</Card> </Card>
</div> </div>
{/* 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">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Ações Rápidas</CardTitle> <CardTitle>Ações Rápidas</CardTitle>
<CardDescription> <CardDescription>Acesse rapidamente as principais funcionalidades</CardDescription>
Acesse rapidamente as principais funcionalidades
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Link href="/doctor/medicos/consultas"> <Link href="/doctor/medicos/consultas">
<Button className="bg-blue-600 hover:bg-blue-700 text-white cursor-pointer"> <Button className="w-full justify-start">
<Calendar className="mr-2 h-4 w-4 text-white" /> <Calendar className="mr-2 h-4 w-4" />
Ver Minhas Consultas Ver Minhas Consultas
</Button> </Button>
</Link> </Link>
@ -358,12 +372,11 @@ export default function PatientDashboard() {
<CardContent className="space-y-4 grid md:grid-cols-7 gap-2"> <CardContent className="space-y-4 grid md:grid-cols-7 gap-2">
{exceptions && exceptions.length > 0 ? ( {exceptions && exceptions.length > 0 ? (
exceptions.map((ex: Exception) => { exceptions.map((ex: Exception) => {
// Formata data e hora
const date = new Date(ex.date).toLocaleDateString("pt-BR", { const date = new Date(ex.date).toLocaleDateString("pt-BR", {
weekday: "long", weekday: "long",
day: "2-digit", day: "2-digit",
month: "long", month: "long",
timeZone: "UTC", timeZone: "UTC"
}); });
const startTime = formatTime(ex.start_time); const startTime = formatTime(ex.start_time);
@ -374,7 +387,11 @@ export default function PatientDashboard() {
<div className="flex flex-col items-center justify-between p-3 bg-blue-50 rounded-lg shadow-sm"> <div className="flex flex-col items-center justify-between p-3 bg-blue-50 rounded-lg shadow-sm">
<div className="text-center"> <div className="text-center">
<p className="font-semibold capitalize">{date}</p> <p className="font-semibold capitalize">{date}</p>
<p className="text-sm text-gray-600">{startTime && endTime ? `${startTime} - ${endTime}` : "Dia todo"}</p> <p className="text-sm text-gray-600">
{startTime && endTime
? `${startTime} - ${endTime}`
: "Dia todo"}
</p>
</div> </div>
<div className="text-center mt-2"> <div className="text-center mt-2">
<p className={`text-sm font-medium ${ex.kind === "bloqueio" ? "text-red-600" : "text-green-600"}`}>{ex.kind === "bloqueio" ? "Bloqueio" : "Liberação"}</p> <p className={`text-sm font-medium ${ex.kind === "bloqueio" ? "text-red-600" : "text-green-600"}`}>{ex.kind === "bloqueio" ? "Bloqueio" : "Liberação"}</p>

View File

@ -144,10 +144,6 @@ export default function EditarLaudoPage() {
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="order_number"> do Pedido</Label>
<Input id="order_number" value={formData.order_number || ''} onChange={handleInputChange} />
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="exam">Exame</Label> <Label htmlFor="exam">Exame</Label>
<Input id="exam" value={formData.exam || ''} onChange={handleInputChange} /> <Input id="exam" value={formData.exam || ''} onChange={handleInputChange} />

View File

@ -1,5 +1,4 @@
"use client";
"use client";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
@ -106,10 +105,6 @@ export default function NovoLaudoPage() {
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="order_number"> do Pedido</Label>
<Input id="order_number" value={formData.order_number} onChange={handleInputChange} />
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="exam">Exame</Label> <Label htmlFor="exam">Exame</Label>
<Input id="exam" value={formData.exam} onChange={handleInputChange} /> <Input id="exam" value={formData.exam} onChange={handleInputChange} />

View File

@ -8,7 +8,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Eye, Edit, Calendar, Trash2, Loader2 } from "lucide-react"; import { Eye, Edit, Calendar, Trash2, Loader2, MoreVertical } 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 { import {
@ -50,7 +50,7 @@ export default function PacientesPage() {
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);
@ -197,14 +197,6 @@ export default function PacientesPage() {
<SelectItem value="20">20 por página</SelectItem> <SelectItem value="20">20 por página</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Link href="/doctor/pacientes/novo" className="w-full sm:w-auto">
<Button
variant="default"
className="bg-blue-600 hover:bg-blue-700 w-full sm:w-auto"
>
Novo Paciente
</Button>
</Link>
</div> </div>
</div> </div>
@ -291,9 +283,10 @@ export default function PacientesPage() {
<td className="p-3 sm:p-4"> <td className="p-3 sm:p-4">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button className="text-primary hover:underline text-sm sm:text-base"> <Button variant="ghost" className="h-8 w-8 p-0">
Ações <span className="sr-only">Abrir menu</span>
</button> <MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem
@ -303,19 +296,11 @@ export default function PacientesPage() {
Ver detalhes Ver detalhes
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/doctor/pacientes/${p.id}/laudos`}> <Link href={`/doctor/medicos/${p.id}/laudos`}>
<Edit className="w-4 h-4 mr-2" /> <Edit className="w-4 h-4 mr-2" />
Laudos Laudos
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
alert(`Agenda para paciente ID: ${p.id}`)
}
>
<Calendar className="w-4 h-4 mr-2" />
Ver agenda
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
const newPacientes = pacientes.filter( const newPacientes = pacientes.filter(

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

View File

@ -8,12 +8,13 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar, Clock, Plus, User } from "lucide-react"; 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";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { usersService } from "services/usersApi.mjs"; import { usersService } from "services/usersApi.mjs";
import { doctorsService } from "services/doctorsApi.mjs"; import { doctorsService } from "services/doctorsApi.mjs";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import { api } from "services/api.mjs"; // <-- ADICIONEI ESTE IMPORT
export default function ManagerDashboard() { export default function ManagerDashboard() {
// 🔹 Estados para usuários // 🔹 Estados para usuários
@ -24,16 +25,44 @@ export default function ManagerDashboard() {
const [doctors, setDoctors] = useState<any[]>([]); const [doctors, setDoctors] = useState<any[]>([]);
const [loadingDoctors, setLoadingDoctors] = useState(true); const [loadingDoctors, setLoadingDoctors] = useState(true);
// 🔹 Buscar primeiro usuário // 🔹 Buscar primeiro usuário (LÓGICA ATUALIZADA)
useEffect(() => { useEffect(() => {
async function fetchFirstUser() { async function fetchFirstUser() {
setLoadingUser(true); // Garante que o estado de loading inicie como true
try { try {
const data = await usersService.list_roles(); // 1. Busca a lista de usuários com seus cargos (roles)
if (Array.isArray(data) && data.length > 0) { const rolesData = await usersService.list_roles();
setFirstUser(data[0]);
// 2. Verifica se a lista não está vazia
if (Array.isArray(rolesData) && rolesData.length > 0) {
const firstUserRole = rolesData[0];
const firstUserId = firstUserRole.user_id;
if (!firstUserId) {
throw new Error("O primeiro usuário da lista não possui um ID válido.");
}
// 3. Usa o ID para buscar o perfil (com nome e email) do usuário
const profileData = await api.get(
`/rest/v1/profiles?select=full_name,email&id=eq.${firstUserId}`
);
// 4. Verifica se o perfil foi encontrado
if (Array.isArray(profileData) && profileData.length > 0) {
const userProfile = profileData[0];
// 5. Combina os dados do cargo e do perfil e atualiza o estado
setFirstUser({
...firstUserRole,
...userProfile
});
} else {
// Se não encontrar o perfil, exibe os dados que temos
setFirstUser(firstUserRole);
}
} }
} catch (error) { } catch (error) {
console.error("Erro ao carregar usuário:", error); console.error("Erro ao carregar usuário:", error);
setFirstUser(null); // Limpa o usuário em caso de erro
} finally { } finally {
setLoadingUser(false); setLoadingUser(false);
} }
@ -73,21 +102,7 @@ export default function ManagerDashboard() {
{/* 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 1 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Relatórios gerenciais
</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">0</div>
<p className="text-xs text-muted-foreground">
Relatórios disponíveis
</p>
</CardContent>
</Card>
{/* Card 2 — Gestão de usuários */} {/* Card 2 — Gestão de usuários */}
<Card> <Card>

View File

@ -225,7 +225,7 @@ export default function EditarMedicoPage() {
Editar Médico: <span className="text-green-600">{formData.nomeCompleto}</span> Editar Médico: <span className="text-green-600">{formData.nomeCompleto}</span>
</h1> </h1>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Atualize as informações do médico (ID: {id}). Atualize as informações do médico
</p> </p>
</div> </div>
<Link href="/manager/home"> <Link href="/manager/home">

View File

@ -4,34 +4,19 @@ 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 { import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
DropdownMenu, import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
DropdownMenuContent, import { Edit, Trash2, Eye, Calendar, Loader2, MoreVertical } from "lucide-react";
DropdownMenuItem, import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
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 {
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;
@ -66,41 +51,44 @@ interface DoctorDetails {
export default function DoctorsPage() { export default function DoctorsPage() {
const router = useRouter(); const router = useRouter();
// --- Estados de Dados ---
const [doctors, setDoctors] = useState<Doctor[]>([]); const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// --- Estados de Modais ---
const [detailsDialogOpen, setDetailsDialogOpen] = useState(false); const [detailsDialogOpen, setDetailsDialogOpen] = useState(false);
const [doctorDetails, setDoctorDetails] = useState<DoctorDetails | null>( const [doctorDetails, setDoctorDetails] = useState<DoctorDetails | null>(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: status: index % 3 === 0 ? "Inativo" : index % 2 === 0 ? "Férias" : "Ativo",
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( setError("Não foi possível carregar a lista de médicos. Verifique a conexão com a API.");
"Não foi possível carregar a lista de médicos. Verifique a conexão com a API."
);
setDoctors([]); setDoctors([]);
} finally { } finally {
setLoading(false); setLoading(false);
@ -111,74 +99,63 @@ export default function DoctorsPage() {
fetchDoctors(); fetchDoctors();
}, [fetchDoctors]); }, [fetchDoctors]);
const openDetailsDialog = async (doctor: Doctor) => { // 2. Gerar lista única de especialidades (Normalizada)
setDetailsDialogOpen(true);
setDoctorDetails({
nome: doctor.full_name,
crm: doctor.crm,
especialidade: doctor.specialty,
contato: { celular: doctor.phone_mobile ?? undefined },
endereco: {
cidade: doctor.city ?? undefined,
estado: doctor.state ?? undefined,
},
status: doctor.status || "Ativo",
convenio: "Particular",
vip: false,
ultimo_atendimento: "N/A",
proximo_atendimento: "N/A",
});
};
const handleDelete = async () => {
if (doctorToDeleteId === null) return;
setLoading(true);
try {
await doctorsService.delete(doctorToDeleteId);
setDeleteDialogOpen(false);
setDoctorToDeleteId(null);
await fetchDoctors();
} catch (e) {
console.error("Erro ao excluir:", e);
alert("Erro ao excluir médico.");
} finally {
setLoading(false);
}
};
const openDeleteDialog = (doctorId: number) => {
setDoctorToDeleteId(doctorId);
setDeleteDialogOpen(true);
};
const uniqueSpecialties = useMemo(() => { const uniqueSpecialties = useMemo(() => {
const specialties = doctors return getUniqueSpecialties(doctors);
.map((doctor) => doctor.specialty)
.filter(Boolean);
return [...new Set(specialties)];
}, [doctors]); }, [doctors]);
const filteredDoctors = doctors.filter((doctor) => { // 3. Lógica de Filtragem Centralizada
const specialtyMatch = const filteredDoctors = useMemo(() => {
specialtyFilter === "all" || doctor.specialty === specialtyFilter; return doctors.filter((doctor) => {
const statusMatch = // Normaliza a especialidade do médico atual para comparar
statusFilter === "all" || doctor.status === statusFilter; const normalizedDocSpecialty = normalizeSpecialty(doctor.specialty);
return specialtyMatch && statusMatch;
});
// 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 totalPages = Math.ceil(filteredDoctors.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage; const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage; const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredDoctors.slice(indexOfFirstItem, indexOfLastItem); const currentItems = filteredDoctors.slice(indexOfFirstItem, indexOfLastItem);
const paginate = (pageNumber: number) => setCurrentPage(pageNumber); const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
const goToPrevPage = () => setCurrentPage((prev) => Math.max(1, prev - 1));
const goToPrevPage = () => { const goToNextPage = () => setCurrentPage((prev) => Math.min(totalPages, prev + 1));
setCurrentPage((prev) => Math.max(1, prev - 1));
};
const goToNextPage = () => {
setCurrentPage((prev) => Math.min(totalPages, prev + 1));
};
const getVisiblePageNumbers = (totalPages: number, currentPage: number) => { const getVisiblePageNumbers = (totalPages: number, currentPage: number) => {
const pages: number[] = []; const pages: number[] = [];
@ -204,9 +181,42 @@ export default function DoctorsPage() {
const visiblePageNumbers = getVisiblePageNumbers(totalPages, currentPage); const visiblePageNumbers = getVisiblePageNumbers(totalPages, currentPage);
const handleItemsPerPageChange = (value: string) => { // --- Handlers de Ações (Detalhes e Delete) ---
setItemsPerPage(Number(value)); const openDetailsDialog = (doctor: Doctor) => {
setCurrentPage(1); setDetailsDialogOpen(true);
setDoctorDetails({
nome: doctor.full_name,
crm: doctor.crm,
especialidade: normalizeSpecialty(doctor.specialty), // Exibe normalizado
contato: { celular: doctor.phone_mobile ?? undefined },
endereco: { cidade: doctor.city ?? undefined, estado: doctor.state ?? undefined },
status: doctor.status || "Ativo",
convenio: "Particular",
vip: false,
ultimo_atendimento: "N/A",
proximo_atendimento: "N/A",
});
};
const openDeleteDialog = (doctorId: number) => {
setDoctorToDeleteId(doctorId);
setDeleteDialogOpen(true);
};
const handleDelete = async () => {
if (doctorToDeleteId === null) return;
setLoading(true);
try {
await doctorsService.delete(doctorToDeleteId);
setDeleteDialogOpen(false);
setDoctorToDeleteId(null);
await fetchDoctors();
} catch (e) {
console.error("Erro ao excluir:", e);
alert("Erro ao excluir médico.");
} finally {
setLoading(false);
}
}; };
return ( return (
@ -224,65 +234,43 @@ export default function DoctorsPage() {
</div> </div>
</div> </div>
{/* Filtros e Itens por Página */} {/* --- NOVO COMPONENTE DE FILTRO --- */}
<div className="flex flex-wrap items-center gap-3 bg-white p-3 sm:p-4 rounded-lg border border-gray-200"> <FilterBar
<div className="flex items-center gap-2 w-full md:w-auto"> searchTerm={searchTerm}
<span className="text-sm font-medium text-foreground"> onSearch={handleSearch}
Especialidade activeFilters={filters}
</span> onFilterChange={handleFilterChange}
<Select value={specialtyFilter} onValueChange={setSpecialtyFilter}> onClearFilters={handleClearFilters}
<SelectTrigger className="w-[160px] sm:w-[180px]"> searchPlaceholder="Buscar por nome, CRM ou telefone..."
<SelectValue placeholder="Especialidade" /> filters={[
</SelectTrigger> {
<SelectContent> key: "specialty",
<SelectItem value="all">Todas</SelectItem> label: "Especialidade",
{uniqueSpecialties.map((specialty) => ( options: uniqueSpecialties
<SelectItem key={specialty} value={specialty}> },
{specialty} {
</SelectItem> key: "status",
))} label: "Status",
</SelectContent> options: ["Ativo", "Férias", "Inativo"]
</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)}
> >
<SelectTrigger className="w-[140px]"> {/* Seletor de Itens por Página (Filho do FilterBar) */}
<SelectValue placeholder="Itens por pág." /> <div className="hidden lg:block">
<Select onValueChange={handleItemsPerPageChange} defaultValue={String(itemsPerPage)}>
<SelectTrigger className="w-[70px]">
<SelectValue placeholder="10" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="5">5 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">
@ -293,44 +281,22 @@ export default function DoctorsPage() {
<div className="p-8 text-center text-red-600">{error}</div> <div className="p-8 text-center text-red-600">{error}</div>
) : filteredDoctors.length === 0 ? ( ) : filteredDoctors.length === 0 ? (
<div className="p-8 text-center text-gray-500"> <div className="p-8 text-center text-gray-500">
{doctors.length === 0 ? ( {doctors.length === 0
<> ? <>Nenhum médico cadastrado. <Link href="/manager/home/novo" className="text-green-600 hover:underline">Adicione um novo</Link>.</>
Nenhum médico cadastrado.{" "} : "Nenhum médico encontrado com os filtros aplicados."
<Link }
href="/manager/home/novo"
className="text-green-600 hover:underline"
>
Adicione um novo
</Link>
.
</>
) : (
"Nenhum médico encontrado com os filtros aplicados."
)}
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[600px]"> <table className="w-full min-w-[600px]">
<thead className="bg-gray-50 border-b border-gray-200"> <thead className="bg-gray-50 border-b border-gray-200">
<tr> <tr>
<th className="text-left p-2 md:p-4 font-medium text-gray-700"> <th className="text-left p-2 md:p-4 font-medium text-gray-700">Nome</th>
Nome <th className="text-left p-2 md:p-4 font-medium text-gray-700">CRM</th>
</th> <th className="text-left p-2 md:p-4 font-medium text-gray-700">Especialidade</th>
<th className="text-left p-2 md:p-4 font-medium text-gray-700"> <th className="text-left p-2 md:p-4 font-medium text-gray-700 hidden lg:table-cell">Status</th>
CRM <th className="text-left p-2 md:p-4 font-medium text-gray-700 hidden xl:table-cell">Cidade/Estado</th>
</th> <th className="text-right p-4 font-medium text-gray-700">Ações</th>
<th className="text-left p-2 md:p-4 font-medium text-gray-700">
Especialidade
</th>
<th className="text-left p-2 md:p-4 font-medium text-gray-700 hidden lg:table-cell">
Status
</th>
<th className="text-left p-2 md:p-4 font-medium text-gray-700 hidden xl:table-cell">
Cidade/Estado
</th>
<th className="text-right p-4 font-medium text-gray-700">
Ações
</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
@ -339,33 +305,33 @@ export default function DoctorsPage() {
<td className="px-4 py-3 font-medium text-gray-900"> <td className="px-4 py-3 font-medium text-gray-900">
{doctor.full_name} {doctor.full_name}
</td> </td>
<td className="px-4 py-3 text-gray-500 hidden sm:table-cell"> <td className="px-4 py-3 text-gray-500 hidden sm:table-cell">{doctor.crm}</td>
{doctor.crm}
</td>
<td className="px-4 py-3 text-gray-500 hidden md:table-cell"> <td className="px-4 py-3 text-gray-500 hidden md:table-cell">
{doctor.specialty} {/* Exibe Especialidade Normalizada */}
{normalizeSpecialty(doctor.specialty)}
</td> </td>
<td className="px-4 py-3 text-gray-500 hidden lg:table-cell"> <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"} {doctor.status || "N/A"}
</span>
</td> </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.city && doctor.state ? '/' : ''}${doctor.state || ""}`
doctor.city && doctor.state ? "/" : ""
}${doctor.state || ""}`
: "N/A"} : "N/A"}
</td> </td>
<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"> <div className="text-black-600 cursor-pointer inline-block">
Ações <MoreVertical className="h-4 w-4" />
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem onClick={() => openDetailsDialog(doctor)}>
onClick={() => openDetailsDialog(doctor)}
>
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
Ver detalhes Ver detalhes
</DropdownMenuItem> </DropdownMenuItem>
@ -379,10 +345,7 @@ export default function DoctorsPage() {
<Calendar className="mr-2 h-4 w-4" /> <Calendar className="mr-2 h-4 w-4" />
Marcar consulta Marcar consulta
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem className="text-red-600" onClick={() => openDeleteDialog(doctor.id)}>
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
</DropdownMenuItem> </DropdownMenuItem>
@ -397,7 +360,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">
@ -408,46 +371,37 @@ export default function DoctorsPage() {
<div className="p-8 text-center text-red-600">{error}</div> <div className="p-8 text-center text-red-600">{error}</div>
) : filteredDoctors.length === 0 ? ( ) : filteredDoctors.length === 0 ? (
<div className="p-8 text-center text-gray-500"> <div className="p-8 text-center text-gray-500">
{doctors.length === 0 ? ( {doctors.length === 0
<> ? <>Nenhum médico cadastrado. <Link href="/manager/home/novo" className="text-green-600 hover:underline">Adicione um novo</Link>.</>
Nenhum médico cadastrado.{" "} : "Nenhum médico encontrado com os filtros aplicados."
<Link }
href="/manager/home/novo"
className="text-green-600 hover:underline"
>
Adicione um novo
</Link>
.
</>
) : (
"Nenhum médico encontrado com os filtros aplicados."
)}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{currentItems.map((doctor) => ( {currentItems.map((doctor) => (
<div <div key={doctor.id} className="bg-gray-50 rounded-lg p-4 flex justify-between items-center border border-gray-100">
key={doctor.id}
className="bg-white-50 rounded-lg p-4 flex justify-between items-center border border-white-200"
>
<div> <div>
<div className="font-semibold text-gray-900"> <div className="font-semibold text-gray-900">{doctor.full_name}</div>
{doctor.full_name} <div className="text-xs text-gray-500 mb-1">{doctor.phone_mobile}</div>
</div> <div className="text-sm text-gray-600">{normalizeSpecialty(doctor.specialty)}</div>
<div className="text-sm text-gray-600"> <div className="text-xs mt-1">
{doctor.specialty} <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> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<div className="text-blue-600 cursor-pointer inline-block"> <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
Ações <span className="sr-only">Abrir menu</span>
</div> <div className="font-bold text-gray-500">...</div>
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem onClick={() => openDetailsDialog(doctor)}>
onClick={() => openDetailsDialog(doctor)}
>
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
Ver detalhes Ver detalhes
</DropdownMenuItem> </DropdownMenuItem>
@ -457,14 +411,7 @@ export default function DoctorsPage() {
Editar Editar
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem className="text-red-600" onClick={() => openDeleteDialog(doctor.id)}>
<Calendar className="mr-2 h-4 w-4" />
Marcar consulta
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => openDeleteDialog(doctor.id)}
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Excluir Excluir
</DropdownMenuItem> </DropdownMenuItem>
@ -511,26 +458,17 @@ export default function DoctorsPage() {
</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>
<AlertDialogTitle>Confirma a exclusão?</AlertDialogTitle> <AlertDialogTitle>Confirma a exclusão?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>Esta ação é irreversível e excluirá permanentemente o registro deste médico.</AlertDialogDescription>
Esta ação é irreversível e excluirá permanentemente o registro
deste médico.
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancelar</AlertDialogCancel> <AlertDialogCancel disabled={loading}>Cancelar</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700" disabled={loading}>
onClick={handleDelete} {loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
className="bg-red-600 hover:bg-red-700"
disabled={loading}
>
{loading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : null}
Excluir Excluir
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View File

@ -16,7 +16,16 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Edit, Trash2, Eye, Calendar, Filter, Loader2 } from "lucide-react"; import {
Plus,
Edit,
Trash2,
Eye,
Calendar,
Filter,
Loader2,
MoreVertical,
} from "lucide-react";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -81,7 +90,9 @@ export default function PacientesPage() {
estado: p.state ?? "—", estado: p.state ?? "—",
// Formate as datas se necessário, aqui usamos como string // Formate as datas se necessário, aqui usamos como string
ultimoAtendimento: p.last_visit_at?.split("T")[0] ?? "—", ultimoAtendimento: p.last_visit_at?.split("T")[0] ?? "—",
proximoAtendimento: p.next_appointment_at?.split("T")[0] ?? "—", proximoAtendimento: p.next_appointment_at
? p.next_appointment_at.split("T")[0].split("-").reverse().join("-")
: "—",
vip: Boolean(p.vip ?? false), vip: Boolean(p.vip ?? false),
convenio: p.convenio ?? "Particular", // Define um valor padrão convenio: p.convenio ?? "Particular", // Define um valor padrão
status: p.status ?? undefined, status: p.status ?? undefined,
@ -302,7 +313,6 @@ export default function PacientesPage() {
{patient.nome?.charAt(0) || "?"} {patient.nome?.charAt(0) || "?"}
</span> </span>
</div> </div>
<span className="font-medium text-gray-900"> <span className="font-medium text-gray-900">
{patient.nome} {patient.nome}
{patient.vip && ( {patient.vip && (
@ -330,8 +340,8 @@ export default function PacientesPage() {
<td className="p-4"> <td className="p-4">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<div className="text-blue-600 cursor-pointer"> <div className="text-black-600 cursor-pointer">
Ações <MoreVertical className="h-4 w-4" />
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@ -343,7 +353,6 @@ export default function PacientesPage() {
<Eye className="w-4 h-4 mr-2" /> <Eye className="w-4 h-4 mr-2" />
Ver detalhes Ver detalhes
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link
href={`/secretary/pacientes/${patient.id}/editar`} href={`/secretary/pacientes/${patient.id}/editar`}
@ -379,90 +388,6 @@ export default function PacientesPage() {
</div> </div>
</div> </div>
{/* --- SEÇÃO DE CARDS (VISÍVEL APENAS EM TELAS MENORES QUE MD) --- */}
{/* Garantir que os cards apareçam em telas menores e se escondam em MD+ */}
<div className="bg-white rounded-lg border border-gray-200 shadow-md p-4 block md:hidden">
{error ? (
<div className="p-6 text-red-600">{`Erro ao carregar pacientes: ${error}`}</div>
) : loading ? (
<div className="p-6 text-center text-gray-500 flex items-center justify-center">
<Loader2 className="w-6 h-6 mr-2 animate-spin text-green-600" />{" "}
Carregando pacientes...
</div>
) : filteredPatients.length === 0 ? (
<div className="p-8 text-center text-gray-500">
{allPatients.length === 0
? "Nenhum paciente cadastrado"
: "Nenhum paciente encontrado com os filtros aplicados"}
</div>
) : (
<div className="space-y-4">
{currentPatients.map((patient) => (
<div
key={patient.id}
className="bg-gray-50 rounded-lg p-4 flex flex-col sm:flex-row justify-between items-start sm:items-center border border-gray-200"
>
<div className="flex-grow mb-2 sm:mb-0">
<div className="font-semibold text-lg text-gray-900 flex items-center">
{patient.nome}
{patient.vip && (
<span className="ml-2 px-2 py-0.5 text-xs font-semibold text-purple-600 bg-purple-100 rounded-full">
VIP
</span>
)}
</div>
<div className="text-sm text-gray-600">
Telefone: {patient.telefone}
</div>
<div className="text-sm text-gray-600">
Convênio: {patient.convenio}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="w-full">
<Button variant="outline" className="w-full">
Ações
</Button>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => openDetailsDialog(String(patient.id))}
>
<Eye className="w-4 h-4 mr-2" />
Ver detalhes
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/secretary/pacientes/${patient.id}/editar`}
className="flex items-center w-full"
>
<Edit className="w-4 h-4 mr-2" />
Editar
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Calendar className="w-4 h-4 mr-2" />
Marcar consulta
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => openDeleteDialog(String(patient.id))}
>
<Trash2 className="w-4 h-4 mr-2" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
</div>
{/* Paginação */} {/* Paginação */}
{totalPages > 1 && !loading && ( {totalPages > 1 && !loading && (
<div className="flex flex-col sm:flex-row items-center justify-center p-4 border-t border-gray-200"> <div className="flex flex-col sm:flex-row items-center justify-center p-4 border-t border-gray-200">

View File

@ -3,23 +3,10 @@
import React, { useEffect, useState, useCallback } from "react"; 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 { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select, import { Input } from "@/components/ui/input"; // <--- 1. Importação Adicionada
SelectContent, import { Plus, Eye, Filter, Loader2, Search } from "lucide-react"; // <--- 1. Ícone Search Adicionado
SelectItem, import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, Eye, Filter, Loader2 } from "lucide-react";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { 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";
@ -46,6 +33,9 @@ export default function UsersPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [detailsDialogOpen, setDetailsDialogOpen] = useState(false); const [detailsDialogOpen, setDetailsDialogOpen] = useState(false);
const [userDetails, setUserDetails] = useState<UserInfoResponse | null>(null); const [userDetails, setUserDetails] = useState<UserInfoResponse | 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 ---
@ -130,10 +120,21 @@ export default function UsersPage() {
} }
}; };
const filteredUsers = // --- 3. Lógica de Filtragem Atualizada ---
selectedRole && selectedRole !== "all" const filteredUsers = users.filter((u) => {
? users.filter((u) => u.role === selectedRole) // Filtro por Papel (Role)
: users; const roleMatch = selectedRole === "all" || u.role === selectedRole;
// Filtro da Barra de Pesquisa (Nome, Email ou Telefone)
const searchLower = searchTerm.toLowerCase();
const nameMatch = u.full_name?.toLowerCase().includes(searchLower);
const emailMatch = u.email?.toLowerCase().includes(searchLower);
const phoneMatch = u.phone?.includes(searchLower);
const searchMatch = !searchTerm || nameMatch || emailMatch || phoneMatch;
return roleMatch && searchMatch;
});
const indexOfLastItem = currentPage * itemsPerPage; const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage; const indexOfFirstItem = indexOfLastItem - itemsPerPage;
@ -191,24 +192,35 @@ export default function UsersPage() {
</Link> </Link>
</div> </div>
{/* Filtro e Itens por Página */} {/* --- 4. Filtro (Barra de Pesquisa + Selects) --- */}
<div className="flex flex-wrap items-center gap-3 bg-white p-4 rounded-lg border border-gray-200"> <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">
{/* Select de Filtro por Papel - Ajustado para resetar a página */}
{/* 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
}}
className="pl-10 w-full bg-gray-50 border-gray-200 focus:bg-white transition-colors"
/>
</div>
<div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
{/* Select de Filtro por Papel */}
<div className="flex items-center gap-2 w-full md:w-auto"> <div className="flex items-center gap-2 w-full md:w-auto">
<span className="text-sm font-medium text-foreground whitespace-nowrap">
Filtrar por papel
</span>
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
setSelectedRole(value); setSelectedRole(value);
setCurrentPage(1); setCurrentPage(1);
}} }}
value={selectedRole} value={selectedRole}>
>
<SelectTrigger className="w-full sm:w-[180px]"> <SelectTrigger className="w-full sm:w-[150px]">
{" "} <SelectValue placeholder="Papel" />
{/* w-full para mobile, w-[180px] para sm+ */}
<SelectValue placeholder="Filtrar por papel" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Todos</SelectItem> <SelectItem value="all">Todos</SelectItem>
@ -223,31 +235,28 @@ export default function UsersPage() {
{/* Select de Itens por Página */} {/* Select de Itens por Página */}
<div className="flex items-center gap-2 w-full md:w-auto"> <div className="flex items-center gap-2 w-full md:w-auto">
<span className="text-sm font-medium text-foreground whitespace-nowrap">
Itens por página
</span>
<Select <Select
onValueChange={handleItemsPerPageChange} onValueChange={handleItemsPerPageChange}
defaultValue={String(itemsPerPage)} defaultValue={String(itemsPerPage)}
> >
<SelectTrigger className="w-full sm:w-[140px]"> <SelectTrigger className="w-full sm:w-[80px]">
{" "} <SelectValue placeholder="10" />
{/* w-full para mobile, w-[140px] para sm+ */}
<SelectValue placeholder="Itens por pág." />
</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">
<Button variant="outline" className="ml-auto w-full md:w-auto hidden lg:flex">
<Filter className="w-4 h-4 mr-2" /> <Filter className="w-4 h-4 mr-2" />
Filtro avançado Filtros
</Button> </Button>
</div> </div>
{/* Fim do Filtro e Itens por Página */} </div>
{/* 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">
@ -318,15 +327,15 @@ export default function UsersPage() {
{/* 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">
{currentItems.map((u) => ( {currentItems.map((u) => (
<div <div key={u.id} className="flex items-center justify-between p-4 hover:bg-gray-50">
key={u.id}
className="flex items-center justify-between p-4 hover:bg-gray-50"
>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<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>

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

@ -284,22 +284,13 @@ export default function SecretaryAppointments() {
</div> </div>
<div className="flex gap-2 mt-4 pt-4 border-t"> <div className="flex gap-2 mt-4 pt-4 border-t">
<Button <Button variant="outline" size="sm" onClick={() => handleEdit(appointment)}>
variant="outline"
size="sm"
onClick={() => handleEdit(appointment)}
>
<Pencil className="mr-2 h-4 w-4" /> <Pencil className="mr-2 h-4 w-4" />
Editar Editar
</Button> </Button>
<Button <Button variant="outline" size="sm" className="text-red-600 hover:text-red-700 hover:bg-red-50 bg-transparent" onClick={() => handleDelete(appointment)}>
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" /> <Trash2 className="mr-2 h-4 w-4" />
Deletar Cancelar
</Button> </Button>
</div> </div>
</CardContent> </CardContent>

View File

@ -4,38 +4,11 @@
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 { import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
DropdownMenu, import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
DropdownMenuContent, import { Plus, Edit, Trash2, Eye, Calendar, Filter, Loader2, MoreVertical } from "lucide-react";
DropdownMenuItem, import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
DropdownMenuTrigger, import SecretaryLayout from "@/components/secretary-layout";
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Plus,
Edit,
Trash2,
Eye,
Calendar,
Filter,
Loader2,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { patientsService } from "@/services/patientsApi.mjs"; import { patientsService } from "@/services/patientsApi.mjs";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
@ -75,7 +48,8 @@ export default function PacientesPage() {
// --- FUNÇÕES DE LÓGICA --- // --- FUNÇÕES DE LÓGICA ---
// 1. Função para carregar TODOS os pacientes da API // 1. Função para carregar TODOS os pacientes da API
const fetchAllPacientes = useCallback(async () => { const fetchAllPacientes = useCallback(
async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
@ -89,8 +63,8 @@ export default function PacientesPage() {
cidade: p.city ?? "—", cidade: p.city ?? "—",
estado: p.state ?? "—", estado: p.state ?? "—",
// Formate as datas se necessário, aqui usamos como string // Formate as datas se necessário, aqui usamos como string
ultimoAtendimento: p.last_visit_at?.split("T")[0] ?? "—", ultimoAtendimento: p.last_visit_at?.split('T')[0] ?? "—",
proximoAtendimento: p.next_appointment_at?.split("T")[0] ?? "—", proximoAtendimento: p.next_appointment_at?.split('T')[0] ?? "—",
vip: Boolean(p.vip ?? false), vip: Boolean(p.vip ?? false),
convenio: p.convenio ?? "Particular", // Define um valor padrão convenio: p.convenio ?? "Particular", // Define um valor padrão
status: p.status ?? undefined, status: p.status ?? undefined,
@ -115,7 +89,8 @@ export default function PacientesPage() {
// Filtro por Convênio // Filtro por Convênio
const matchesConvenio = const matchesConvenio =
convenioFilter === "all" || patient.convenio === convenioFilter; convenioFilter === "all" ||
patient.convenio === convenioFilter;
// Filtro por VIP // Filtro por VIP
const matchesVip = const matchesVip =
@ -202,8 +177,7 @@ export default function PacientesPage() {
placeholder="Buscar por nome ou telefone..." placeholder="Buscar por nome ou telefone..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
// w-full no mobile, depois flex-grow para ocupar o espaço disponível className="w-full sm:flex-grow sm:min-w-[150px] 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 */}
@ -229,13 +203,9 @@ export default function PacientesPage() {
{/* 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]">
<span className="text-sm font-medium text-foreground whitespace-nowrap hidden md:block"> <span className="text-sm font-medium text-foreground whitespace-nowrap hidden md:block">VIP</span>
VIP
</span>
<Select value={vipFilter} onValueChange={setVipFilter}> <Select value={vipFilter} onValueChange={setVipFilter}>
<SelectTrigger className="w-full sm:w-32"> <SelectTrigger className="w-full sm:w-32"> {/* w-full para mobile, w-32 para sm+ */}
{" "}
{/* w-full para mobile, w-32 para sm+ */}
<SelectValue placeholder="VIP" /> <SelectValue placeholder="VIP" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -246,11 +216,8 @@ export default function PacientesPage() {
</Select> </Select>
</div> </div>
{/* Aniversariantes - Ocupa 100% no mobile, e se alinha à direita no md+ */}
<Button variant="outline" className="w-full md:w-auto md:ml-auto">
<Calendar className="w-4 h-4 mr-2" />
Aniversariantes
</Button>
</div> </div>
{/* --- SEÇÃO DE TABELA (VISÍVEL EM TELAS MAIORES OU IGUAIS A MD) --- */} {/* --- SEÇÃO DE TABELA (VISÍVEL EM TELAS MAIORES OU IGUAIS A MD) --- */}

View File

@ -1,22 +1,43 @@
"use client" "use client";
import { useState, useEffect, useCallback, useRef } from "react"
import type React from "react"
import { usersService } from "@/services/usersApi.mjs" import { useState, useEffect, useCallback, useRef } from "react";
import { patientsService } from "@/services/patientsApi.mjs" import { usersService } from "@/services/usersApi.mjs";
import { doctorsService } from "@/services/doctorsApi.mjs" import { patientsService } from "@/services/patientsApi.mjs";
import { appointmentsService } from "@/services/appointmentsApi.mjs" import { doctorsService } from "@/services/doctorsApi.mjs";
import { AvailabilityService } from "@/services/availabilityApi.mjs" import { appointmentsService } from "@/services/appointmentsApi.mjs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { AvailabilityService } from "@/services/availabilityApi.mjs";
import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label" import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea" import {
import { Calendar as CalendarShadcn } from "@/components/ui/calendar" Select,
import { format, addDays } from "date-fns" SelectContent,
import { User, StickyNote } from "lucide-react" SelectItem,
import { smsService } from "@/services/Sms.mjs" SelectTrigger,
import { toast } from "@/hooks/use-toast" SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Calendar as CalendarShadcn } from "@/components/ui/calendar";
import { format, addDays } from "date-fns";
import { User, StickyNote, Check, ChevronsUpDown } from "lucide-react";
import { smsService } from "@/services/Sms.mjs";
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
@ -24,16 +45,20 @@ export default function ScheduleForm() {
const [userId, setUserId] = useState<string | null>(null) const [userId, setUserId] = useState<string | null>(null)
// 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 [doctors, setDoctors] = useState<any[]>([]) const [openPatientCombobox, setOpenPatientCombobox] = useState(false);
const [selectedDoctor, setSelectedDoctor] = useState("")
const [selectedDate, setSelectedDate] = useState("") const [doctors, setDoctors] = useState<any[]>([]);
const [selectedTime, setSelectedTime] = useState("") const [selectedDoctor, setSelectedDoctor] = useState("");
const [notes, setNotes] = useState("") const [openDoctorCombobox, setOpenDoctorCombobox] = useState(false); // Novo estado para médico
const [availableTimes, setAvailableTimes] = useState<string[]>([])
const [loadingDoctors, setLoadingDoctors] = useState(true) const [selectedDate, setSelectedDate] = useState("");
const [loadingSlots, setLoadingSlots] = useState(false) const [selectedTime, setSelectedTime] = useState("");
const [notes, setNotes] = useState("");
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
const [loadingDoctors, setLoadingDoctors] = useState(true);
const [loadingSlots, setLoadingSlots] = useState(false);
// Outras configs // Outras configs
const [tipoConsulta] = useState("presencial") const [tipoConsulta] = useState("presencial")
@ -100,7 +125,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")
@ -225,18 +253,18 @@ export default function ScheduleForm() {
appointment_type: tipoConsulta, appointment_type: tipoConsulta,
} }
await appointmentsService.create(body) await appointmentsService.create(body);
const dateFormatted = selectedDate.split("-").reverse().join("/") const dateFormatted = selectedDate.split("-").reverse().join("/");
toast({ toast({
title: "Consulta agendada!", title: "Consulta agendada!",
description: `Consulta marcada para ${dateFormatted} às ${selectedTime} com o(a) médico(a) ${ description: `Consulta marcada para ${dateFormatted} às ${selectedTime} com o(a) médico(a) ${
doctors.find((d) => d.id === selectedDoctor)?.full_name || "" doctors.find((d) => d.id === selectedDoctor)?.full_name || ""
}.`, }.`,
}) });
let phoneNumber = "+5511999999999" let phoneNumber = "+5511999999999";
try { try {
if (isSecretaryLike) { if (isSecretaryLike) {
@ -274,24 +302,25 @@ export default function ScheduleForm() {
}) })
if (smsRes?.success) { if (smsRes?.success) {
console.log("✅ SMS enviado com sucesso:", smsRes.message_sid) console.log("✅ SMS enviado com sucesso:", smsRes.message_sid);
} else { } else {
console.warn("⚠️ Falha no envio do SMS:", smsRes) console.warn("⚠️ Falha no envio do SMS:", smsRes);
} }
} catch (smsErr) { } catch (smsErr) {
console.error("❌ Erro ao enviar SMS:", smsErr) console.error("❌ Erro ao enviar SMS:", smsErr);
} }
setSelectedDoctor("") // 🧹 limpa os campos
setSelectedDate("") setSelectedDoctor("");
setSelectedTime("") setSelectedDate("");
setNotes("") setSelectedTime("");
setSelectedPatient("") setNotes("");
setSelectedPatient("");
} catch (err) { } catch (err) {
console.error("❌ Erro ao agendar consulta:", err) console.error("❌ Erro ao agendar consulta:", err);
toast({ title: "Erro", description: "Falha ao agendar consulta." }) toast({ title: "Erro", description: "Falha ao agendar consulta." });
}
} }
};
// 🔹 Tooltip no calendário // 🔹 Tooltip no calendário
useEffect(() => { useEffect(() => {
@ -392,7 +421,11 @@ export default function ScheduleForm() {
<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")

View File

@ -41,8 +41,10 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button' const Comp = asChild ? Slot : 'button'
return ( return (
<Comp <Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
{...props} {...props}

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>
{/* CORREÇÃO AQUI: Adicionado 'break-all' para quebrar o email */}
<div>
<p className="font-semibold text-gray-900">Email</p>
<p className="text-gray-700 break-all">{patient.email || "N/A"}</p>
</div>
<div>
<p className="font-semibold text-gray-900">Telefone</p>
<p className="text-gray-700">{patient.telefone}</p>
</div>
<div>
<p className="font-semibold text-gray-900">Data de Nascimento</p>
<p className="text-gray-700">{patient.birth_date || "N/A"}</p>
</div>
<div>
<p className="font-semibold text-gray-900">CPF</p>
<p className="text-gray-700">{patient.cpf || "N/A"}</p>
</div>
<div>
<p className="font-semibold text-gray-900">Tipo Sanguíneo</p>
<p className="text-gray-700">{patient.blood_type || "N/A"}</p>
</div>
<div>
<p className="font-semibold text-gray-900">Peso (kg)</p>
<p className="text-gray-700">{patient.weight_kg || "0"}</p>
</div>
<div>
<p className="font-semibold text-gray-900">Altura (m)</p>
<p className="text-gray-700">{patient.height_m || "0"}</p>
</div>
</div>
<hr className="border-gray-200" />
{/* 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>
<p className="font-semibold text-gray-900">Rua</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">Email</p> <p className="font-semibold text-gray-900">Complemento</p>
<p>{patient.email}</p> <p className="text-gray-700">{patient.complement || "N/A"}</p>
</div> </div>
<div> <div>
<p className="font-semibold">Telefone</p> <p className="font-semibold text-gray-900">Bairro</p>
<p>{patient.telefone}</p> <p className="text-gray-700">{patient.neighborhood || "N/A"}</p>
</div> </div>
<div> <div>
<p className="font-semibold">Data de Nascimento</p> <p className="font-semibold text-gray-900">Cidade</p>
<p>{patient.birth_date}</p> <p className="text-gray-700">{patient.cidade || "N/A"}</p>
</div> </div>
<div> <div>
<p className="font-semibold">CPF</p> <p className="font-semibold text-gray-900">Estado</p>
<p>{patient.cpf}</p> <p className="text-gray-700">{patient.estado || "N/A"}</p>
</div> </div>
<div> <div>
<p className="font-semibold">Tipo Sanguíneo</p> <p className="font-semibold text-gray-900">CEP</p>
<p>{patient.blood_type}</p> <p className="text-gray-700">{patient.cep || "N/A"}</p>
</div>
<div>
<p className="font-semibold">Peso (kg)</p>
<p>{patient.weight_kg}</p>
</div>
<div>
<p className="font-semibold">Altura (m)</p>
<p>{patient.height_m}</p>
</div>
</div>
<div className="border-t pt-4 mt-4">
<h3 className="font-semibold mb-2">Endereço</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="font-semibold">Rua</p>
<p>{`${patient.street}, ${patient.number}`}</p>
</div>
<div>
<p className="font-semibold">Complemento</p>
<p>{patient.complement}</p>
</div>
<div>
<p className="font-semibold">Bairro</p>
<p>{patient.neighborhood}</p>
</div>
<div>
<p className="font-semibold">Cidade</p>
<p>{patient.cidade}</p>
</div>
<div>
<p className="font-semibold">Estado</p>
<p>{patient.estado}</p>
</div>
<div>
<p className="font-semibold">CEP</p>
<p>{patient.cep}</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<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>

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