Seção de Relatórios
Acesse a seção de relatórios médicos para gerenciar, visualizar e exportar documentos.
diff --git a/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx b/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx
index 126fc8e..c70081a 100644
--- a/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx
+++ b/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx
@@ -1,16 +1,29 @@
"use client";
+import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { FileDown, BarChart2, Users, DollarSign, TrendingUp, UserCheck, CalendarCheck, ThumbsUp, User, Briefcase } from "lucide-react";
import jsPDF from "jspdf";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
+import {
+ countTotalPatients,
+ countTotalDoctors,
+ countAppointmentsToday,
+ getAppointmentsByDateRange,
+ listarAgendamentos,
+ getUpcomingAppointments,
+ getNewUsersLastDays,
+ getPendingReports,
+ buscarMedicosPorIds,
+ buscarPacientesPorIds,
+} from "@/lib/api";
// Dados fictícios para demonstração
const metricas = [
{ label: "Atendimentos", value: 1240, icon: },
{ label: "Absenteísmo", value: "7,2%", icon: },
- { label: "Satisfação", value: "92%", icon: },
+ { label: "Satisfação", value: "Dados não foram disponibilizados", icon: },
{ label: "Faturamento (Mês)", value: "R$ 45.000", icon: },
{ label: "No-show", value: "5,1%", icon: },
];
@@ -42,13 +55,7 @@ const taxaNoShow = [
{ mes: "Jun", noShow: 4.7 },
];
-const pacientesMaisAtendidos = [
- { nome: "Ana Souza", consultas: 18 },
- { nome: "Bruno Lima", consultas: 15 },
- { nome: "Carla Menezes", consultas: 13 },
- { nome: "Diego Alves", consultas: 12 },
- { nome: "Fernanda Dias", consultas: 11 },
-];
+// pacientesMaisAtendidos static list removed — data will be fetched from the API
const medicosMaisProdutivos = [
{ nome: "Dr. Carlos Andrade", consultas: 62 },
@@ -81,19 +88,260 @@ function exportPDF(title: string, content: string) {
}
export default function RelatoriosPage() {
+ // Local state that will be replaced by API data when available
+ // Start with empty data to avoid showing fictitious frontend data while loading
+ const [metricsState, setMetricsState] = useState>([]);
+ const [consultasData, setConsultasData] = useState>([]);
+ const [faturamentoData, setFaturamentoData] = useState>([]);
+ const [taxaNoShowState, setTaxaNoShowState] = useState>([]);
+ const [pacientesTop, setPacientesTop] = useState>([]);
+ const [medicosTop, setMedicosTop] = useState(medicosMaisProdutivos);
+ const [medicosPerformance, setMedicosPerformance] = useState>([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [conveniosData, setConveniosData] = useState>(convenios);
+
+ useEffect(() => {
+ let mounted = true;
+ async function load() {
+ setLoading(true);
+ try {
+ // Fetch counts in parallel, then try to fetch a larger appointments list via listarAgendamentos.
+ // If listarAgendamentos fails (for example: unauthenticated), fall back to getAppointmentsByDateRange(30).
+ const [patientsCount, doctorsCount, appointmentsToday] = await Promise.all([
+ countTotalPatients().catch(() => 0),
+ countTotalDoctors().catch(() => 0),
+ countAppointmentsToday().catch(() => 0),
+ ]);
+
+ let appointments: any[] = [];
+ try {
+ // Try to get a larger set of appointments (up to 1000) to compute top patients
+ // select=patient_id,doctor_id,scheduled_at,status to reduce payload
+ // include insurance_provider so we can aggregate convênios client-side
+ appointments = await listarAgendamentos('select=patient_id,doctor_id,scheduled_at,status,insurance_provider&order=scheduled_at.desc&limit=1000');
+ } catch (e) {
+ // Fallback to the smaller helper if listarAgendamentos cannot be used (e.g., no auth token)
+ console.warn('[relatorios] listarAgendamentos falhou, usando getAppointmentsByDateRange fallback', e);
+ appointments = await getAppointmentsByDateRange(30).catch(() => []);
+ }
+
+ if (!mounted) return;
+
+ // Update top metrics card
+ setMetricsState([
+ { label: "Atendimentos", value: appointmentsToday ?? 0, icon: },
+ { label: "Absenteísmo", value: "—", icon: },
+ { label: "Satisfação", value: "Dados não foram disponibilizados", icon: },
+ { label: "Faturamento (Mês)", value: "—", icon: },
+ { label: "No-show", value: "—", icon: },
+ ]);
+
+ // Build last 30 days series for consultas
+ const daysCount = 30;
+ const now = new Date();
+ const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const startTs = start.getTime() - (daysCount - 1) * 86400000; // include today
+ const dayBuckets: Record = {};
+ for (let i = 0; i < daysCount; i++) {
+ const d = new Date(startTs + i * 86400000);
+ const iso = d.toISOString().split("T")[0];
+ const periodo = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`;
+ dayBuckets[iso] = { periodo, consultas: 0 };
+ }
+
+ // Count appointments per day
+ const appts = Array.isArray(appointments) ? appointments : [];
+ for (const a of appts) {
+ try {
+ const iso = (a.scheduled_at || '').toString().split('T')[0];
+ if (iso && dayBuckets[iso]) dayBuckets[iso].consultas += 1;
+ } catch (e) {
+ // ignore malformed
+ }
+ }
+ const consultasArr = Object.values(dayBuckets);
+ setConsultasData(consultasArr);
+
+ // Estimate monthly faturamento for last 6 months using doctor.valor_consulta when available
+ const monthsBack = 6;
+ const monthMap: Record = {};
+ const nowMonth = new Date(now.getFullYear(), now.getMonth(), 1);
+ const monthKeys: string[] = [];
+ for (let i = monthsBack - 1; i >= 0; i--) {
+ const m = new Date(nowMonth.getFullYear(), nowMonth.getMonth() - i, 1);
+ const key = `${m.getFullYear()}-${String(m.getMonth() + 1).padStart(2, '0')}`;
+ monthKeys.push(key);
+ monthMap[key] = { mes: m.toLocaleString('pt-BR', { month: 'short' }), valor: 0, totalAppointments: 0, noShowCount: 0 };
+ }
+
+ // Filter appointments within monthsBack and group
+ const apptsForMonths = appts.filter((a) => {
+ try {
+ const d = new Date(a.scheduled_at);
+ const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
+ return key in monthMap;
+ } catch (e) {
+ return false;
+ }
+ });
+
+ // Collect unique doctor ids to fetch valor_consulta in bulk
+ const doctorIds = Array.from(new Set(apptsForMonths.map((a: any) => String(a.doctor_id).trim()).filter(Boolean)));
+ const doctors = doctorIds.length ? await buscarMedicosPorIds(doctorIds) : [];
+ const doctorMap = new Map();
+ for (const d of doctors) doctorMap.set(String(d.id), d);
+
+ for (const a of apptsForMonths) {
+ try {
+ const d = new Date(a.scheduled_at);
+ const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
+ const doc = doctorMap.get(String(a.doctor_id));
+ const price = doc && doc.valor_consulta ? Number(doc.valor_consulta) : 0;
+ monthMap[key].valor += price;
+ monthMap[key].totalAppointments += 1;
+ if (String(a.status || '').toLowerCase() === 'no_show' || String(a.status || '').toLowerCase() === 'no-show') {
+ monthMap[key].noShowCount += 1;
+ }
+ } catch (e) {}
+ }
+
+ const faturamentoArr = monthKeys.map((k) => ({ mes: monthMap[k].mes, valor: Math.round(monthMap[k].valor) }));
+ setFaturamentoData(faturamentoArr);
+
+ // Taxa no-show per month
+ const taxaArr = monthKeys.map((k) => {
+ const total = monthMap[k].totalAppointments || 0;
+ const noShow = monthMap[k].noShowCount || 0;
+ const pct = total ? Number(((noShow / total) * 100).toFixed(1)) : 0;
+ return { mes: monthMap[k].mes, noShow: pct };
+ });
+ setTaxaNoShowState(taxaArr);
+
+ // Top patients and doctors (by number of appointments in the period)
+ const patientCounts: Record = {};
+ const doctorCounts: Record = {};
+ const doctorNoShowCounts: Record = {};
+ for (const a of apptsForMonths) {
+ if (a.patient_id) patientCounts[String(a.patient_id)] = (patientCounts[String(a.patient_id)] || 0) + 1;
+ if (a.doctor_id) {
+ const did = String(a.doctor_id);
+ doctorCounts[did] = (doctorCounts[did] || 0) + 1;
+ const status = String(a.status || '').toLowerCase();
+ if (status === 'no_show' || status === 'no-show') doctorNoShowCounts[did] = (doctorNoShowCounts[did] || 0) + 1;
+ }
+ }
+
+ const topPatientIds = Object.entries(patientCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map((x) => x[0]);
+ const topDoctorIds = Object.entries(doctorCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map((x) => x[0]);
+
+ const [patientsFetched, doctorsFetched] = await Promise.all([
+ topPatientIds.length ? buscarPacientesPorIds(topPatientIds) : Promise.resolve([]),
+ topDoctorIds.length ? buscarMedicosPorIds(topDoctorIds) : Promise.resolve([]),
+ ]);
+
+ const pacientesList = topPatientIds.map((id) => {
+ const p = (patientsFetched || []).find((x: any) => String(x.id) === String(id));
+ return { nome: p ? p.full_name : id, consultas: patientCounts[id] || 0 };
+ });
+
+ const medicosList = topDoctorIds.map((id) => {
+ const m = (doctorsFetched || []).find((x: any) => String(x.id) === String(id));
+ return { nome: m ? m.full_name : id, consultas: doctorCounts[id] || 0 };
+ });
+
+ // Build performance list (consultas + absenteísmo)
+ const perfIds = Object.keys(doctorCounts).sort((a, b) => (doctorCounts[b] || 0) - (doctorCounts[a] || 0)).slice(0, 5);
+ const perfDoctors = (doctorsFetched && doctorsFetched.length) ? doctorsFetched : doctors;
+ const perfList = perfIds.map((id) => {
+ const d = (perfDoctors || []).find((x: any) => String(x.id) === String(id));
+ const consultas = doctorCounts[id] || 0;
+ const noShow = doctorNoShowCounts[id] || 0;
+ const absenteismo = consultas ? Number(((noShow / consultas) * 100).toFixed(1)) : 0;
+ return { nome: d ? d.full_name : id, consultas, absenteismo };
+ });
+
+ // Use fetched list (may be empty) — do not fall back to static data for patients, but keep fallback for medicosTop
+ setPacientesTop(pacientesList);
+ setMedicosTop(medicosList.length ? medicosList : medicosMaisProdutivos);
+ setMedicosPerformance(perfList.length ? perfList.slice(0,5) : performancePorMedico.map((p) => ({ nome: p.nome, consultas: p.consultas, absenteismo: p.absenteismo })).slice(0,5));
+
+ // Aggregate convênios (insurance providers) from appointments in the period
+ try {
+ const providerCounts: Record = {};
+ for (const a of apptsForMonths) {
+ let prov: any = a?.insurance_provider ?? a?.insuranceProvider ?? a?.insurance ?? '';
+ // If provider is an object, try to extract a human-friendly name
+ if (prov && typeof prov === 'object') prov = prov.name || prov.full_name || prov.title || '';
+ prov = String(prov || '').trim();
+ const key = prov || 'Não disponibilizado';
+ providerCounts[key] = (providerCounts[key] || 0) + 1;
+ }
+
+ let conveniosArr = Object.entries(providerCounts).map(([nome, valor]) => ({ nome, valor }));
+ if (!conveniosArr.length) {
+ // No provider info at all — present a single bucket showing the total count as 'Não disponibilizado'
+ conveniosArr = [{ nome: 'Não disponibilizado', valor: apptsForMonths.length }];
+ } else {
+ // Sort and keep top 5, group the rest into 'Outros'
+ conveniosArr.sort((a, b) => b.valor - a.valor);
+ if (conveniosArr.length > 5) {
+ const top = conveniosArr.slice(0, 5);
+ const others = conveniosArr.slice(5).reduce((s, c) => s + c.valor, 0);
+ top.push({ nome: 'Outros', valor: others });
+ conveniosArr = top;
+ }
+ }
+ setConveniosData(conveniosArr);
+ } catch (e) {
+ // keep existing static conveniosData if something goes wrong
+ console.warn('[relatorios] erro ao agregar convênios', e);
+ }
+
+ // Update metrics cards with numbers we fetched
+ setMetricsState([
+ { label: "Atendimentos", value: appointmentsToday ?? 0, icon: },
+ { label: "Absenteísmo", value: '—', icon: },
+ { label: "Satisfação", value: 'Dados não foram disponibilizados', icon: },
+ { label: "Faturamento (Mês)", value: `R$ ${faturamentoArr.at(-1)?.valor ?? 0}`, icon: },
+ { label: "No-show", value: `${taxaArr.at(-1)?.noShow ?? 0}%`, icon: },
+ ] as any);
+
+ } catch (err: any) {
+ console.error('[relatorios] erro ao carregar dados', err);
+ if (mounted) setError(err?.message ?? String(err));
+ } finally {
+ if (mounted) setLoading(false);
+ }
+ }
+ load();
+ return () => { mounted = false; };
+ }, []);
+
return (
Dashboard Executivo de Relatórios
{/* Métricas principais */}
- {metricas.map((m) => (
-
- {m.icon}
- {m.value}
- {m.label}
-
- ))}
+ {loading ? (
+ // simple skeletons while loading to avoid showing fake data
+ Array.from({ length: 5 }).map((_, i) => (
+
+ ))
+ ) : (
+ metricsState.map((m) => (
+
+ {m.icon}
+ {m.value}
+ {m.label}
+
+ ))
+ )}
{/* Gráficos e Relatórios */}
@@ -102,34 +350,42 @@ export default function RelatoriosPage() {
Consultas por Período
- exportPDF("Consultas por Período", "Resumo das consultas realizadas por período.")}> Exportar PDF
+ exportPDF("Consultas por Período", "Resumo das consultas realizadas por período.")}> Exportar PDF
-
-
-
-
-
-
-
-
-
+ {loading ? (
+
Carregando dados...
+ ) : (
+
+
+
+
+
+
+
+
+
+ )}
{/* Faturamento mensal/anual */}
Faturamento Mensal
- exportPDF("Faturamento Mensal", "Resumo do faturamento mensal.")}> Exportar PDF
+ exportPDF("Faturamento Mensal", "Resumo do faturamento mensal.")}> Exportar PDF
-
-
-
-
-
-
-
-
-
+ {loading ? (
+
Carregando dados...
+ ) : (
+
+
+
+
+
+
+
+
+
+ )}
@@ -138,27 +394,31 @@ export default function RelatoriosPage() {
Taxa de No-show
- exportPDF("Taxa de No-show", "Resumo da taxa de no-show.")}> Exportar PDF
+ exportPDF("Taxa de No-show", "Resumo da taxa de no-show.")}> Exportar PDF
-
-
-
-
-
-
-
-
-
+ {loading ? (
+
Carregando dados...
+ ) : (
+
+
+
+
+
+
+
+
+
+ )}
{/* Indicadores de satisfação */}
Satisfação dos Pacientes
- exportPDF("Satisfação dos Pacientes", "Resumo dos indicadores de satisfação.")}> Exportar PDF
+ exportPDF("Satisfação dos Pacientes", "Resumo dos indicadores de satisfação.")}> Exportar PDF
- 92%
+ Dados não foram disponibilizados
Índice de satisfação geral
@@ -169,7 +429,7 @@ export default function RelatoriosPage() {
Pacientes Mais Atendidos
- exportPDF("Pacientes Mais Atendidos", "Lista dos pacientes mais atendidos.")}> Exportar PDF
+ exportPDF("Pacientes Mais Atendidos", "Lista dos pacientes mais atendidos.")}> Exportar PDF
@@ -179,12 +439,22 @@ export default function RelatoriosPage() {
- {pacientesMaisAtendidos.map((p) => (
-
- {p.nome}
- {p.consultas}
+ {loading ? (
+
+ Carregando pacientes...
- ))}
+ ) : pacientesTop && pacientesTop.length ? (
+ pacientesTop.map((p: { nome: string; consultas: number }) => (
+
+ {p.nome}
+ {p.consultas}
+
+ ))
+ ) : (
+
+ Nenhum paciente encontrado
+
+ )}
@@ -193,7 +463,7 @@ export default function RelatoriosPage() {
Médicos Mais Produtivos
- exportPDF("Médicos Mais Produtivos", "Lista dos médicos mais produtivos.")}> Exportar PDF
+ exportPDF("Médicos Mais Produtivos", "Lista dos médicos mais produtivos.")}> Exportar PDF
@@ -203,12 +473,22 @@ export default function RelatoriosPage() {
- {medicosMaisProdutivos.map((m) => (
-
- {m.nome}
- {m.consultas}
+ {loading ? (
+
+ Carregando médicos...
- ))}
+ ) : medicosTop && medicosTop.length ? (
+ medicosTop.map((m) => (
+
+ {m.nome}
+ {m.consultas}
+
+ ))
+ ) : (
+
+ Nenhum médico encontrado
+
+ )}
@@ -219,26 +499,30 @@ export default function RelatoriosPage() {
Análise de Convênios
- exportPDF("Análise de Convênios", "Resumo da análise de convênios.")}> Exportar PDF
+ exportPDF("Análise de Convênios", "Resumo da análise de convênios.")}> Exportar PDF
-
-
-
- {convenios.map((entry, index) => (
- |
- ))}
-
-
-
-
-
+ {loading ? (
+
Carregando dados...
+ ) : (
+
+
+
+ {conveniosData.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+
+ )}
{/* Performance por médico */}
Performance por Médico
- exportPDF("Performance por Médico", "Resumo da performance por médico.")}> Exportar PDF
+ exportPDF("Performance por Médico", "Resumo da performance por médico.")}> Exportar PDF
@@ -249,7 +533,7 @@ export default function RelatoriosPage() {
- {performancePorMedico.map((m) => (
+ {(loading ? performancePorMedico : medicosPerformance).map((m) => (
{m.nome}
{m.consultas}
diff --git a/susconecta/app/(main-routes)/doutores/page.tsx b/susconecta/app/(main-routes)/doutores/page.tsx
index 3e958f7..5761f68 100644
--- a/susconecta/app/(main-routes)/doutores/page.tsx
+++ b/susconecta/app/(main-routes)/doutores/page.tsx
@@ -9,9 +9,9 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
import { Label } from "@/components/ui/label";
import { MoreHorizontal, Plus, Search, Edit, Trash2, ArrowLeft, Eye, Users } from "lucide-react";
import { Badge } from "@/components/ui/badge";
-import { DoctorRegistrationForm } from "@/components/forms/doctor-registration-form";
-import AvailabilityForm from '@/components/forms/availability-form'
-import ExceptionForm from '@/components/forms/exception-form'
+import { DoctorRegistrationForm } from "@/components/features/forms/doctor-registration-form";
+import AvailabilityForm from '@/components/features/forms/availability-form'
+import ExceptionForm from '@/components/features/forms/exception-form'
import { listarDisponibilidades, DoctorAvailability, deletarDisponibilidade, listarExcecoes, DoctorException, deletarExcecao } from '@/lib/api'
@@ -20,7 +20,7 @@ import { listAssignmentsForUser } from '@/lib/assignment';
function normalizeMedico(m: any): Medico {
const normalizeSex = (v: any) => {
- if (v === null || typeof v === 'undefined') return null;
+ if (v === undefined) return null;
const s = String(v || '').trim().toLowerCase();
if (!s) return null;
const male = new Set(['m','masc','male','masculino','homem','h','1','mas']);
@@ -623,7 +623,7 @@ export default function DoutoresPage() {
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
- className="hover:!bg-primary hover:!text-white transition-colors"
+ className="hover:bg-primary! hover:text-white! transition-colors"
>
Primeira
@@ -632,7 +632,7 @@ export default function DoutoresPage() {
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
- className="hover:!bg-primary hover:!text-white transition-colors"
+ className="hover:bg-primary! hover:text-white! transition-colors"
>
Anterior
@@ -644,7 +644,7 @@ export default function DoutoresPage() {
size="sm"
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages || totalPages === 0}
- className="hover:!bg-primary hover:!text-white transition-colors"
+ className="hover:bg-primary! hover:text-white! transition-colors"
>
Próxima
@@ -653,7 +653,7 @@ export default function DoutoresPage() {
size="sm"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages || totalPages === 0}
- className="hover:!bg-primary hover:!text-white transition-colors"
+ className="hover:bg-primary! hover:text-white! transition-colors"
>
Última
diff --git a/susconecta/app/(main-routes)/financeiro/page.tsx b/susconecta/app/(main-routes)/financeiro/page.tsx
index acab08f..190305a 100644
--- a/susconecta/app/(main-routes)/financeiro/page.tsx
+++ b/susconecta/app/(main-routes)/financeiro/page.tsx
@@ -5,8 +5,8 @@ import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Calculator, DollarSign } from "lucide-react";
-import HeaderAgenda from "@/components/agenda/HeaderAgenda";
-import FooterAgenda from "@/components/agenda/FooterAgenda";
+import HeaderAgenda from "@/components/features/agenda/HeaderAgenda";
+import FooterAgenda from "@/components/features/agenda/FooterAgenda";
export default function FinanceiroPage() {
const router = useRouter();
diff --git a/susconecta/app/(main-routes)/layout.tsx b/susconecta/app/(main-routes)/layout.tsx
index 6269b21..a673113 100644
--- a/susconecta/app/(main-routes)/layout.tsx
+++ b/susconecta/app/(main-routes)/layout.tsx
@@ -1,8 +1,8 @@
import type React from "react";
-import ProtectedRoute from "@/components/ProtectedRoute";
-import { Sidebar } from "@/components/dashboard/sidebar";
+import ProtectedRoute from "@/components/shared/ProtectedRoute";
+import { Sidebar } from "@/components/layout/sidebar";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
-import { PagesHeader } from "@/components/dashboard/header";
+import { PagesHeader } from "@/components/features/dashboard/header";
export default function MainRoutesLayout({
children,
diff --git a/susconecta/app/(main-routes)/pacientes/page.tsx b/susconecta/app/(main-routes)/pacientes/page.tsx
index df77a10..3ebb358 100644
--- a/susconecta/app/(main-routes)/pacientes/page.tsx
+++ b/susconecta/app/(main-routes)/pacientes/page.tsx
@@ -11,8 +11,8 @@ import { Label } from "@/components/ui/label";
import { MoreHorizontal, Plus, Search, Eye, Edit, Trash2, ArrowLeft } from "lucide-react";
import { Paciente, Endereco, listarPacientes, buscarPacientes, buscarPacientePorId, excluirPaciente } from "@/lib/api";
-import { PatientRegistrationForm } from "@/components/forms/patient-registration-form";
-import AssignmentForm from "@/components/admin/AssignmentForm";
+import { PatientRegistrationForm } from "@/components/features/forms/patient-registration-form";
+import AssignmentForm from "@/components/features/admin/AssignmentForm";
function normalizePaciente(p: any): Paciente {
@@ -320,7 +320,7 @@ export default function PacientesPage() {
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
- className="hover:!bg-primary hover:!text-white transition-colors"
+ className="hover:bg-primary! hover:text-white! transition-colors"
>
Primeira
@@ -329,7 +329,7 @@ export default function PacientesPage() {
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
- className="hover:!bg-primary hover:!text-white transition-colors"
+ className="hover:bg-primary! hover:text-white! transition-colors"
>
Anterior
@@ -341,7 +341,7 @@ export default function PacientesPage() {
size="sm"
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages || totalPages === 0}
- className="hover:!bg-primary hover:!text-white transition-colors"
+ className="hover:bg-primary! hover:text-white! transition-colors"
>
Próxima
@@ -350,7 +350,7 @@ export default function PacientesPage() {
size="sm"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages || totalPages === 0}
- className="hover:!bg-primary hover:!text-white transition-colors"
+ className="hover:bg-primary! hover:text-white! transition-colors"
>
Última
diff --git a/susconecta/app/(main-routes)/procedimento/page.tsx b/susconecta/app/(main-routes)/procedimento/page.tsx
index bccaaba..7edd710 100644
--- a/susconecta/app/(main-routes)/procedimento/page.tsx
+++ b/susconecta/app/(main-routes)/procedimento/page.tsx
@@ -7,8 +7,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Search, ChevronDown } from "lucide-react";
import { Plus } from "lucide-react";
-import HeaderAgenda from "@/components/agenda/HeaderAgenda";
-import FooterAgenda from "@/components/agenda/FooterAgenda";
+import HeaderAgenda from "@/components/features/agenda/HeaderAgenda";
+import FooterAgenda from "@/components/features/agenda/FooterAgenda";
export default function ProcedimentoPage() {
const router = useRouter();
diff --git a/susconecta/app/layout.tsx b/susconecta/app/layout.tsx
index b7b71f1..4f6ef6b 100644
--- a/susconecta/app/layout.tsx
+++ b/susconecta/app/layout.tsx
@@ -1,7 +1,7 @@
import type React from "react"
import type { Metadata } from "next"
import { AuthProvider } from "@/hooks/useAuth"
-import { ThemeProvider } from "@/components/theme-provider"
+import { ThemeProvider } from "@/components/providers/theme-provider"
import "./globals.css"
export const metadata: Metadata = {
diff --git a/susconecta/app/paciente/page.tsx b/susconecta/app/paciente/page.tsx
index 5e427d1..697c02f 100644
--- a/susconecta/app/paciente/page.tsx
+++ b/susconecta/app/paciente/page.tsx
@@ -3,6 +3,7 @@
import type { ReactNode } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useRouter } from 'next/navigation'
+import Image from 'next/image'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -11,10 +12,10 @@ import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { User, LogOut, Calendar, FileText, MessageCircle, UserCog, Home, Clock, FolderOpen, ChevronLeft, ChevronRight, MapPin, Stethoscope } from 'lucide-react'
-import { SimpleThemeToggle } from '@/components/simple-theme-toggle'
+import { SimpleThemeToggle } from '@/components/ui/simple-theme-toggle'
import { UploadAvatar } from '@/components/ui/upload-avatar'
import Link from 'next/link'
-import ProtectedRoute from '@/components/ProtectedRoute'
+import ProtectedRoute from '@/components/shared/ProtectedRoute'
import { useAuth } from '@/hooks/useAuth'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { buscarPacientes, buscarPacientePorUserId, getUserInfo, listarAgendamentos, buscarMedicosPorIds, buscarMedicos, atualizarPaciente, buscarPacientePorId, getDoctorById } from '@/lib/api'
@@ -171,7 +172,6 @@ export default function PacientePage() {
loadProfile()
return () => { mounted = false }
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, user?.email])
// Load authoritative patient row for the logged-in user (prefer user_id lookup)
@@ -324,10 +324,84 @@ export default function PacientePage() {
setNextAppt(null)
}
- // Load reports/laudos count
+ // Load reports/laudos and compute count matching the Laudos session rules
const reports = await listarRelatoriosPorPaciente(String(patientId)).catch(() => [])
if (!mounted) return
- setExamsCount(Array.isArray(reports) ? reports.length : 0)
+ let count = 0
+ try {
+ if (!Array.isArray(reports) || reports.length === 0) {
+ count = 0
+ } else {
+ // Use the same robust doctor-resolution strategy as ExamesLaudos so
+ // the card matches the list: try buscarMedicosPorIds, then per-id
+ // getDoctorById and finally a REST fallback by user_id.
+ const ids = Array.from(new Set((reports as any[]).map((r:any) => r.doctor_id || r.created_by || r.doctor).filter(Boolean).map(String)))
+ if (ids.length === 0) {
+ // fallback: count reports that have any direct doctor reference
+ count = (reports as any[]).filter((r:any) => !!(r && (r.doctor_id || r.created_by || r.doctor || r.user_id))).length
+ } else {
+ const docs = await buscarMedicosPorIds(ids).catch(() => [])
+ const map: Record = {}
+ for (const d of docs || []) {
+ if (!d) continue
+ try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
+ try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = map[String(d.user_id)] || d } catch {}
+ }
+
+ // Try per-id fallback using getDoctorById for any unresolved ids
+ const unresolved = ids.filter(i => !map[i])
+ if (unresolved.length) {
+ for (const u of unresolved) {
+ try {
+ const d = await getDoctorById(String(u)).catch(() => null)
+ if (d) {
+ try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
+ try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
+ }
+ } catch (e) {
+ // ignore per-id failure
+ }
+ }
+ }
+
+ // REST fallback: try lookup by user_id for still unresolved ids
+ const stillUnresolved = ids.filter(i => !map[i])
+ if (stillUnresolved.length) {
+ for (const u of stillUnresolved) {
+ try {
+ const token = (typeof window !== 'undefined') ? (localStorage.getItem('auth_token') || localStorage.getItem('token') || sessionStorage.getItem('auth_token') || sessionStorage.getItem('token')) : null
+ const headers: Record = { apikey: ENV_CONFIG.SUPABASE_ANON_KEY, Accept: 'application/json' }
+ if (token) headers.Authorization = `Bearer ${token}`
+ const url = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/doctors?user_id=eq.${encodeURIComponent(String(u))}&limit=1`
+ const res = await fetch(url, { method: 'GET', headers })
+ if (!res || res.status >= 400) continue
+ const rows = await res.json().catch(() => [])
+ if (rows && Array.isArray(rows) && rows.length) {
+ const d = rows[0]
+ if (d) {
+ try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
+ try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
+ }
+ }
+ } catch (e) {
+ // ignore network errors
+ }
+ }
+ }
+
+ // Count only reports whose referenced doctor record has user_id
+ count = (reports as any[]).filter((r:any) => {
+ const maybeId = String(r.doctor_id || r.created_by || r.doctor || '')
+ const doc = map[maybeId]
+ return !!(doc && (doc.user_id || (doc as any).user_id))
+ }).length
+ }
+ }
+ } catch (e) {
+ count = Array.isArray(reports) ? reports.length : 0
+ }
+ if (!mounted) return
+ setExamsCount(count)
} catch (e) {
console.warn('[DashboardCards] erro ao carregar dados', e)
if (!mounted) return
@@ -339,7 +413,7 @@ export default function PacientePage() {
}
load()
return () => { mounted = false }
- }, [patientId])
+ }, [])
return (
@@ -353,7 +427,7 @@ export default function PacientePage() {
{strings.proximaConsulta}
- {loading ? '—' : (nextAppt ?? '-')}
+ {loading ? strings.carregando : (nextAppt ?? '-')}
@@ -367,7 +441,7 @@ export default function PacientePage() {
{strings.ultimosExames}
- {loading ? '—' : (examsCount !== null ? String(examsCount) : '-')}
+ {loading ? strings.carregando : (examsCount !== null ? String(examsCount) : '-')}
@@ -547,7 +621,7 @@ export default function PacientePage() {
loadAppointments()
return () => { mounted = false }
- }, [patientId])
+ }, [])
// Monta a URL de resultados com os filtros atuais
const buildResultadosHref = () => {
@@ -557,7 +631,7 @@ export default function PacientePage() {
if (localizacao) qs.set('local', localizacao)
// indicate navigation origin so destination can alter UX (e.g., show modal instead of redirect)
qs.set('origin', 'paciente')
- return `/resultados?${qs.toString()}`
+ return `/paciente/resultados?${qs.toString()}`
}
// derived lists for the page (computed after appointments state is declared)
@@ -567,16 +641,16 @@ export default function PacientePage() {
return (
{/* Hero Section */}
-
+
-
+
-
+
Pesquisar Médicos
@@ -595,7 +669,7 @@ export default function PacientePage() {
{/* Date Navigation */}
-
+
{ e.stopPropagation(); e.preventDefault(); navigateDate('prev') }}
aria-label="Dia anterior"
- className={`group shadow-sm hover:!bg-primary hover:!text-white hover:!border-primary transition-all ${hoverPrimaryIconClass}`}
+ className={`group shadow-sm hover:bg-primary! hover:text-white! hover:border-primary! transition-all ${hoverPrimaryIconClass}`}
>
@@ -614,7 +688,7 @@ export default function PacientePage() {
size="icon"
onClick={(e: any) => { e.stopPropagation(); e.preventDefault(); navigateDate('next') }}
aria-label="Próximo dia"
- className={`group shadow-sm hover:!bg-primary hover:!text-white hover:!border-primary transition-all ${hoverPrimaryIconClass}`}
+ className={`group shadow-sm hover:bg-primary! hover:text-white! hover:border-primary! transition-all ${hoverPrimaryIconClass}`}
>
@@ -665,16 +739,16 @@ export default function PacientePage() {
{/* Doctor Info */}
-
+
{consulta.medico}
-
+
{consulta.especialidade}
•
{consulta.local}
@@ -684,7 +758,7 @@ export default function PacientePage() {
{/* Time */}
-
+
{consulta.hora}
@@ -692,10 +766,10 @@ export default function PacientePage() {
{consulta.status}
@@ -706,7 +780,7 @@ export default function PacientePage() {
Detalhes
@@ -714,7 +788,7 @@ export default function PacientePage() {
Reagendar
@@ -723,7 +797,7 @@ export default function PacientePage() {
Cancelar
@@ -809,6 +883,7 @@ export default function PacientePage() {
return false
}
})
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [reports, searchTerm, doctorsMap, remoteMatch])
// When the search term looks like an id, attempt a direct fetch using the reports API
@@ -834,7 +909,7 @@ export default function PacientePage() {
setSearchingRemote(true)
setRemoteMatch(null)
- if (looksLikeId) {
+ if (looksLikeId && q) { // Adicionada verificação para q não ser vazio
const r = await buscarRelatorioPorId(q).catch(() => null)
if (!mounted) return
if (r) setRemoteMatch(r)
@@ -847,9 +922,24 @@ export default function PacientePage() {
if (q.length >= 2) {
const docs = await buscarMedicos(q).catch(() => [])
if (!mounted) return
- if (docs && Array.isArray(docs) && docs.length) {
- // fetch reports for matching doctors in parallel
- const promises = docs.map(d => listarRelatoriosPorMedico(String(d.id)).catch(() => []))
+ if (docs && Array.isArray(docs) && docs.length) {
+ // fetch reports for matching doctors in parallel. Some report rows
+ // reference the doctor's account `user_id` in `requested_by` while
+ // others reference the doctor's record `id`. Try both per doctor.
+ const promises = docs.map(async (d: any) => {
+ try {
+ const byId = await listarRelatoriosPorMedico(String(d.id)).catch(() => [])
+ if (Array.isArray(byId) && byId.length) return byId
+ // fallback: if the doctor record has a user_id, try that too
+ if (d && (d.user_id || d.userId)) {
+ const byUser = await listarRelatoriosPorMedico(String(d.user_id || d.userId)).catch(() => [])
+ if (Array.isArray(byUser) && byUser.length) return byUser
+ }
+ return []
+ } catch (e) {
+ return []
+ }
+ })
const arrays = await Promise.all(promises)
if (!mounted) return
const combined = ([] as any[]).concat(...arrays)
@@ -981,6 +1071,22 @@ export default function PacientePage() {
}
setDoctorsMap(map)
+ // After resolving doctor records, filter out reports whose doctor
+ // record doesn't have a user_id (doctor_userid). If a report's
+ // referenced doctor lacks user_id, we hide that laudo.
+ try {
+ const filtered = (reports || []).filter((r: any) => {
+ const maybeId = String(r?.doctor_id || r?.created_by || r?.doctor || '')
+ const doc = map[maybeId]
+ return !!(doc && (doc.user_id || (doc as any).user_id))
+ })
+ // Only update when different to avoid extra cycles
+ if (Array.isArray(filtered) && filtered.length !== (reports || []).length) {
+ setReports(filtered)
+ }
+ } catch (e) {
+ // ignore filtering errors
+ }
setResolvingDoctors(false)
} catch (e) {
// ignore resolution errors
@@ -995,20 +1101,104 @@ export default function PacientePage() {
if (!patientId) return
setLoadingReports(true)
setReportsError(null)
- listarRelatoriosPorPaciente(String(patientId))
- .then(res => {
+
+ ;(async () => {
+ try {
+ const res = await listarRelatoriosPorPaciente(String(patientId)).catch(() => [])
if (!mounted) return
- setReports(Array.isArray(res) ? res : [])
- })
- .catch(err => {
+
+ // If no reports, set empty and return
+ if (!Array.isArray(res) || res.length === 0) {
+ setReports([])
+ return
+ }
+
+ // Resolve referenced doctor ids and only keep reports whose
+ // referenced doctor record has a truthy user_id (i.e., created by a doctor)
+ try {
+ setResolvingDoctors(true)
+ const ids = Array.from(new Set((res as any[]).map((r:any) => r.doctor_id || r.created_by || r.doctor).filter(Boolean).map(String)))
+ const map: Record
= {}
+ if (ids.length) {
+ const docs = await buscarMedicosPorIds(ids).catch(() => [])
+ for (const d of docs || []) {
+ if (!d) continue
+ try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
+ try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = map[String(d.user_id)] || d } catch {}
+ }
+
+ // per-id fallback
+ const unresolved = ids.filter(i => !map[i])
+ if (unresolved.length) {
+ for (const u of unresolved) {
+ try {
+ const d = await getDoctorById(String(u)).catch(() => null)
+ if (d) {
+ try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
+ try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
+ }
+ } catch (e) {
+ // ignore
+ }
+ }
+ }
+
+ // REST fallback by user_id
+ const stillUnresolved = ids.filter(i => !map[i])
+ if (stillUnresolved.length) {
+ for (const u of stillUnresolved) {
+ try {
+ const token = (typeof window !== 'undefined') ? (localStorage.getItem('auth_token') || localStorage.getItem('token') || sessionStorage.getItem('auth_token') || sessionStorage.getItem('token')) : null
+ const headers: Record = { apikey: ENV_CONFIG.SUPABASE_ANON_KEY, Accept: 'application/json' }
+ if (token) headers.Authorization = `Bearer ${token}`
+ const url = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/doctors?user_id=eq.${encodeURIComponent(String(u))}&limit=1`
+ const r = await fetch(url, { method: 'GET', headers })
+ if (!r || r.status >= 400) continue
+ const rows = await r.json().catch(() => [])
+ if (rows && Array.isArray(rows) && rows.length) {
+ const d = rows[0]
+ if (d) {
+ try { if (d.id !== undefined && d.id !== null) map[String(d.id)] = d } catch {}
+ try { if (d.user_id !== undefined && d.user_id !== null) map[String(d.user_id)] = d } catch {}
+ }
+ }
+ } catch (e) {
+ // ignore
+ }
+ }
+ }
+ }
+
+ // Now filter reports to only those whose referenced doctor has user_id
+ const filtered = (res || []).filter((r: any) => {
+ const maybeId = String(r?.doctor_id || r?.created_by || r?.doctor || '')
+ const doc = map[maybeId]
+ return !!(doc && (doc.user_id || (doc as any).user_id))
+ })
+
+ // Update doctorsMap and reports
+ setDoctorsMap(map)
+ setReports(filtered)
+ setResolvingDoctors(false)
+ return
+ } catch (e) {
+ // If resolution fails, fall back to setting raw results
+ console.warn('[ExamesLaudos] falha ao resolver médicos para filtragem', e)
+ setReports(Array.isArray(res) ? res : [])
+ setResolvingDoctors(false)
+ return
+ }
+ } catch (err) {
console.warn('[ExamesLaudos] erro ao carregar laudos', err)
if (!mounted) return
setReportsError('Falha ao carregar laudos.')
- })
- .finally(() => { if (mounted) setLoadingReports(false) })
+ } finally {
+ if (mounted) setLoadingReports(false)
+ }
+ })()
return () => { mounted = false }
- }, [patientId])
+ }, [])
// When a report is selected, try to fetch doctor name if we have an id
useEffect(() => {
@@ -1065,7 +1255,7 @@ export default function PacientePage() {
}
})()
return () => { mounted = false }
- }, [selectedReport])
+ }, [])
// reset pagination when reports change
useEffect(() => {
@@ -1099,11 +1289,13 @@ export default function PacientePage() {
) : (
(() => {
const total = Array.isArray(filteredReports) ? filteredReports.length : 0
- const totalPages = Math.max(1, Math.ceil(total / reportsPerPage))
+ // enforce a maximum of 5 laudos per page
+ const perPage = Math.max(1, Math.min(reportsPerPage || 5, 5))
+ const totalPages = Math.max(1, Math.ceil(total / perPage))
// keep page inside bounds
const page = Math.min(Math.max(1, reportsPage), totalPages)
- const start = (page - 1) * reportsPerPage
- const end = start + reportsPerPage
+ const start = (page - 1) * perPage
+ const end = start + perPage
const pageItems = (filteredReports || []).slice(start, end)
return (
@@ -1121,8 +1313,8 @@ export default function PacientePage() {
Data: {new Date(r.report_date || r.created_at || Date.now()).toLocaleDateString('pt-BR')}
- { setSelectedReport(r); }}>{strings.visualizarLaudo}
- { try { await navigator.clipboard.writeText(JSON.stringify(r)); setToast({ type: 'success', msg: 'Laudo copiado.' }) } catch { setToast({ type: 'error', msg: 'Falha ao copiar.' }) } }}>{strings.compartilhar}
+ { setSelectedReport(r); }}>{strings.visualizarLaudo}
+ { try { await navigator.clipboard.writeText(JSON.stringify(r)); setToast({ type: 'success', msg: 'Laudo copiado.' }) } catch { setToast({ type: 'error', msg: 'Falha ao copiar.' }) } }}>{strings.compartilhar}
))}
@@ -1145,64 +1337,62 @@ export default function PacientePage() {
!open && setSelectedReport(null)}>
- Laudo Médico
-
+
+ {selectedReport && (
+ (() => {
+ const looksLikeIdStr = (s: any) => {
+ try {
+ const hexOnly = String(s || '').replace(/[^0-9a-fA-F]/g, '');
+ const len = (typeof hexOnly === 'string') ? hexOnly.length : (Number(hexOnly) || 0);
+ return len >= 8;
+ } catch { return false; }
+ };
+ const maybeId = selectedReport?.doctor_id || selectedReport?.created_by || selectedReport?.doctor || null;
+ const derived = reportDoctorName ? reportTitle(selectedReport, reportDoctorName) : reportTitle(selectedReport);
+
+ if (looksLikeIdStr(derived)) {
+ return {strings.carregando} ;
+ }
+ if (resolvingDoctors && maybeId && !doctorsMap[String(maybeId)]) {
+ return {strings.carregando} ;
+ }
+ return {derived} ;
+ })()
+ )}
+
+ Detalhes do laudo
+
{selectedReport && (
<>
-
- {
- // prefer the resolved doctor name; while resolving, show a loading indicator instead of raw IDs
- (() => {
- const looksLikeIdStr = (s: any) => {
- try {
- const hexOnly = String(s || '').replace(/[^0-9a-fA-F]/g, '')
- const len = (typeof hexOnly === 'string') ? hexOnly.length : (Number(hexOnly) || 0)
- return len >= 8
- } catch { return false }
- }
- const maybeId = selectedReport?.doctor_id || selectedReport?.created_by || selectedReport?.doctor || null
- // derive the title text
- const derived = reportDoctorName ? reportTitle(selectedReport, reportDoctorName) : reportTitle(selectedReport)
- // if the derived title looks like an id (UUID/hex) avoid showing it — show loading instead
- if (looksLikeIdStr(derived)) return
{strings.carregando}
- if (resolvingDoctors && maybeId && !doctorsMap[String(maybeId)]) return
{strings.carregando}
- return
{derived}
- })()
- }
-
Data: {new Date(selectedReport.report_date || selectedReport.created_at || Date.now()).toLocaleDateString('pt-BR')}
- {reportDoctorName &&
Profissional: {reportDoctorName}
}
-
+
Data: {new Date(selectedReport.report_date || selectedReport.created_at || Date.now()).toLocaleDateString('pt-BR')}
+ {reportDoctorName &&
Profissional: {reportDoctorName}
}
- {/* Standardized laudo sections: CID, Exame, Diagnóstico, Conclusão, Notas (prefer HTML when available) */}
+ {/* Standardized laudo sections */}
{(() => {
- const cid = selectedReport.cid ?? selectedReport.cid_code ?? selectedReport.cidCode ?? selectedReport.cie ?? '-'
- const exam = selectedReport.exam ?? selectedReport.exame ?? selectedReport.especialidade ?? selectedReport.report_type ?? '-'
- const diagnosis = selectedReport.diagnosis ?? selectedReport.diagnostico ?? selectedReport.diagnosis_text ?? selectedReport.diagnostico_text ?? ''
- const conclusion = selectedReport.conclusion ?? selectedReport.conclusao ?? selectedReport.conclusion_text ?? selectedReport.conclusao_text ?? ''
- const notesHtml = selectedReport.content_html ?? selectedReport.conteudo_html ?? selectedReport.contentHtml ?? null
- const notesText = selectedReport.content ?? selectedReport.body ?? selectedReport.conteudo ?? selectedReport.notes ?? selectedReport.observacoes ?? ''
+ const cid = selectedReport.cid ?? selectedReport.cid_code ?? selectedReport.cidCode ?? selectedReport.cie ?? '-';
+ const exam = selectedReport.exam ?? selectedReport.exame ?? selectedReport.especialidade ?? selectedReport.report_type ?? '-';
+ const diagnosis = selectedReport.diagnosis ?? selectedReport.diagnostico ?? selectedReport.diagnosis_text ?? selectedReport.diagnostico_text ?? '';
+ const conclusion = selectedReport.conclusion ?? selectedReport.conclusao ?? selectedReport.conclusion_text ?? selectedReport.conclusao_text ?? '';
+ const notesHtml = selectedReport.content_html ?? selectedReport.conteudo_html ?? selectedReport.contentHtml ?? null;
+ const notesText = selectedReport.content ?? selectedReport.body ?? selectedReport.conteudo ?? selectedReport.notes ?? selectedReport.observacoes ?? '';
return (
-
+
-
-
Diagnóstico
{diagnosis || '-'}
-
Conclusão
{conclusion || '-'}
-
Notas do Profissional
{notesHtml ? (
@@ -1212,18 +1402,23 @@ export default function PacientePage() {
)}
- )
+ );
})()}
- {/* Optional: doctor signature or footer */}
{selectedReport.doctor_signature && (
-
Assinatura:
+
Assinatura:
)}
>
)}
-
+
- setSelectedReport(null)}>Fechar
+ setSelectedReport(null)}
+ className="transition duration-200 hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground"
+ >
+ Fechar
+
@@ -1346,7 +1541,7 @@ export default function PacientePage() {
-
+
Início
@@ -1356,7 +1551,7 @@ export default function PacientePage() {
variant="outline"
aria-label={strings.sair}
disabled={loading}
- className="text-destructive border-destructive hover:!bg-destructive hover:!text-white hover:!border-destructive transition-colors"
+ className="text-destructive border-destructive hover:bg-destructive! hover:text-white! hover:border-destructive! transition-colors"
>
{strings.sair}
@@ -1372,7 +1567,7 @@ export default function PacientePage() {
variant={tab==='dashboard'?'default':'ghost'}
aria-current={tab==='dashboard'}
onClick={()=>setTab('dashboard')}
- className={`w-full justify-start transition-colors hover:!bg-primary hover:!text-white cursor-pointer`}
+ className={`w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer`}
>
{strings.dashboard}
@@ -1380,7 +1575,7 @@ export default function PacientePage() {
variant={tab==='consultas'?'default':'ghost'}
aria-current={tab==='consultas'}
onClick={()=>setTab('consultas')}
- className={`w-full justify-start transition-colors hover:!bg-primary hover:!text-white cursor-pointer`}
+ className={`w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer`}
>
{strings.consultas}
@@ -1388,7 +1583,7 @@ export default function PacientePage() {
variant={tab==='exames'?'default':'ghost'}
aria-current={tab==='exames'}
onClick={()=>setTab('exames')}
- className={`w-full justify-start transition-colors hover:!bg-primary hover:!text-white cursor-pointer`}
+ className={`w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer`}
>
{strings.exames}
@@ -1397,7 +1592,7 @@ export default function PacientePage() {
variant={tab==='perfil'?'default':'ghost'}
aria-current={tab==='perfil'}
onClick={()=>setTab('perfil')}
- className={`w-full justify-start transition-colors hover:!bg-primary hover:!text-white cursor-pointer`}
+ className={`w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer`}
>
{strings.perfil}
diff --git a/susconecta/app/resultados/ResultadosClient.tsx b/susconecta/app/paciente/resultados/ResultadosClient.tsx
similarity index 78%
rename from susconecta/app/resultados/ResultadosClient.tsx
rename to susconecta/app/paciente/resultados/ResultadosClient.tsx
index 8c6391d..1fa8519 100644
--- a/susconecta/app/resultados/ResultadosClient.tsx
+++ b/susconecta/app/paciente/resultados/ResultadosClient.tsx
@@ -31,6 +31,7 @@ import {
getAvailableSlots,
criarAgendamento,
criarAgendamentoDireto,
+ listarAgendamentos,
getUserInfo,
buscarPacientes,
listarDisponibilidades,
@@ -54,13 +55,16 @@ export default function ResultadosClient() {
const params = useSearchParams()
const router = useRouter()
- // Filtros/controles da UI
- const [tipoConsulta, setTipoConsulta] = useState
(
- params?.get('tipo') === 'presencial' ? 'local' : 'teleconsulta'
- )
- const [especialidadeHero, setEspecialidadeHero] = useState(params?.get('especialidade') || 'Psicólogo')
+ // Filtros/controles da UI - initialize with defaults to avoid hydration mismatch
+ const [tipoConsulta, setTipoConsulta] = useState('teleconsulta')
+ const [especialidadeHero, setEspecialidadeHero] = useState('Psicólogo')
const [convenio, setConvenio] = useState('Todos')
const [bairro, setBairro] = useState('Todos')
+ // Busca por nome do médico
+ const [searchQuery, setSearchQuery] = useState('')
+
+ // Track if URL params have been synced to avoid race condition
+ const [paramsSync, setParamsSync] = useState(false)
// Estado dinâmico
const [patientId, setPatientId] = useState(null)
@@ -104,7 +108,20 @@ export default function ResultadosClient() {
const [bookingSuccessOpen, setBookingSuccessOpen] = useState(false)
const [bookedWhenLabel, setBookedWhenLabel] = useState(null)
- // 1) Obter patientId a partir do usuário autenticado (email -> patients)
+ // 1) Sincronize URL params with state after client mount (prevent hydration mismatch)
+ useEffect(() => {
+ if (!params) return
+ const tipoParam = params.get('tipo')
+ if (tipoParam === 'presencial') setTipoConsulta('local')
+
+ const especialidadeParam = params.get('especialidade')
+ if (especialidadeParam) setEspecialidadeHero(especialidadeParam)
+
+ // Mark params as synced
+ setParamsSync(true)
+ }, [params])
+
+ // 2) Fetch patient ID from auth
useEffect(() => {
let mounted = true
;(async () => {
@@ -124,8 +141,32 @@ export default function ResultadosClient() {
return () => { mounted = false }
}, [])
- // 2) Buscar médicos conforme especialidade selecionada
+ // 3) Initial doctors fetch on mount (one-time initialization)
useEffect(() => {
+ let mounted = true
+ ;(async () => {
+ try {
+ setLoadingMedicos(true)
+ console.log('[ResultadosClient] Initial doctors fetch starting')
+ const list = await buscarMedicos('medico').catch((err) => {
+ console.error('[ResultadosClient] Initial fetch error:', err)
+ return []
+ })
+ if (!mounted) return
+ console.log('[ResultadosClient] Initial fetch completed, got:', list?.length || 0, 'doctors')
+ setMedicos(Array.isArray(list) ? list : [])
+ } finally {
+ if (mounted) setLoadingMedicos(false)
+ }
+ })()
+ return () => { mounted = false }
+ }, [])
+
+ // 4) Re-fetch doctors when especialidade changes (after initial sync)
+ useEffect(() => {
+ // Skip if this is the initial render or if user is searching by name
+ if (!paramsSync || (searchQuery && String(searchQuery).trim().length > 1)) return
+
let mounted = true
;(async () => {
try {
@@ -133,10 +174,15 @@ export default function ResultadosClient() {
setMedicos([])
setAgendaByDoctor({})
setAgendasExpandida({})
- // termo de busca: usar a especialidade escolhida (fallback para string genérica)
- const termo = (especialidadeHero && especialidadeHero !== 'Veja mais') ? especialidadeHero : (params?.get('q') || 'medico')
- const list = await buscarMedicos(termo).catch(() => [])
+ // termo de busca: usar a especialidade escolhida
+ const termo = (especialidadeHero && especialidadeHero !== 'Veja mais') ? especialidadeHero : 'medico'
+ console.log('[ResultadosClient] Fetching doctors with term:', termo)
+ const list = await buscarMedicos(termo).catch((err) => {
+ console.error('[ResultadosClient] buscarMedicos error:', err)
+ return []
+ })
if (!mounted) return
+ console.log('[ResultadosClient] Doctors fetched:', list?.length || 0)
setMedicos(Array.isArray(list) ? list : [])
} catch (e: any) {
showToast('error', e?.message || 'Falha ao buscar profissionais')
@@ -145,7 +191,32 @@ export default function ResultadosClient() {
}
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [especialidadeHero])
+ }, [especialidadeHero, paramsSync])
+
+ // 5) Debounced search by doctor name
+ useEffect(() => {
+ let mounted = true
+ const term = String(searchQuery || '').trim()
+ const handle = setTimeout(async () => {
+ if (!mounted) return
+ // if no meaningful search, do nothing (the specialidade effect will run)
+ if (!term || term.length < 2) return
+ try {
+ setLoadingMedicos(true)
+ setMedicos([])
+ setAgendaByDoctor({})
+ setAgendasExpandida({})
+ const list = await buscarMedicos(term).catch(() => [])
+ if (!mounted) return
+ setMedicos(Array.isArray(list) ? list : [])
+ } catch (e: any) {
+ showToast('error', e?.message || 'Falha ao buscar profissionais')
+ } finally {
+ if (mounted) setLoadingMedicos(false)
+ }
+ }, 350)
+ return () => { mounted = false; clearTimeout(handle) }
+ }, [searchQuery])
// 3) Carregar horários disponíveis para um médico (próximos 7 dias) e agrupar por dia
async function loadAgenda(doctorId: string) {
@@ -172,7 +243,7 @@ export default function ResultadosClient() {
days.push({ label, data: fmtDay(d), dateKey, horarios: [] })
}
- const onlyAvail = (res?.slots || []).filter(s => s.available)
+ const onlyAvail = (res?.slots || []).filter((s: any) => s.available)
for (const s of onlyAvail) {
const dt = new Date(s.datetime)
const key = dt.toISOString().split('T')[0]
@@ -237,7 +308,26 @@ export default function ResultadosClient() {
}
// Open confirmation dialog for a selected slot instead of immediately booking
- function openConfirmDialog(doctorId: string, iso: string) {
+ async function openConfirmDialog(doctorId: string, iso: string) {
+ // Pre-check: ensure there is no existing appointment for this doctor at this exact datetime
+ try {
+ // build query: exact match on doctor_id and scheduled_at
+ const params = new URLSearchParams();
+ params.set('doctor_id', `eq.${String(doctorId)}`);
+ params.set('scheduled_at', `eq.${String(iso)}`);
+ params.set('limit', '1');
+ const existing = await listarAgendamentos(params.toString()).catch(() => [])
+ if (existing && (existing as any).length) {
+ showToast('error', 'Não é possível agendar: já existe uma consulta neste horário para o profissional selecionado.')
+ return
+ }
+ } catch (err) {
+ // If checking fails (auth or network), surface a friendly error and avoid opening the dialog to prevent accidental duplicates.
+ console.warn('[ResultadosClient] falha ao checar conflitos de agendamento', err)
+ showToast('error', 'Não foi possível verificar disponibilidade. Tente novamente em instantes.')
+ return
+ }
+
setPendingAppointment({ doctorId, iso })
setConfirmOpen(true)
}
@@ -255,6 +345,24 @@ export default function ResultadosClient() {
showToast('success', 'Iniciando agendamento...')
setConfirmLoading(true)
try {
+ // Final conflict check to avoid race conditions: query appointments for same doctor + scheduled_at
+ try {
+ const params = new URLSearchParams();
+ params.set('doctor_id', `eq.${String(doctorId)}`);
+ params.set('scheduled_at', `eq.${String(iso)}`);
+ params.set('limit', '1');
+ const existing = await listarAgendamentos(params.toString()).catch(() => [])
+ if (existing && (existing as any).length) {
+ showToast('error', 'Não é possível agendar: já existe uma consulta neste horário para o profissional selecionado.')
+ setConfirmLoading(false)
+ return
+ }
+ } catch (err) {
+ console.warn('[ResultadosClient] falha ao checar conflito antes de criar agendamento', err)
+ showToast('error', 'Falha ao verificar conflito de agendamento. Tente novamente.')
+ setConfirmLoading(false)
+ return
+ }
// Use direct POST to ensure creation even if availability checks would block
await criarAgendamentoDireto({
patient_id: String(patientId),
@@ -319,7 +427,7 @@ export default function ResultadosClient() {
let start: Date
let end: Date
try {
- const parts = String(dateOnly).split('-').map((p) => Number(p))
+ const parts = String(dateOnly).split('-').map(Number)
if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) {
const [y, m, d] = parts
start = new Date(y, m - 1, d, 0, 0, 0, 0)
@@ -357,12 +465,12 @@ export default function ResultadosClient() {
5: ['5','fri','friday','sexta','sexta-feira'],
6: ['6','sat','saturday','sabado','sábado']
}
- const allowed = (weekdayNames[weekdayNumber] || []).map(s => String(s).toLowerCase())
+ const allowed = new Set((weekdayNames[weekdayNumber] || []).map(s => String(s).toLowerCase()))
const matched = (disponibilidades || []).filter((d: any) => {
try {
const raw = String(d.weekday ?? d.weekday_name ?? d.day ?? d.day_of_week ?? '').toLowerCase()
if (!raw) return false
- if (allowed.includes(raw)) return true
+ if (allowed.has(raw)) return true
if (typeof d.weekday === 'number' && d.weekday === weekdayNumber) return true
if (typeof d.day_of_week === 'number' && d.day_of_week === weekdayNumber) return true
return false
@@ -373,7 +481,7 @@ export default function ResultadosClient() {
const windows = matched.map((d: any) => {
const parseTime = (t?: string) => {
if (!t) return { hh: 0, mm: 0, ss: 0 }
- const parts = String(t).split(':').map((p) => Number(p))
+ const parts = String(t).split(':').map(Number)
return { hh: parts[0] || 0, mm: parts[1] || 0, ss: parts[2] || 0 }
}
const s = parseTime(d.start_time)
@@ -420,8 +528,8 @@ export default function ResultadosClient() {
cursorMs += perWindowStep * 60000
}
} else {
- const lastBackendMs = backendSlotsInWindow[backendSlotsInWindow.length - 1]
- let cursorMs = lastBackendMs + perWindowStep * 60000
+ const lastBackendMs = backendSlotsInWindow.at(-1)
+ let cursorMs = (lastBackendMs ?? 0) + perWindowStep * 60000
while (cursorMs <= lastStartMs) {
generatedSet.add(new Date(cursorMs).toISOString())
cursorMs += perWindowStep * 60000
@@ -505,6 +613,20 @@ export default function ResultadosClient() {
})
}, [medicos, convenio, bairro])
+ // Paginação local para a lista de médicos
+ const [currentPage, setCurrentPage] = useState(1)
+ const [itemsPerPage, setItemsPerPage] = useState(5)
+
+ // Resetar para página 1 quando o conjunto de profissionais (filtro) ou itemsPerPage mudar
+ useEffect(() => {
+ setCurrentPage(1)
+ }, [profissionais, itemsPerPage])
+
+ const totalPages = Math.max(1, Math.ceil((profissionais || []).length / itemsPerPage))
+ const paginatedProfissionais = (profissionais || []).slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
+ const startItem = (profissionais || []).length ? (currentPage - 1) * itemsPerPage + 1 : 0
+ const endItem = Math.min(currentPage * itemsPerPage, (profissionais || []).length)
+
// Render
return (
@@ -517,7 +639,7 @@ export default function ResultadosClient() {
)}
{/* Confirmation dialog shown when a user selects a slot */}
- { if (!open) { setConfirmOpen(false); setPendingAppointment(null); } }}>
+ { if (!open) { setConfirmOpen(false); setPendingAppointment(null); } }}>
Confirmar agendamento
@@ -550,7 +672,7 @@ export default function ResultadosClient() {
{/* Booking success modal shown when origin=paciente */}
- setBookingSuccessOpen(open)}>
+ setBookingSuccessOpen(open)}>
Consulta agendada
@@ -573,7 +695,7 @@ export default function ResultadosClient() {
Ajustar filtros
@@ -600,7 +722,7 @@ export default function ResultadosClient() {
setTipoConsulta('teleconsulta')}
- className={cn('rounded-full px-4 py-[10px] text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]',
+ className={cn('rounded-full px-4 py-2.5 text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]',
tipoConsulta === 'teleconsulta' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')}
>
@@ -609,7 +731,7 @@ export default function ResultadosClient() {
setTipoConsulta('local')}
- className={cn('rounded-full px-4 py-[10px] text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]',
+ className={cn('rounded-full px-4 py-2.5 text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]',
tipoConsulta === 'local' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')}
>
@@ -617,7 +739,7 @@ export default function ResultadosClient() {
-
+
@@ -631,7 +753,7 @@ export default function ResultadosClient() {
-
+
@@ -642,17 +764,51 @@ export default function ResultadosClient() {
-
-
- Mais filtros
-
+ {/* Search input para buscar médico por nome */}
+
+ ) => setSearchQuery(e.target.value)}
+ className="min-w-[220px] rounded-full"
+ />
+ {searchQuery ? (
+ {
+ // limpar o termo de busca e restaurar a lista por especialidade
+ setSearchQuery('')
+ setCurrentPage(1)
+ try {
+ setLoadingMedicos(true)
+ setMedicos([])
+ setAgendaByDoctor({})
+ setAgendasExpandida({})
+ const termo = (especialidadeHero && especialidadeHero !== 'Veja mais') ? especialidadeHero : (params?.get('q') || 'medico')
+ const list = await buscarMedicos(termo).catch(() => [])
+ setMedicos(Array.isArray(list) ? list : [])
+ } catch (e: any) {
+ showToast('error', e?.message || 'Falha ao buscar profissionais')
+ } finally {
+ setLoadingMedicos(false)
+ }
+ }}
+ >Limpar
+ ) : (
+
+
+ Mais filtros
+
+ )}
+
router.back()}
>
Voltar
@@ -668,7 +824,7 @@ export default function ResultadosClient() {
)}
- {!loadingMedicos && profissionais.map((medico) => {
+ {!loadingMedicos && paginatedProfissionais.map((medico) => {
const id = String(medico.id)
const agenda = agendaByDoctor[id]
const isLoadingAgenda = !!agendaLoading[id]
@@ -711,7 +867,7 @@ export default function ResultadosClient() {
{
setMedicoSelecionado(medico)
setAbaDetalhe('experiencia')
@@ -782,12 +938,12 @@ export default function ResultadosClient() {
>
Agendar consulta
-
+
Enviar mensagem
{
const willOpen = !agendasExpandida[id]
setAgendasExpandida(prev => ({ ...prev, [id]: !prev[id] }))
@@ -806,50 +962,7 @@ export default function ResultadosClient() {
- {/* Agenda: 4 colunas como no layout. Se ainda não carregou, mostra placeholders. */}
-
-
- {(agenda || [
- { label: 'HOJE', data: fmtDay(new Date()), horarios: [] },
- { label: 'AMANHÃ', data: fmtDay(new Date(Date.now()+86400000)), horarios: [] },
- { label: shortWeek[new Date(Date.now()+2*86400000).getDay()], data: fmtDay(new Date(Date.now()+2*86400000)), horarios: [] },
- { label: shortWeek[new Date(Date.now()+3*86400000).getDay()], data: fmtDay(new Date(Date.now()+3*86400000)), horarios: [] },
- ]).map((col, idx) => {
- const horarios = agendasExpandida[id] ? col.horarios : col.horarios.slice(0, 3)
- return (
-
-
{col.label}
-
{col.data}
-
- {isLoadingAgenda && !agenda ? (
-
- Carregando...
-
- ) : horarios.length ? (
- horarios.map(h => (
- openConfirmDialog(id, h.iso)}
- >
- {h.label}
-
- ))
- ) : (
-
- Sem horários
-
- )}
- {!agendasExpandida[id] && (col.horarios.length > 3) && (
- +{col.horarios.length - 3} horários
- )}
-
-
- )
- })}
-
-
+ {/* Horários compactos removidos conforme solicitação do design (colunas HOJE/AMANHÃ/etc.). */}
)
})}
@@ -859,10 +972,33 @@ export default function ResultadosClient() {
Nenhum profissional encontrado. Ajuste os filtros para ver outras opções.
)}
+
+ {/* Pagination controls */}
+ {!loadingMedicos && profissionais.length > 0 && (
+
+
+ Itens por página:
+ setItemsPerPage(Number(e.target.value))} className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary cursor-pointer">
+ 5
+ 10
+ 20
+
+ Mostrando {startItem} a {endItem} de {profissionais.length}
+
+
+
+ setCurrentPage(1)} disabled={currentPage === 1} className="hover:bg-primary! hover:text-white!">Primeira
+ setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="hover:bg-primary! hover:text-white!">Anterior
+ Página {currentPage} de {totalPages}
+ setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="hover:bg-primary! hover:text-white!">Próxima
+ setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="hover:bg-primary! hover:text-white!">Última
+
+
+ )}
{/* Dialog de perfil completo (mantido e adaptado) */}
-
!open && setMedicoSelecionado(null)}>
+ !open && setMedicoSelecionado(null)}>
{medicoSelecionado && (
<>
@@ -978,7 +1114,7 @@ export default function ResultadosClient() {
{/* Dialog: Mostrar mais horários (escolher data arbitrária) */}
- { if (!open) { setMoreTimesForDoctor(null); setMoreTimesSlots([]); setMoreTimesException(null); } }}>
+ { if (!open) { setMoreTimesForDoctor(null); setMoreTimesSlots([]); setMoreTimesException(null); } }}>
Mais horários
diff --git a/susconecta/app/resultados/page.tsx b/susconecta/app/paciente/resultados/page.tsx
similarity index 60%
rename from susconecta/app/resultados/page.tsx
rename to susconecta/app/paciente/resultados/page.tsx
index 1318172..12b177a 100644
--- a/susconecta/app/resultados/page.tsx
+++ b/susconecta/app/paciente/resultados/page.tsx
@@ -3,7 +3,7 @@ import ResultadosClient from './ResultadosClient'
export default function Page() {
return (
- Carregando... }>
+
Carregando... }>
)
diff --git a/susconecta/app/page.tsx b/susconecta/app/page.tsx
index 3ee76cc..d62f5fb 100644
--- a/susconecta/app/page.tsx
+++ b/susconecta/app/page.tsx
@@ -1,6 +1,6 @@
-import { Header } from "@/components/header"
-import { HeroSection } from "@/components/hero-section"
-import { Footer } from "@/components/footer"
+import { Header } from "@/components/layout/header"
+import { HeroSection } from "@/components/features/general/hero-section"
+import { Footer } from "@/components/layout/footer"
export default function HomePage() {
return (
diff --git a/susconecta/app/profissional/page.tsx b/susconecta/app/profissional/page.tsx
index 74794b5..485fc90 100644
--- a/susconecta/app/profissional/page.tsx
+++ b/susconecta/app/profissional/page.tsx
@@ -1,9 +1,11 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
+import Image from "next/image";
import SignatureCanvas from "react-signature-canvas";
import Link from "next/link";
-import ProtectedRoute from "@/components/ProtectedRoute";
+import ProtectedRoute from "@/components/shared/ProtectedRoute";
import { useAuth } from "@/hooks/useAuth";
+import { useToast } from "@/hooks/use-toast";
import { buscarPacientes, listarPacientes, buscarPacientePorId, buscarPacientesPorIds, buscarMedicoPorId, buscarMedicosPorIds, buscarMedicos, listarAgendamentos, type Paciente, buscarRelatorioPorId, atualizarMedico } from "@/lib/api";
import { useReports } from "@/hooks/useReports";
import { CreateReportData } from "@/types/report-types";
@@ -12,7 +14,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select";
-import { SimpleThemeToggle } from "@/components/simple-theme-toggle";
+import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
import {
Table,
TableBody,
@@ -174,7 +176,8 @@ const ProfissionalPage = () => {
}
})();
return () => { mounted = false; };
- }, [user?.id, doctorId]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
// Carregar perfil do médico correspondente ao usuário logado
useEffect(() => {
@@ -226,7 +229,8 @@ const ProfissionalPage = () => {
}
})();
return () => { mounted = false; };
- }, [user?.id, user?.email]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
@@ -338,7 +342,7 @@ const ProfissionalPage = () => {
// Helper: parse 'YYYY-MM-DD' into a local Date to avoid UTC parsing which can shift day
const parseYMDToLocal = (ymd?: string) => {
if (!ymd || typeof ymd !== 'string') return new Date();
- const parts = ymd.split('-').map((p) => Number(p));
+ const parts = ymd.split('-').map(Number);
if (parts.length < 3 || parts.some((n) => Number.isNaN(n))) return new Date(ymd);
const [y, m, d] = parts;
return new Date(y, (m || 1) - 1, d || 1);
@@ -369,7 +373,8 @@ const ProfissionalPage = () => {
}
})();
return () => { mounted = false; };
- }, [doctorId, user?.id, user?.email]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [doctorId]);
const [editingEvent, setEditingEvent] = useState
(null);
const [showPopup, setShowPopup] = useState(false);
const [showActionModal, setShowActionModal] = useState(false);
@@ -690,7 +695,7 @@ const ProfissionalPage = () => {
variant="outline"
size="sm"
onClick={() => navigateDate('prev')}
- className="p-2 hover:!bg-primary hover:!text-white cursor-pointer transition-colors"
+ className="p-2 hover:bg-primary! hover:text-white! cursor-pointer transition-colors"
>
@@ -701,7 +706,7 @@ const ProfissionalPage = () => {
variant="outline"
size="sm"
onClick={() => navigateDate('next')}
- className="p-2 hover:!bg-primary hover:!text-white cursor-pointer transition-colors"
+ className="p-2 hover:bg-primary! hover:text-white! cursor-pointer transition-colors"
>
@@ -900,7 +905,7 @@ const ProfissionalPage = () => {
variant={selectedRange === 'todos' ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedRange('todos')}
- className="hover:!bg-primary hover:!text-white transition-colors"
+ className="hover:bg-primary! hover:text-white! transition-colors"
>
Todos
@@ -908,7 +913,7 @@ const ProfissionalPage = () => {
variant={selectedRange === 'semana' ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedRange('semana')}
- className="hover:!bg-primary hover:!text-white transition-colors"
+ className="hover:bg-primary! hover:text-white! transition-colors"
>
Semana
@@ -916,7 +921,7 @@ const ProfissionalPage = () => {
variant={selectedRange === 'mes' ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedRange('mes')}
- className="hover:!bg-primary hover:!text-white transition-colors"
+ className="hover:bg-primary! hover:text-white! transition-colors"
>
Mês
@@ -1077,7 +1082,7 @@ const ProfissionalPage = () => {
Buscar
-
+
Limpar
@@ -1200,12 +1205,14 @@ const ProfissionalPage = () => {
await loadAssignedLaudos();
})();
return () => { mounted = false; };
- }, [user?.id]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
// sincroniza quando reports mudarem no hook (fallback)
useEffect(() => {
if (!laudos || laudos.length === 0) setLaudos(reports || []);
- }, [reports]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
// Sort reports newest-first (more recent dates at the top)
const sortedLaudos = React.useMemo(() => {
@@ -1383,7 +1390,7 @@ const ProfissionalPage = () => {
setIsViewing(true);
}
}}
- className="flex items-center gap-1 hover:!bg-primary hover:!text-white transition-colors"
+ className="flex items-center gap-1 hover:bg-primary! hover:text-white! transition-colors"
>
Ver Laudo
@@ -1668,8 +1675,7 @@ const ProfissionalPage = () => {
// Editor de Laudo Avançado (para novos laudos)
function LaudoEditor({ pacientes, laudo, onClose, isNewLaudo, preSelectedPatient, createNewReport, updateExistingReport, reloadReports, onSaved }: { pacientes?: any[]; laudo?: any; onClose: () => void; isNewLaudo?: boolean; preSelectedPatient?: any; createNewReport?: (data: any) => Promise; updateExistingReport?: (id: string, data: any) => Promise; reloadReports?: () => Promise; onSaved?: (r:any) => void }) {
- // Import useToast at the top level of the component
- const { toast } = require('@/hooks/use-toast').useToast();
+ const { toast } = useToast();
const [activeTab, setActiveTab] = useState("editor");
const [content, setContent] = useState(laudo?.conteudo || "");
const [showPreview, setShowPreview] = useState(false);
@@ -1818,7 +1824,7 @@ const ProfissionalPage = () => {
const sig = laudo.assinaturaImg ?? laudo.signature_image ?? laudo.signature ?? laudo.sign_image ?? null;
if (sig) setAssinaturaImg(sig);
}
- }, [laudo, isNewLaudo, pacienteSelecionado, listaPacientes, user]);
+ }, [laudo, isNewLaudo, pacienteSelecionado, listaPacientes]);
// Histórico para desfazer/refazer
const [history, setHistory] = useState([]);
@@ -2250,6 +2256,7 @@ const ProfissionalPage = () => {
{imagens.map((img) => (
{img.type.startsWith('image/') ? (
+ // eslint-disable-next-line @next/next/no-img-element
{
Imagens:
{imagens.map((img) => (
+ // eslint-disable-next-line @next/next/no-img-element
{
{campos.mostrarAssinatura && (
{assinaturaImg && assinaturaImg.length > 30 ? (
+ // eslint-disable-next-line @next/next/no-img-element
) : (
Assine no campo ao lado para visualizar aqui.
@@ -2457,7 +2466,7 @@ const ProfissionalPage = () => {
Este editor permite escrever relatórios de forma livre, com formatação de texto rica.
-
+
Cancelar
{/* botão 'Salvar Rascunho' removido por não ser utilizado */}
@@ -2528,7 +2537,11 @@ const ProfissionalPage = () => {
} else if (typeof val === 'boolean') {
if (origVal !== val) diff[k] = val;
} else if (val !== undefined && val !== null) {
- if (JSON.stringify(origVal) !== JSON.stringify(val)) diff[k] = val;
+ if (JSON.stringify(origVal) !== JSON.stringify(val)) {
+ diff[k] = val;
+ } else {
+ // no change
+ }
}
}
@@ -2656,7 +2669,7 @@ const ProfissionalPage = () => {
Salvar
-
+
Cancelar
@@ -2779,7 +2792,7 @@ const ProfissionalPage = () => {
{isEditingProfile && (
-
+
Alterar Foto
@@ -2875,7 +2888,7 @@ const ProfissionalPage = () => {
setActiveSection('calendario')}
>
@@ -2883,7 +2896,7 @@ const ProfissionalPage = () => {
setActiveSection('pacientes')}
>
@@ -2891,7 +2904,7 @@ const ProfissionalPage = () => {
setActiveSection('laudos')}
>
@@ -2899,7 +2912,7 @@ const ProfissionalPage = () => {
setActiveSection('comunicacao')}
>
@@ -2907,7 +2920,7 @@ const ProfissionalPage = () => {
setActiveSection('perfil')}
>
@@ -2957,7 +2970,7 @@ const ProfissionalPage = () => {
setShowPopup(false)}
variant="outline"
- className="flex-1 hover:!bg-primary hover:!text-white transition-colors"
+ className="flex-1 hover:bg-primary! hover:text-white! transition-colors"
>
Cancelar
@@ -3072,7 +3085,7 @@ const ProfissionalPage = () => {
setShowActionModal(false)}
variant="outline"
- className="w-full mt-2 hover:!bg-primary hover:!text-white transition-colors"
+ className="w-full mt-2 hover:bg-primary! hover:text-white! transition-colors"
>
Cancelar
diff --git a/susconecta/app/sobre/page.tsx b/susconecta/app/sobre/page.tsx
index 4ce80eb..fb0a655 100644
--- a/susconecta/app/sobre/page.tsx
+++ b/susconecta/app/sobre/page.tsx
@@ -1,6 +1,6 @@
-import { Header } from "@/components/header"
-import { AboutSection } from "@/components/about-section"
-import { Footer } from "@/components/footer"
+import { Header } from "@/components/layout/header"
+import { AboutSection } from "@/components/features/general/about-section"
+import { Footer } from "@/components/layout/footer"
export default function AboutPage() {
return (
diff --git a/susconecta/components/features/Calendario/Calendar.tsx b/susconecta/components/features/Calendario/Calendar.tsx
new file mode 100644
index 0000000..73eafe7
--- /dev/null
+++ b/susconecta/components/features/Calendario/Calendar.tsx
@@ -0,0 +1,118 @@
+import React, { useState, useCallback, useMemo } from "react";
+import { EventCard } from "./EventCard";
+import { Card } from "@/components/ui/card";
+
+// Types
+import { Event } from "@/components/features/general/event-manager";
+
+// Week View Component
+export function WeekView({
+ currentDate,
+ events,
+ onEventClick,
+ onDragStart,
+ onDragEnd,
+ onDrop,
+ getColorClasses,
+}: {
+ currentDate: Date;
+ events: Event[];
+ onEventClick: (event: Event) => void;
+ onDragStart: (event: Event) => void;
+ onDragEnd: () => void;
+ onDrop: (date: Date, hour: number) => void;
+ getColorClasses: (color: string) => { bg: string; text: string };
+}) {
+ const startOfWeek = new Date(currentDate);
+ startOfWeek.setDate(currentDate.getDay());
+
+ const weekDays = Array.from({ length: 7 }, (_, i) => {
+ const day = new Date(startOfWeek);
+ day.setDate(startOfWeek.getDate() + i);
+ return day;
+ });
+
+ const hours = Array.from({ length: 24 }, (_, i) => i);
+
+ const getEventsForDayAndHour = (date: Date, hour: number) => {
+ return events.filter((event) => {
+ const eventDate = new Date(event.startTime);
+ const eventHour = eventDate.getHours();
+ return (
+ eventDate.getDate() === date.getDate() &&
+ eventDate.getMonth() === date.getMonth() &&
+ eventDate.getFullYear() === date.getFullYear() &&
+ eventHour === hour
+ );
+ });
+ };
+
+ // dias da semana em pt-BR (abreviações)
+ const weekDayNames = ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"];
+
+ return (
+
+
+
+ Hora
+
+ {weekDays.map((day) => (
+
+
+ {day.toLocaleDateString("pt-BR", { weekday: "short" })}
+
+
+ {day.toLocaleDateString("pt-BR", { weekday: "narrow" })}
+
+
+ {day.toLocaleDateString("pt-BR", {
+ month: "short",
+ day: "numeric",
+ })}
+
+
+ ))}
+
+
+ {hours.map((hour) => (
+
+
+ {hour.toString().padStart(2, "0")}:00
+
+ {weekDays.map((day) => {
+ const dayEvents = getEventsForDayAndHour(day, hour);
+ return (
+ e.preventDefault()}
+ onDrop={() => onDrop(day, hour)}
+ >
+
+ {dayEvents.map((event) => (
+
+ ))}
+
+
+ );
+ })}
+
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/susconecta/components/features/Calendario/EventCard.tsx b/susconecta/components/features/Calendario/EventCard.tsx
new file mode 100644
index 0000000..a28b566
--- /dev/null
+++ b/susconecta/components/features/Calendario/EventCard.tsx
@@ -0,0 +1,103 @@
+import React, { useState } from "react";
+import { Event } from "@/components/features/general/event-manager";
+import { cn } from "@/lib/utils";
+
+/*
+ Componente leve para representar um evento no calendário.
+ Compatível com o uso em Calendar.tsx (WeekView / DayView).
+*/
+
+export function EventCard({
+ event,
+ onEventClick,
+ onDragStart,
+ onDragEnd,
+ getColorClasses,
+ variant = "default",
+}: {
+ event: Event;
+ onEventClick: (e: Event) => void;
+ onDragStart: (e: Event) => void;
+ onDragEnd: () => void;
+ getColorClasses: (color: string) => { bg: string; text: string };
+ variant?: "default" | "compact" | "detailed";
+}) {
+ const [hover, setHover] = useState(false);
+ const color = getColorClasses?.(event.color) ?? { bg: "bg-slate-400", text: "text-white" };
+
+ const handleDragStart = (e: React.DragEvent) => {
+ e.dataTransfer.setData("text/plain", event.id);
+ onDragStart && onDragStart(event);
+ };
+
+ const handleClick = () => {
+ onEventClick && onEventClick(event);
+ };
+
+ if (variant === "compact") {
+ return (
+ onDragEnd && onDragEnd()}
+ onClick={handleClick}
+ onMouseEnter={() => setHover(true)}
+ onMouseLeave={() => setHover(false)}
+ className={cn(
+ "rounded px-2 py-0.5 text-xs font-medium truncate",
+ color.bg,
+ color.text,
+ "cursor-pointer transition-all",
+ hover && "shadow-md scale-105"
+ )}
+ >
+ {event.title}
+
+ );
+ }
+
+ if (variant === "detailed") {
+ return (
+ onDragEnd && onDragEnd()}
+ onClick={handleClick}
+ onMouseEnter={() => setHover(true)}
+ onMouseLeave={() => setHover(false)}
+ className={cn(
+ "rounded-lg p-2 text-sm cursor-pointer transition-all",
+ color.bg,
+ color.text,
+ hover && "shadow-lg scale-[1.02]"
+ )}
+ >
+
{event.title}
+ {event.description &&
{event.description}
}
+
+ {event.startTime?.toLocaleTimeString?.("pt-BR", { hour: "2-digit", minute: "2-digit" }) ?? ""} - {event.endTime?.toLocaleTimeString?.("pt-BR", { hour: "2-digit", minute: "2-digit" }) ?? ""}
+
+
+ );
+ }
+
+ // default
+ return (
+ onDragEnd && onDragEnd()}
+ onClick={handleClick}
+ onMouseEnter={() => setHover(true)}
+ onMouseLeave={() => setHover(false)}
+ className={cn(
+ "relative rounded px-2 py-1 text-xs font-medium cursor-pointer transition-all",
+ color.bg,
+ color.text,
+ hover && "shadow-md scale-105"
+ )}
+ >
+
{event.title}
+
+ );
+}
diff --git a/susconecta/components/admin/AssignmentForm.tsx b/susconecta/components/features/admin/AssignmentForm.tsx
similarity index 100%
rename from susconecta/components/admin/AssignmentForm.tsx
rename to susconecta/components/features/admin/AssignmentForm.tsx
diff --git a/susconecta/components/agenda/FooterAgenda.tsx b/susconecta/components/features/agenda/FooterAgenda.tsx
similarity index 88%
rename from susconecta/components/agenda/FooterAgenda.tsx
rename to susconecta/components/features/agenda/FooterAgenda.tsx
index 37a6fa6..9c911fc 100644
--- a/susconecta/components/agenda/FooterAgenda.tsx
+++ b/susconecta/components/features/agenda/FooterAgenda.tsx
@@ -1,9 +1,9 @@
"use client";
import { Save } from "lucide-react";
-import { Button } from "../ui/button";
-import { Label } from "../ui/label";
-import { Switch } from "../ui/switch";
+import { Button } from "../../ui/button";
+import { Label } from "../../ui/label";
+import { Switch } from "../../ui/switch";
import { useState } from "react";
interface FooterAgendaProps {
diff --git a/susconecta/components/agenda/HeaderAgenda.tsx b/susconecta/components/features/agenda/HeaderAgenda.tsx
similarity index 100%
rename from susconecta/components/agenda/HeaderAgenda.tsx
rename to susconecta/components/features/agenda/HeaderAgenda.tsx
diff --git a/susconecta/components/agendamento/AgendaCalendar.tsx b/susconecta/components/features/agendamento/AgendaCalendar.tsx
similarity index 100%
rename from susconecta/components/agendamento/AgendaCalendar.tsx
rename to susconecta/components/features/agendamento/AgendaCalendar.tsx
diff --git a/susconecta/components/agendamento/AppointmentModal.tsx b/susconecta/components/features/agendamento/AppointmentModal.tsx
similarity index 100%
rename from susconecta/components/agendamento/AppointmentModal.tsx
rename to susconecta/components/features/agendamento/AppointmentModal.tsx
diff --git a/susconecta/components/agendamento/ListaEspera.tsx b/susconecta/components/features/agendamento/ListaEspera.tsx
similarity index 100%
rename from susconecta/components/agendamento/ListaEspera.tsx
rename to susconecta/components/features/agendamento/ListaEspera.tsx
diff --git a/susconecta/components/agendamento/index.ts b/susconecta/components/features/agendamento/index.ts
similarity index 100%
rename from susconecta/components/agendamento/index.ts
rename to susconecta/components/features/agendamento/index.ts
diff --git a/susconecta/components/dashboard/header.tsx b/susconecta/components/features/dashboard/header.tsx
similarity index 95%
rename from susconecta/components/dashboard/header.tsx
rename to susconecta/components/features/dashboard/header.tsx
index 9963776..c9587cf 100644
--- a/susconecta/components/dashboard/header.tsx
+++ b/susconecta/components/features/dashboard/header.tsx
@@ -7,8 +7,8 @@ import { Input } from "@/components/ui/input"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
-import { SidebarTrigger } from "../ui/sidebar"
-import { SimpleThemeToggle } from "@/components/simple-theme-toggle";
+import { SidebarTrigger } from "../../ui/sidebar"
+import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
export function PagesHeader({ title = "", subtitle = "" }: { title?: string, subtitle?: string }) {
const { logout, user } = useAuth();
@@ -43,7 +43,7 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub
-
+
diff --git a/susconecta/components/forms/availability-form.tsx b/susconecta/components/features/forms/availability-form.tsx
similarity index 100%
rename from susconecta/components/forms/availability-form.tsx
rename to susconecta/components/features/forms/availability-form.tsx
diff --git a/susconecta/components/forms/calendar-registration-form.tsx b/susconecta/components/features/forms/calendar-registration-form.tsx
similarity index 100%
rename from susconecta/components/forms/calendar-registration-form.tsx
rename to susconecta/components/features/forms/calendar-registration-form.tsx
diff --git a/susconecta/components/forms/doctor-registration-form.tsx b/susconecta/components/features/forms/doctor-registration-form.tsx
similarity index 99%
rename from susconecta/components/forms/doctor-registration-form.tsx
rename to susconecta/components/features/forms/doctor-registration-form.tsx
index 75e0d1d..415070c 100644
--- a/susconecta/components/forms/doctor-registration-form.tsx
+++ b/susconecta/components/features/forms/doctor-registration-form.tsx
@@ -32,7 +32,7 @@ import { getAvatarPublicUrl } from '@/lib/api';
;
import { buscarCepAPI } from "@/lib/api";
-import { CredentialsDialog } from "@/components/credentials-dialog";
+import { CredentialsDialog } from "@/components/features/general/credentials-dialog";
type FormacaoAcademica = {
instituicao: string;
diff --git a/susconecta/components/forms/exception-form.tsx b/susconecta/components/features/forms/exception-form.tsx
similarity index 100%
rename from susconecta/components/forms/exception-form.tsx
rename to susconecta/components/features/forms/exception-form.tsx
diff --git a/susconecta/components/forms/patient-registration-form.tsx b/susconecta/components/features/forms/patient-registration-form.tsx
similarity index 99%
rename from susconecta/components/forms/patient-registration-form.tsx
rename to susconecta/components/features/forms/patient-registration-form.tsx
index 77746d6..1ff1dde 100644
--- a/susconecta/components/forms/patient-registration-form.tsx
+++ b/susconecta/components/features/forms/patient-registration-form.tsx
@@ -30,7 +30,7 @@ import { getAvatarPublicUrl } from '@/lib/api';
import { validarCPFLocal } from "@/lib/utils";
import { verificarCpfDuplicado } from "@/lib/api";
-import { CredentialsDialog } from "@/components/credentials-dialog";
+import { CredentialsDialog } from "@/components/features/general/credentials-dialog";
type Mode = "create" | "edit";
diff --git a/susconecta/components/about-section.tsx b/susconecta/components/features/general/about-section.tsx
similarity index 100%
rename from susconecta/components/about-section.tsx
rename to susconecta/components/features/general/about-section.tsx
diff --git a/susconecta/components/features/general/calendarComponente/page.tsx b/susconecta/components/features/general/calendarComponente/page.tsx
new file mode 100644
index 0000000..cc25cd4
--- /dev/null
+++ b/susconecta/components/features/general/calendarComponente/page.tsx
@@ -0,0 +1,1495 @@
+"use client"
+
+import { useState, useCallback, useMemo } from "react"
+import { Button } from "@/components/ui/button"
+import { Card } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { ChevronLeft, ChevronRight, Plus, Calendar, Clock, Grid3x3, List, Search, Filter, X } from "lucide-react"
+import { cn } from "@/lib/utils"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuCheckboxItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+export interface Event {
+ id: string
+ title: string
+ description?: string
+ startTime: Date
+ endTime: Date
+ color: string
+ category?: string
+ attendees?: string[]
+ tags?: string[]
+}
+
+export interface EventManagerProps {
+ events?: Event[]
+ onEventCreate?: (event: Omit) => void
+ onEventUpdate?: (id: string, event: Partial) => void
+ onEventDelete?: (id: string) => void
+ categories?: string[]
+ colors?: { name: string; value: string; bg: string; text: string }[]
+ defaultView?: "month" | "week" | "day" | "list"
+ className?: string
+ availableTags?: string[]
+}
+
+const defaultColors = [
+ { name: "Blue", value: "blue", bg: "bg-blue-500", text: "text-blue-700" },
+ { name: "Green", value: "green", bg: "bg-green-500", text: "text-green-700" },
+ { name: "Purple", value: "purple", bg: "bg-purple-500", text: "text-purple-700" },
+ { name: "Orange", value: "orange", bg: "bg-orange-500", text: "text-orange-700" },
+ { name: "Pink", value: "pink", bg: "bg-pink-500", text: "text-pink-700" },
+ { name: "Red", value: "red", bg: "bg-red-500", text: "text-red-700" },
+]
+
+export function EventManager({
+ events: initialEvents = [],
+ onEventCreate,
+ onEventUpdate,
+ onEventDelete,
+ categories = ["Meeting", "Task", "Reminder", "Personal"],
+ colors = defaultColors,
+ defaultView = "month",
+ className,
+ availableTags = ["Important", "Urgent", "Work", "Personal", "Team", "Client"],
+}: EventManagerProps) {
+ const [events, setEvents] = useState(initialEvents)
+ const [currentDate, setCurrentDate] = useState(new Date())
+ const [view, setView] = useState<"month" | "week" | "day" | "list">(defaultView)
+ const [selectedEvent, setSelectedEvent] = useState(null)
+ const [isDialogOpen, setIsDialogOpen] = useState(false)
+ const [isCreating, setIsCreating] = useState(false)
+ const [draggedEvent, setDraggedEvent] = useState(null)
+ const [newEvent, setNewEvent] = useState>({
+ title: "",
+ description: "",
+ color: colors[0].value,
+ category: categories[0],
+ tags: [],
+ })
+
+ const [searchQuery, setSearchQuery] = useState("")
+ const [selectedColors, setSelectedColors] = useState([])
+ const [selectedTags, setSelectedTags] = useState([])
+ const [selectedCategories, setSelectedCategories] = useState([])
+
+ const filteredEvents = useMemo(() => {
+ return events.filter((event) => {
+ // Search filter
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase()
+ const matchesSearch =
+ event.title.toLowerCase().includes(query) ||
+ event.description?.toLowerCase().includes(query) ||
+ event.category?.toLowerCase().includes(query) ||
+ event.tags?.some((tag) => tag.toLowerCase().includes(query))
+
+ if (!matchesSearch) return false
+ }
+
+ // Color filter
+ if (selectedColors.length > 0 && !selectedColors.includes(event.color)) {
+ return false
+ }
+
+ // Tag filter
+ if (selectedTags.length > 0) {
+ const hasMatchingTag = event.tags?.some((tag) => selectedTags.includes(tag))
+ if (!hasMatchingTag) return false
+ }
+
+ // Category filter
+ if (selectedCategories.length > 0 && event.category && !selectedCategories.includes(event.category)) {
+ return false
+ }
+
+ return true
+ })
+ }, [events, searchQuery, selectedColors, selectedTags, selectedCategories])
+
+ const hasActiveFilters = selectedColors.length > 0 || selectedTags.length > 0 || selectedCategories.length > 0
+
+ const clearFilters = () => {
+ setSelectedColors([])
+ setSelectedTags([])
+ setSelectedCategories([])
+ setSearchQuery("")
+ }
+
+ const handleCreateEvent = useCallback(() => {
+ if (!newEvent.title || !newEvent.startTime || !newEvent.endTime) return
+
+ const event: Event = {
+ id: Math.random().toString(36).substr(2, 9),
+ title: newEvent.title,
+ description: newEvent.description,
+ startTime: newEvent.startTime,
+ endTime: newEvent.endTime,
+ color: newEvent.color || colors[0].value,
+ category: newEvent.category,
+ attendees: newEvent.attendees,
+ tags: newEvent.tags || [],
+ }
+
+ setEvents((prev) => [...prev, event])
+ onEventCreate?.(event)
+ setIsDialogOpen(false)
+ setIsCreating(false)
+ setNewEvent({
+ title: "",
+ description: "",
+ color: colors[0].value,
+ category: categories[0],
+ tags: [],
+ })
+ }, [newEvent, colors, categories, onEventCreate])
+
+ const handleUpdateEvent = useCallback(() => {
+ if (!selectedEvent) return
+
+ setEvents((prev) => prev.map((e) => (e.id === selectedEvent.id ? selectedEvent : e)))
+ onEventUpdate?.(selectedEvent.id, selectedEvent)
+ setIsDialogOpen(false)
+ setSelectedEvent(null)
+ }, [selectedEvent, onEventUpdate])
+
+ const handleDeleteEvent = useCallback(
+ (id: string) => {
+ setEvents((prev) => prev.filter((e) => e.id !== id))
+ onEventDelete?.(id)
+ setIsDialogOpen(false)
+ setSelectedEvent(null)
+ },
+ [onEventDelete],
+ )
+
+ const handleDragStart = useCallback((event: Event) => {
+ setDraggedEvent(event)
+ }, [])
+
+ const handleDragEnd = useCallback(() => {
+ setDraggedEvent(null)
+ }, [])
+
+ const handleDrop = useCallback(
+ (date: Date, hour?: number) => {
+ if (!draggedEvent) return
+
+ const duration = draggedEvent.endTime.getTime() - draggedEvent.startTime.getTime()
+ const newStartTime = new Date(date)
+ if (hour !== undefined) {
+ newStartTime.setHours(hour, 0, 0, 0)
+ }
+ const newEndTime = new Date(newStartTime.getTime() + duration)
+
+ const updatedEvent = {
+ ...draggedEvent,
+ startTime: newStartTime,
+ endTime: newEndTime,
+ }
+
+ setEvents((prev) => prev.map((e) => (e.id === draggedEvent.id ? updatedEvent : e)))
+ onEventUpdate?.(draggedEvent.id, updatedEvent)
+ setDraggedEvent(null)
+ },
+ [draggedEvent, onEventUpdate],
+ )
+
+ const navigateDate = useCallback(
+ (direction: "prev" | "next") => {
+ setCurrentDate((prev) => {
+ const newDate = new Date(prev)
+ if (view === "month") {
+ newDate.setMonth(prev.getMonth() + (direction === "next" ? 1 : -1))
+ } else if (view === "week") {
+ newDate.setDate(prev.getDate() + (direction === "next" ? 7 : -7))
+ } else if (view === "day") {
+ newDate.setDate(prev.getDate() + (direction === "next" ? 1 : -1))
+ }
+ return newDate
+ })
+ },
+ [view],
+ )
+
+ const getColorClasses = useCallback(
+ (colorValue: string) => {
+ const color = colors.find((c) => c.value === colorValue)
+ return color || colors[0]
+ },
+ [colors],
+ )
+
+ const toggleTag = (tag: string, isCreating: boolean) => {
+ if (isCreating) {
+ setNewEvent((prev) => ({
+ ...prev,
+ tags: prev.tags?.includes(tag) ? prev.tags.filter((t) => t !== tag) : [...(prev.tags || []), tag],
+ }))
+ } else {
+ setSelectedEvent((prev) =>
+ prev
+ ? {
+ ...prev,
+ tags: prev.tags?.includes(tag) ? prev.tags.filter((t) => t !== tag) : [...(prev.tags || []), tag],
+ }
+ : null,
+ )
+ }
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ {view === "month" &&
+ currentDate.toLocaleDateString("en-US", {
+ month: "long",
+ year: "numeric",
+ })}
+ {view === "week" &&
+ `Week of ${currentDate.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })}`}
+ {view === "day" &&
+ currentDate.toLocaleDateString("en-US", {
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ })}
+ {view === "list" && "All Events"}
+
+
+ navigateDate("prev")} className="h-8 w-8">
+
+
+ setCurrentDate(new Date())}>
+ Today
+
+ navigateDate("next")} className="h-8 w-8">
+
+
+
+
+
+
+ {/* Mobile: Select dropdown */}
+
+
setView(value)}>
+
+
+
+
+
+
+
+ Month View
+
+
+
+
+
+ Week View
+
+
+
+
+
+ Day View
+
+
+
+
+
+ List View
+
+
+
+
+
+
+ {/* Desktop: Button group */}
+
+ setView("month")}
+ className="h-8"
+ >
+
+ Month
+
+ setView("week")}
+ className="h-8"
+ >
+
+ Week
+
+ setView("day")}
+ className="h-8"
+ >
+
+ Day
+
+ setView("list")}
+ className="h-8"
+ >
+
+ List
+
+
+
+
{
+ setIsCreating(true)
+ setIsDialogOpen(true)
+ }}
+ className="w-full sm:w-auto"
+ >
+
+ New Event
+
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-9"
+ />
+ {searchQuery && (
+ setSearchQuery("")}
+ >
+
+
+ )}
+
+
+ {/* Mobile: Horizontal scroll with full-length buttons */}
+
+
+ {/* Color Filter */}
+
+
+
+
+ Colors
+ {selectedColors.length > 0 && (
+
+ {selectedColors.length}
+
+ )}
+
+
+
+ Filter by Color
+
+ {colors.map((color) => (
+ {
+ setSelectedColors((prev) =>
+ checked ? [...prev, color.value] : prev.filter((c) => c !== color.value),
+ )
+ }}
+ >
+
+
+ ))}
+
+
+
+ {/* Tag Filter */}
+
+
+
+
+ Tags
+ {selectedTags.length > 0 && (
+
+ {selectedTags.length}
+
+ )}
+
+
+
+ Filter by Tag
+
+ {availableTags.map((tag) => (
+ {
+ setSelectedTags((prev) => (checked ? [...prev, tag] : prev.filter((t) => t !== tag)))
+ }}
+ >
+ {tag}
+
+ ))}
+
+
+
+ {/* Category Filter */}
+
+
+
+
+ Categories
+ {selectedCategories.length > 0 && (
+
+ {selectedCategories.length}
+
+ )}
+
+
+
+ Filter by Category
+
+ {categories.map((category) => (
+ {
+ setSelectedCategories((prev) =>
+ checked ? [...prev, category] : prev.filter((c) => c !== category),
+ )
+ }}
+ >
+ {category}
+
+ ))}
+
+
+
+ {hasActiveFilters && (
+
+
+ Clear Filters
+
+ )}
+
+
+
+ {/* Desktop: Original layout */}
+
+ {/* Color Filter */}
+
+
+
+
+ Colors
+ {selectedColors.length > 0 && (
+
+ {selectedColors.length}
+
+ )}
+
+
+
+ Filter by Color
+
+ {colors.map((color) => (
+ {
+ setSelectedColors((prev) =>
+ checked ? [...prev, color.value] : prev.filter((c) => c !== color.value),
+ )
+ }}
+ >
+
+
+ ))}
+
+
+
+ {/* Tag Filter */}
+
+
+
+
+ Tags
+ {selectedTags.length > 0 && (
+
+ {selectedTags.length}
+
+ )}
+
+
+
+ Filter by Tag
+
+ {availableTags.map((tag) => (
+ {
+ setSelectedTags((prev) => (checked ? [...prev, tag] : prev.filter((t) => t !== tag)))
+ }}
+ >
+ {tag}
+
+ ))}
+
+
+
+ {/* Category Filter */}
+
+
+
+
+ Categories
+ {selectedCategories.length > 0 && (
+
+ {selectedCategories.length}
+
+ )}
+
+
+
+ Filter by Category
+
+ {categories.map((category) => (
+ {
+ setSelectedCategories((prev) =>
+ checked ? [...prev, category] : prev.filter((c) => c !== category),
+ )
+ }}
+ >
+ {category}
+
+ ))}
+
+
+
+ {hasActiveFilters && (
+
+
+ Clear
+
+ )}
+
+
+
+ {hasActiveFilters && (
+
+
Active filters:
+ {selectedColors.map((colorValue) => {
+ const color = getColorClasses(colorValue)
+ return (
+
+
+ {color.name}
+ setSelectedColors((prev) => prev.filter((c) => c !== colorValue))}
+ className="ml-1 hover:text-foreground"
+ >
+
+
+
+ )
+ })}
+ {selectedTags.map((tag) => (
+
+ {tag}
+ setSelectedTags((prev) => prev.filter((t) => t !== tag))}
+ className="ml-1 hover:text-foreground"
+ >
+
+
+
+ ))}
+ {selectedCategories.map((category) => (
+
+ {category}
+ setSelectedCategories((prev) => prev.filter((c) => c !== category))}
+ className="ml-1 hover:text-foreground"
+ >
+
+
+
+ ))}
+
+ )}
+
+ {/* Calendar Views - Pass filteredEvents instead of events */}
+ {view === "month" && (
+
{
+ setSelectedEvent(event)
+ setIsDialogOpen(true)
+ }}
+ onDragStart={handleDragStart}
+ onDragEnd={handleDragEnd}
+ onDrop={handleDrop}
+ getColorClasses={getColorClasses}
+ />
+ )}
+
+ {view === "week" && (
+ {
+ setSelectedEvent(event)
+ setIsDialogOpen(true)
+ }}
+ onDragStart={handleDragStart}
+ onDragEnd={handleDragEnd}
+ onDrop={handleDrop}
+ getColorClasses={getColorClasses}
+ />
+ )}
+
+ {view === "day" && (
+ {
+ setSelectedEvent(event)
+ setIsDialogOpen(true)
+ }}
+ onDragStart={handleDragStart}
+ onDragEnd={handleDragEnd}
+ onDrop={handleDrop}
+ getColorClasses={getColorClasses}
+ />
+ )}
+
+ {view === "list" && (
+ {
+ setSelectedEvent(event)
+ setIsDialogOpen(true)
+ }}
+ getColorClasses={getColorClasses}
+ />
+ )}
+
+ {/* Event Dialog */}
+
+
+
+ {isCreating ? "Create Event" : "Event Details"}
+
+ {isCreating ? "Add a new event to your calendar" : "View and edit event details"}
+
+
+
+
+
+ Title
+
+ isCreating
+ ? setNewEvent((prev) => ({ ...prev, title: e.target.value }))
+ : setSelectedEvent((prev) => (prev ? { ...prev, title: e.target.value } : null))
+ }
+ placeholder="Event title"
+ />
+
+
+
+ Description
+
+
+
+
+
+
+ Category
+
+ isCreating
+ ? setNewEvent((prev) => ({ ...prev, category: value }))
+ : setSelectedEvent((prev) => (prev ? { ...prev, category: value } : null))
+ }
+ >
+
+
+
+
+ {categories.map((cat) => (
+
+ {cat}
+
+ ))}
+
+
+
+
+
+
Color
+
+ isCreating
+ ? setNewEvent((prev) => ({ ...prev, color: value }))
+ : setSelectedEvent((prev) => (prev ? { ...prev, color: value } : null))
+ }
+ >
+
+
+
+
+ {colors.map((color) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
Tags
+
+ {availableTags.map((tag) => {
+ const isSelected = isCreating ? newEvent.tags?.includes(tag) : selectedEvent?.tags?.includes(tag)
+ return (
+ toggleTag(tag, isCreating)}
+ >
+ {tag}
+
+ )
+ })}
+
+
+
+
+
+ {!isCreating && (
+ selectedEvent && handleDeleteEvent(selectedEvent.id)}>
+ Delete
+
+ )}
+ {
+ setIsDialogOpen(false)
+ setIsCreating(false)
+ setSelectedEvent(null)
+ }}
+ >
+ Cancel
+
+
+ {isCreating ? "Create" : "Save"}
+
+
+
+
+
+ )
+}
+
+// EventCard component with hover effect
+function EventCard({
+ event,
+ onEventClick,
+ onDragStart,
+ onDragEnd,
+ getColorClasses,
+ variant = "default",
+}: {
+ event: Event
+ onEventClick: (event: Event) => void
+ onDragStart: (event: Event) => void
+ onDragEnd: () => void
+ getColorClasses: (color: string) => { bg: string; text: string }
+ variant?: "default" | "compact" | "detailed"
+}) {
+ const [isHovered, setIsHovered] = useState(false)
+ const colorClasses = getColorClasses(event.color)
+
+ const formatTime = (date: Date) => {
+ return date.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+ }
+
+ const getDuration = () => {
+ const diff = event.endTime.getTime() - event.startTime.getTime()
+ const hours = Math.floor(diff / (1000 * 60 * 60))
+ const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
+ if (hours > 0) {
+ return `${hours}h ${minutes}m`
+ }
+ return `${minutes}m`
+ }
+
+ if (variant === "compact") {
+ return (
+ onDragStart(event)}
+ onDragEnd={onDragEnd}
+ onClick={() => onEventClick(event)}
+ onMouseEnter={() => setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ className="relative cursor-pointer"
+ >
+
+ {event.title}
+
+ {isHovered && (
+
+
+
+
+ {event.description &&
{event.description}
}
+
+
+
+ {formatTime(event.startTime)} - {formatTime(event.endTime)}
+
+ ({getDuration()})
+
+
+ {event.category && (
+
+ {event.category}
+
+ )}
+ {event.tags?.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+ )}
+
+ )
+ }
+
+ if (variant === "detailed") {
+ return (
+ onDragStart(event)}
+ onDragEnd={onDragEnd}
+ onClick={() => onEventClick(event)}
+ onMouseEnter={() => setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ className={cn(
+ "cursor-pointer rounded-lg p-3 transition-all duration-300",
+ colorClasses.bg,
+ "text-white animate-in fade-in slide-in-from-left-2",
+ isHovered && "scale-[1.03] shadow-2xl ring-2 ring-white/50",
+ )}
+ >
+
{event.title}
+ {event.description &&
{event.description}
}
+
+
+ {formatTime(event.startTime)} - {formatTime(event.endTime)}
+
+ {isHovered && (
+
+ {event.category && (
+
+ {event.category}
+
+ )}
+ {event.tags?.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ )
+ }
+
+ return (
+ onDragStart(event)}
+ onDragEnd={onDragEnd}
+ onClick={() => onEventClick(event)}
+ onMouseEnter={() => setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ className="relative"
+ >
+
+ {isHovered && (
+
+
+
+
+ {event.description &&
{event.description}
}
+
+
+
+
+ {formatTime(event.startTime)} - {formatTime(event.endTime)}
+
+ ({getDuration()})
+
+
+ {event.category && (
+
+ {event.category}
+
+ )}
+ {event.tags?.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+
+ )}
+
+ )
+}
+
+// Month View Component
+function MonthView({
+ currentDate,
+ events,
+ onEventClick,
+ onDragStart,
+ onDragEnd,
+ onDrop,
+ getColorClasses,
+}: {
+ currentDate: Date
+ events: Event[]
+ onEventClick: (event: Event) => void
+ onDragStart: (event: Event) => void
+ onDragEnd: () => void
+ onDrop: (date: Date) => void
+ getColorClasses: (color: string) => { bg: string; text: string }
+}) {
+ const firstDayOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1)
+ const lastDayOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0)
+ const startDate = new Date(firstDayOfMonth)
+ startDate.setDate(startDate.getDate() - startDate.getDay())
+
+ const days = []
+ const currentDay = new Date(startDate)
+
+ for (let i = 0; i < 42; i++) {
+ days.push(new Date(currentDay))
+ currentDay.setDate(currentDay.getDate() + 1)
+ }
+
+ const getEventsForDay = (date: Date) => {
+ return events.filter((event) => {
+ const eventDate = new Date(event.startTime)
+ return (
+ eventDate.getDate() === date.getDate() &&
+ eventDate.getMonth() === date.getMonth() &&
+ eventDate.getFullYear() === date.getFullYear()
+ )
+ })
+ }
+
+ return (
+
+
+ {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => (
+
+ {day}
+ {day.charAt(0)}
+
+ ))}
+
+
+ {days.map((day, index) => {
+ const dayEvents = getEventsForDay(day)
+ const isCurrentMonth = day.getMonth() === currentDate.getMonth()
+ const isToday = day.toDateString() === new Date().toDateString()
+
+ return (
+
e.preventDefault()}
+ onDrop={() => onDrop(day)}
+ >
+
+ {day.getDate()}
+
+
+ {dayEvents.slice(0, 3).map((event) => (
+
+ ))}
+ {dayEvents.length > 3 && (
+
+{dayEvents.length - 3} more
+ )}
+
+
+ )
+ })}
+
+
+ )
+}
+
+// Week View Component
+function WeekView({
+ currentDate,
+ events,
+ onEventClick,
+ onDragStart,
+ onDragEnd,
+ onDrop,
+ getColorClasses,
+}: {
+ currentDate: Date
+ events: Event[]
+ onEventClick: (event: Event) => void
+ onDragStart: (event: Event) => void
+ onDragEnd: () => void
+ onDrop: (date: Date, hour: number) => void
+ getColorClasses: (color: string) => { bg: string; text: string }
+}) {
+ const startOfWeek = new Date(currentDate)
+ startOfWeek.setDate(currentDate.getDay())
+
+ const weekDays = Array.from({ length: 7 }, (_, i) => {
+ const day = new Date(startOfWeek)
+ day.setDate(startOfWeek.getDate() + i)
+ return day
+ })
+
+ const hours = Array.from({ length: 24 }, (_, i) => i)
+
+ const getEventsForDayAndHour = (date: Date, hour: number) => {
+ return events.filter((event) => {
+ const eventDate = new Date(event.startTime)
+ const eventHour = eventDate.getHours()
+ return (
+ eventDate.getDate() === date.getDate() &&
+ eventDate.getMonth() === date.getMonth() &&
+ eventDate.getFullYear() === date.getFullYear() &&
+ eventHour === hour
+ )
+ })
+ }
+
+ return (
+
+
+
Time
+ {weekDays.map((day) => (
+
+
{day.toLocaleDateString("en-US", { weekday: "short" })}
+
{day.toLocaleDateString("en-US", { weekday: "narrow" })}
+
+ {day.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
+
+
+ ))}
+
+
+ {hours.map((hour) => (
+ <>
+
+ {hour.toString().padStart(2, "0")}:00
+
+ {weekDays.map((day) => {
+ const dayEvents = getEventsForDayAndHour(day, hour)
+ return (
+
e.preventDefault()}
+ onDrop={() => onDrop(day, hour)}
+ >
+
+ {dayEvents.map((event) => (
+
+ ))}
+
+
+ )
+ })}
+ >
+ ))}
+
+
+ )
+}
+
+// Day View Component
+function DayView({
+ currentDate,
+ events,
+ onEventClick,
+ onDragStart,
+ onDragEnd,
+ onDrop,
+ getColorClasses,
+}: {
+ currentDate: Date
+ events: Event[]
+ onEventClick: (event: Event) => void
+ onDragStart: (event: Event) => void
+ onDragEnd: () => void
+ onDrop: (date: Date, hour: number) => void
+ getColorClasses: (color: string) => { bg: string; text: string }
+}) {
+ const hours = Array.from({ length: 24 }, (_, i) => i)
+
+ const getEventsForHour = (hour: number) => {
+ return events.filter((event) => {
+ const eventDate = new Date(event.startTime)
+ const eventHour = eventDate.getHours()
+ return (
+ eventDate.getDate() === currentDate.getDate() &&
+ eventDate.getMonth() === currentDate.getMonth() &&
+ eventDate.getFullYear() === currentDate.getFullYear() &&
+ eventHour === hour
+ )
+ })
+ }
+
+ return (
+
+
+ {hours.map((hour) => {
+ const hourEvents = getEventsForHour(hour)
+ return (
+
e.preventDefault()}
+ onDrop={() => onDrop(currentDate, hour)}
+ >
+
+ {hour.toString().padStart(2, "0")}:00
+
+
+
+ {hourEvents.map((event) => (
+
+ ))}
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+// List View Component
+function ListView({
+ events,
+ onEventClick,
+ getColorClasses,
+}: {
+ events: Event[]
+ onEventClick: (event: Event) => void
+ getColorClasses: (color: string) => { bg: string; text: string }
+}) {
+ const sortedEvents = [...events].sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
+
+ const groupedEvents = sortedEvents.reduce(
+ (acc, event) => {
+ const dateKey = event.startTime.toLocaleDateString("en-US", {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })
+ if (!acc[dateKey]) {
+ acc[dateKey] = []
+ }
+ acc[dateKey].push(event)
+ return acc
+ },
+ {} as Record,
+ )
+
+ return (
+
+
+ {Object.entries(groupedEvents).map(([date, dateEvents]) => (
+
+
{date}
+
+ {dateEvents.map((event) => {
+ const colorClasses = getColorClasses(event.color)
+ return (
+
onEventClick(event)}
+ className="group cursor-pointer rounded-lg border bg-card p-3 transition-all hover:shadow-md hover:scale-[1.01] animate-in fade-in slide-in-from-bottom-2 duration-300 sm:p-4"
+ >
+
+
+
+
+
+
+ {event.title}
+
+ {event.description && (
+
+ {event.description}
+
+ )}
+
+
+ {event.category && (
+
+ {event.category}
+
+ )}
+
+
+
+
+
+ {event.startTime.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}{" "}
+ -{" "}
+ {event.endTime.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+ {event.tags && event.tags.length > 0 && (
+
+ {event.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+
+
+ )
+ })}
+
+
+ ))}
+ {sortedEvents.length === 0 && (
+
No events found
+ )}
+
+
+ )
+}
+
+export default EventManager;
diff --git a/susconecta/components/credentials-dialog.tsx b/susconecta/components/features/general/credentials-dialog.tsx
similarity index 100%
rename from susconecta/components/credentials-dialog.tsx
rename to susconecta/components/features/general/credentials-dialog.tsx
diff --git a/susconecta/components/features/general/event-manager.tsx b/susconecta/components/features/general/event-manager.tsx
new file mode 100644
index 0000000..1a19417
--- /dev/null
+++ b/susconecta/components/features/general/event-manager.tsx
@@ -0,0 +1,1485 @@
+"use client"
+
+import React, { useState, useCallback, useMemo } from "react"
+import { Button } from "@/components/ui/button"
+import { Card } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { ChevronLeft, ChevronRight, Plus, Calendar, Clock, Grid3x3, List, Search, Filter, X } from "lucide-react"
+import { cn } from "@/lib/utils"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuCheckboxItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+export interface Event {
+ id: string
+ title: string
+ description?: string
+ startTime: Date
+ endTime: Date
+ color: string
+ category?: string
+ attendees?: string[]
+ tags?: string[]
+}
+
+export interface EventManagerProps {
+ events?: Event[]
+ onEventCreate?: (event: Omit) => void
+ onEventUpdate?: (id: string, event: Partial) => void
+ onEventDelete?: (id: string) => void
+ categories?: string[]
+ colors?: { name: string; value: string; bg: string; text: string }[]
+ defaultView?: "month" | "week" | "day" | "list"
+ className?: string
+ availableTags?: string[]
+}
+
+const defaultColors = [
+ { name: "Blue", value: "blue", bg: "bg-blue-500", text: "text-blue-700" },
+ { name: "Green", value: "green", bg: "bg-green-500", text: "text-green-700" },
+ { name: "Purple", value: "purple", bg: "bg-purple-500", text: "text-purple-700" },
+ { name: "Orange", value: "orange", bg: "bg-orange-500", text: "text-orange-700" },
+ { name: "Pink", value: "pink", bg: "bg-pink-500", text: "text-pink-700" },
+ { name: "Red", value: "red", bg: "bg-red-500", text: "text-red-700" },
+]
+
+export function EventManager({
+ events: initialEvents = [],
+ onEventCreate,
+ onEventUpdate,
+ onEventDelete,
+ categories = ["Meeting", "Task", "Reminder", "Personal"],
+ colors = defaultColors,
+ defaultView = "month",
+ className,
+ availableTags = ["Important", "Urgent", "Work", "Personal", "Team", "Client"],
+}: EventManagerProps) {
+ const [events, setEvents] = useState(initialEvents)
+ const [currentDate, setCurrentDate] = useState(new Date())
+ const [view, setView] = useState<"month" | "week" | "day" | "list">(defaultView)
+ const [selectedEvent, setSelectedEvent] = useState(null)
+ const [isDialogOpen, setIsDialogOpen] = useState(false)
+ const [isCreating, setIsCreating] = useState(false)
+ const [draggedEvent, setDraggedEvent] = useState(null)
+ const [newEvent, setNewEvent] = useState>({
+ title: "",
+ description: "",
+ color: colors[0].value,
+ category: categories[0],
+ tags: [],
+ })
+
+ const [searchQuery, setSearchQuery] = useState("")
+ const [selectedColors, setSelectedColors] = useState([])
+ const [selectedTags, setSelectedTags] = useState([])
+ const [selectedCategories, setSelectedCategories] = useState([])
+
+ const filteredEvents = useMemo(() => {
+ return events.filter((event) => {
+ // Search filter
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase()
+ const matchesSearch =
+ event.title.toLowerCase().includes(query) ||
+ event.description?.toLowerCase().includes(query) ||
+ event.category?.toLowerCase().includes(query) ||
+ event.tags?.some((tag) => tag.toLowerCase().includes(query))
+
+ if (!matchesSearch) return false
+ }
+
+ // Color filter
+ if (selectedColors.length > 0 && !selectedColors.includes(event.color)) {
+ return false
+ }
+
+ // Tag filter
+ if (selectedTags.length > 0) {
+ const hasMatchingTag = event.tags?.some((tag) => selectedTags.includes(tag))
+ if (!hasMatchingTag) return false
+ }
+
+ // Category filter
+ if (selectedCategories.length > 0 && event.category && !selectedCategories.includes(event.category)) {
+ return false
+ }
+
+ return true
+ })
+ }, [events, searchQuery, selectedColors, selectedTags, selectedCategories])
+
+ const hasActiveFilters = selectedColors.length > 0 || selectedTags.length > 0 || selectedCategories.length > 0
+
+ const clearFilters = () => {
+ setSelectedColors([])
+ setSelectedTags([])
+ setSelectedCategories([])
+ setSearchQuery("")
+ }
+
+ const handleCreateEvent = useCallback(() => {
+ if (!newEvent.title || !newEvent.startTime || !newEvent.endTime) return
+
+ const event: Event = {
+ id: Math.random().toString(36).substr(2, 9),
+ title: newEvent.title,
+ description: newEvent.description,
+ startTime: newEvent.startTime,
+ endTime: newEvent.endTime,
+ color: newEvent.color || colors[0].value,
+ category: newEvent.category,
+ attendees: newEvent.attendees,
+ tags: newEvent.tags || [],
+ }
+
+ setEvents((prev) => [...prev, event])
+ onEventCreate?.(event)
+ setIsDialogOpen(false)
+ setIsCreating(false)
+ setNewEvent({
+ title: "",
+ description: "",
+ color: colors[0].value,
+ category: categories[0],
+ tags: [],
+ })
+ }, [newEvent, colors, categories, onEventCreate])
+
+ const handleUpdateEvent = useCallback(() => {
+ if (!selectedEvent) return
+
+ setEvents((prev) => prev.map((e) => (e.id === selectedEvent.id ? selectedEvent : e)))
+ onEventUpdate?.(selectedEvent.id, selectedEvent)
+ setIsDialogOpen(false)
+ setSelectedEvent(null)
+ }, [selectedEvent, onEventUpdate])
+
+ const handleDeleteEvent = useCallback(
+ (id: string) => {
+ setEvents((prev) => prev.filter((e) => e.id !== id))
+ onEventDelete?.(id)
+ setIsDialogOpen(false)
+ setSelectedEvent(null)
+ },
+ [onEventDelete],
+ )
+
+ const handleDragStart = useCallback((event: Event) => {
+ setDraggedEvent(event)
+ }, [])
+
+ const handleDragEnd = useCallback(() => {
+ setDraggedEvent(null)
+ }, [])
+
+ const handleDrop = useCallback(
+ (date: Date, hour?: number) => {
+ if (!draggedEvent) return
+
+ const duration = draggedEvent.endTime.getTime() - draggedEvent.startTime.getTime()
+ const newStartTime = new Date(date)
+ if (hour !== undefined) {
+ newStartTime.setHours(hour, 0, 0, 0)
+ }
+ const newEndTime = new Date(newStartTime.getTime() + duration)
+
+ const updatedEvent = {
+ ...draggedEvent,
+ startTime: newStartTime,
+ endTime: newEndTime,
+ }
+
+ setEvents((prev) => prev.map((e) => (e.id === draggedEvent.id ? updatedEvent : e)))
+ onEventUpdate?.(draggedEvent.id, updatedEvent)
+ setDraggedEvent(null)
+ },
+ [draggedEvent, onEventUpdate],
+ )
+
+ const navigateDate = useCallback(
+ (direction: "prev" | "next") => {
+ setCurrentDate((prev) => {
+ const newDate = new Date(prev)
+ if (view === "month") {
+ newDate.setMonth(prev.getMonth() + (direction === "next" ? 1 : -1))
+ } else if (view === "week") {
+ newDate.setDate(prev.getDate() + (direction === "next" ? 7 : -7))
+ } else if (view === "day") {
+ newDate.setDate(prev.getDate() + (direction === "next" ? 1 : -1))
+ }
+ return newDate
+ })
+ },
+ [view],
+ )
+
+ const getColorClasses = useCallback(
+ (colorValue: string) => {
+ const color = colors.find((c) => c.value === colorValue)
+ return color || colors[0]
+ },
+ [colors],
+ )
+
+ const toggleTag = (tag: string, isCreating: boolean) => {
+ if (isCreating) {
+ setNewEvent((prev) => ({
+ ...prev,
+ tags: prev.tags?.includes(tag) ? prev.tags.filter((t) => t !== tag) : [...(prev.tags || []), tag],
+ }))
+ } else {
+ setSelectedEvent((prev) =>
+ prev
+ ? {
+ ...prev,
+ tags: prev.tags?.includes(tag) ? prev.tags.filter((t) => t !== tag) : [...(prev.tags || []), tag],
+ }
+ : null,
+ )
+ }
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ {view === "month" &&
+ currentDate.toLocaleDateString("pt-BR", {
+ month: "long",
+ year: "numeric",
+ })}
+ {view === "week" &&
+ `Semana de ${currentDate.toLocaleDateString("pt-BR", {
+ month: "short",
+ day: "numeric",
+ })}`}
+ {view === "day" &&
+ currentDate.toLocaleDateString("pt-BR", {
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ })}
+ {view === "list" && "Todos os eventos"}
+
+
+ navigateDate("prev")} className="h-8 w-8">
+
+
+ setCurrentDate(new Date())}>
+ Hoje
+
+ navigateDate("next")} className="h-8 w-8">
+
+
+
+
+
+
+ {/* Mobile: Select dropdown */}
+
+
setView(value)}>
+
+
+
+
+
+
+
+ Mês
+
+
+
+
+
+ Semana
+
+
+
+
+
+ Dia
+
+
+
+
+
+ Lista
+
+
+
+
+
+
+ {/* Desktop: Button group */}
+
+ setView("month")}
+ className="h-8"
+ >
+
+ Mês
+
+ setView("week")}
+ className="h-8"
+ >
+
+ Semana
+
+ setView("day")}
+ className="h-8"
+ >
+
+ Dia
+
+ setView("list")}
+ className="h-8"
+ >
+
+ Lista
+
+
+
+
{
+ setIsCreating(true)
+ setIsDialogOpen(true)
+ }}
+ className="w-full sm:w-auto"
+ >
+
+ Novo Evento
+
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-9"
+ />
+ {searchQuery && (
+ setSearchQuery("")}
+ >
+
+
+ )}
+
+
+ {/* Mobile: Horizontal scroll with full-length buttons */}
+
+
+ {/* Color Filter */}
+
+
+
+
+ Cores
+ {selectedColors.length > 0 && (
+
+ {selectedColors.length}
+
+ )}
+
+
+
+ Filtrar por Cor
+
+ {colors.map((color) => (
+ {
+ setSelectedColors((prev) =>
+ checked ? [...prev, color.value] : prev.filter((c) => c !== color.value),
+ )
+ }}
+ >
+
+
+ ))}
+
+
+
+ {/* Tag Filter */}
+
+
+
+
+ Tags
+ {selectedTags.length > 0 && (
+
+ {selectedTags.length}
+
+ )}
+
+
+
+ Filtrar por Tag
+
+ {availableTags.map((tag) => (
+ {
+ setSelectedTags((prev) => (checked ? [...prev, tag] : prev.filter((t) => t !== tag)))
+ }}
+ >
+ {tag}
+
+ ))}
+
+
+
+ {/* Category Filter */}
+
+
+
+
+ Categorias
+ {selectedCategories.length > 0 && (
+
+ {selectedCategories.length}
+
+ )}
+
+
+
+ Filtrar por Categoria
+
+ {categories.map((category) => (
+ {
+ setSelectedCategories((prev) =>
+ checked ? [...prev, category] : prev.filter((c) => c !== category),
+ )
+ }}
+ >
+ {category}
+
+ ))}
+
+
+
+ {hasActiveFilters && (
+
+
+ Limpar Filtros
+
+ )}
+
+
+
+ {/* Desktop: Original layout */}
+
+ {/* Color Filter */}
+
+
+
+
+ Cores
+ {selectedColors.length > 0 && (
+
+ {selectedColors.length}
+
+ )}
+
+
+
+ Filtrar por Cor
+
+ {colors.map((color) => (
+ {
+ setSelectedColors((prev) =>
+ checked ? [...prev, color.value] : prev.filter((c) => c !== color.value),
+ )
+ }}
+ >
+
+
+ ))}
+
+
+
+ {/* Tag Filter */}
+
+
+
+
+ Tags
+ {selectedTags.length > 0 && (
+
+ {selectedTags.length}
+
+ )}
+
+
+
+ Filtrar por Tag
+
+ {availableTags.map((tag) => (
+ {
+ setSelectedTags((prev) => (checked ? [...prev, tag] : prev.filter((t) => t !== tag)))
+ }}
+ >
+ {tag}
+
+ ))}
+
+
+
+ {/* Category Filter */}
+
+
+
+
+ Categorias
+ {selectedCategories.length > 0 && (
+
+ {selectedCategories.length}
+
+ )}
+
+
+
+ Filtrar por Categoria
+
+ {categories.map((category) => (
+ {
+ setSelectedCategories((prev) =>
+ checked ? [...prev, category] : prev.filter((c) => c !== category),
+ )
+ }}
+ >
+ {category}
+
+ ))}
+
+
+
+ {hasActiveFilters && (
+
+
+ Limpar
+
+ )}
+
+
+
+ {hasActiveFilters && (
+
+
Filtros ativos:
+ {selectedColors.map((colorValue) => {
+ const color = getColorClasses(colorValue)
+ return (
+
+
+ {color.name}
+ setSelectedColors((prev) => prev.filter((c) => c !== colorValue))}
+ className="ml-1 hover:text-foreground"
+ >
+
+
+
+ )
+ })}
+ {selectedTags.map((tag) => (
+
+ {tag}
+ setSelectedTags((prev) => prev.filter((t) => t !== tag))}
+ className="ml-1 hover:text-foreground"
+ >
+
+
+
+ ))}
+ {selectedCategories.map((category) => (
+
+ {category}
+ setSelectedCategories((prev) => prev.filter((c) => c !== category))}
+ className="ml-1 hover:text-foreground"
+ >
+
+
+
+ ))}
+
+ )}
+
+ {/* Calendar Views - Pass filteredEvents instead of events */}
+ {view === "month" && (
+
{
+ setSelectedEvent(event)
+ setIsDialogOpen(true)
+ }}
+ onDragStart={(event) => handleDragStart(event)}
+ onDragEnd={() => handleDragEnd()}
+ onDrop={handleDrop}
+ getColorClasses={getColorClasses}
+ />
+ )}
+
+ {view === "week" && (
+ {
+ setSelectedEvent(event)
+ setIsDialogOpen(true)
+ }}
+ onDragStart={(event) => handleDragStart(event)}
+ onDragEnd={() => handleDragEnd()}
+ onDrop={handleDrop}
+ getColorClasses={getColorClasses}
+ />
+ )}
+
+ {view === "day" && (
+ {
+ setSelectedEvent(event)
+ setIsDialogOpen(true)
+ }}
+ onDragStart={(event) => handleDragStart(event)}
+ onDragEnd={() => handleDragEnd()}
+ onDrop={handleDrop}
+ getColorClasses={getColorClasses}
+ />
+ )}
+
+ {view === "list" && (
+ {
+ setSelectedEvent(event)
+ setIsDialogOpen(true)
+ }}
+ getColorClasses={getColorClasses}
+ />
+ )}
+
+ {/* Event Dialog */}
+
+
+
+ {isCreating ? "Criar Evento" : "Detalhes do Evento"}
+
+ {isCreating ? "Adicione um novo evento ao seu calendário" : "Visualizar e editar detalhes do evento"}
+
+
+
+
+
+ Título
+
+ isCreating
+ ? setNewEvent((prev) => ({ ...prev, title: e.target.value }))
+ : setSelectedEvent((prev) => (prev ? { ...prev, title: e.target.value } : null))
+ }
+ placeholder="Título do evento"
+ />
+
+
+
+ Descrição
+
+
+
+
+
+
+ Categoria
+
+ isCreating
+ ? setNewEvent((prev) => ({ ...prev, category: value }))
+ : setSelectedEvent((prev) => (prev ? { ...prev, category: value } : null))
+ }
+ >
+
+
+
+
+ {categories.map((cat) => (
+
+ {cat}
+
+ ))}
+
+
+
+
+
+
Cor
+
+ isCreating
+ ? setNewEvent((prev) => ({ ...prev, color: value }))
+ : setSelectedEvent((prev) => (prev ? { ...prev, color: value } : null))
+ }
+ >
+
+
+
+
+ {colors.map((color) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
Tags
+
+ {availableTags.map((tag) => {
+ const isSelected = isCreating ? newEvent.tags?.includes(tag) : selectedEvent?.tags?.includes(tag)
+ return (
+ toggleTag(tag, isCreating)}
+ >
+ {tag}
+
+ )
+ })}
+
+
+
+
+
+ {!isCreating && (
+ selectedEvent && handleDeleteEvent(selectedEvent.id)}>
+ Deletar
+
+ )}
+ {
+ setIsDialogOpen(false)
+ setIsCreating(false)
+ setSelectedEvent(null)
+ }}
+ >
+ Cancelar
+
+
+ {isCreating ? "Criar" : "Salvar"}
+
+
+
+
+
+ )
+}
+
+// EventCard component with hover effect
+function EventCard({
+ event,
+ onEventClick,
+ onDragStart,
+ onDragEnd,
+ getColorClasses,
+ variant = "default",
+}: {
+ event: Event
+ onEventClick: (event: Event) => void
+ onDragStart: (event: Event) => void
+ onDragEnd: () => void
+ getColorClasses: (color: string) => { bg: string; text: string }
+ variant?: "default" | "compact" | "detailed"
+}) {
+ const [isHovered, setIsHovered] = useState(false)
+ const colorClasses = getColorClasses(event.color)
+
+ const formatTime = (date: Date) => {
+ return date.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+ }
+
+ const getDuration = () => {
+ const diff = event.endTime.getTime() - event.startTime.getTime()
+ const hours = Math.floor(diff / (1000 * 60 * 60))
+ const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
+ if (hours > 0) {
+ return `${hours}h ${minutes}m`
+ }
+ return `${minutes}m`
+ }
+
+ if (variant === "compact") {
+ return (
+ onDragStart(event)}
+ onDragEnd={onDragEnd}
+ onClick={() => onEventClick(event)}
+ onMouseEnter={() => setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ className="relative cursor-pointer"
+ >
+
+ {event.title}
+
+ {isHovered && (
+
+
+
+
+ {event.description &&
{event.description}
}
+
+
+
+ {formatTime(event.startTime)} - {formatTime(event.endTime)}
+
+ ({getDuration()})
+
+
+ {event.category && (
+
+ {event.category}
+
+ )}
+ {event.tags?.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+ )}
+
+ )
+ }
+
+ if (variant === "detailed") {
+ return (
+ onDragStart(event)}
+ onDragEnd={onDragEnd}
+ onClick={() => onEventClick(event)}
+ onMouseEnter={() => setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ className={cn(
+ "cursor-pointer rounded-lg p-3 transition-all duration-300",
+ colorClasses.bg,
+ "text-white animate-in fade-in slide-in-from-left-2",
+ isHovered && "scale-[1.03] shadow-2xl ring-2 ring-white/50",
+ )}
+ >
+
{event.title}
+ {event.description &&
{event.description}
}
+
+
+ {formatTime(event.startTime)} - {formatTime(event.endTime)}
+
+ {isHovered && (
+
+ {event.category && (
+
+ {event.category}
+
+ )}
+ {event.tags?.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ )
+ }
+
+ return (
+ onDragStart(event)}
+ onDragEnd={onDragEnd}
+ onClick={() => onEventClick(event)}
+ onMouseEnter={() => setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ className="relative"
+ >
+
+ {isHovered && (
+
+
+
+
+ {event.description &&
{event.description}
}
+
+
+
+
+ {formatTime(event.startTime)} - {formatTime(event.endTime)}
+
+ ({getDuration()})
+
+
+ {event.category && (
+
+ {event.category}
+
+ )}
+ {event.tags?.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+
+ )}
+
+ )
+}
+
+// Month View Component
+function MonthView({
+ currentDate,
+ events,
+ onEventClick,
+ onDragStart,
+ onDragEnd,
+ onDrop,
+ getColorClasses,
+}: {
+ currentDate: Date
+ events: Event[]
+ onEventClick: (event: Event) => void
+ onDragStart: (event: Event) => void
+ onDragEnd: () => void
+ onDrop: (date: Date) => void
+ getColorClasses: (color: string) => { bg: string; text: string }
+}) {
+ const firstDayOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1)
+ const lastDayOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0)
+ const startDate = new Date(firstDayOfMonth)
+ startDate.setDate(startDate.getDate() - startDate.getDay())
+
+ const days = []
+ const currentDay = new Date(startDate)
+
+ for (let i = 0; i < 42; i++) {
+ days.push(new Date(currentDay))
+ currentDay.setDate(currentDay.getDate() + 1)
+ }
+
+ const getEventsForDay = (date: Date) => {
+ return events.filter((event) => {
+ const eventDate = new Date(event.startTime)
+ return (
+ eventDate.getDate() === date.getDate() &&
+ eventDate.getMonth() === date.getMonth() &&
+ eventDate.getFullYear() === date.getFullYear()
+ )
+ })
+ }
+
+ return (
+
+
+ {["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => (
+
+ {day}
+ {day.charAt(0)}
+
+ ))}
+
+
+ {days.map((day, index) => {
+ const dayEvents = getEventsForDay(day)
+ const isCurrentMonth = day.getMonth() === currentDate.getMonth()
+ const isToday = day.toDateString() === new Date().toDateString()
+
+ return (
+
e.preventDefault()}
+ onDrop={() => onDrop(day)}
+ >
+
+ {day.getDate()}
+
+
+ {dayEvents.slice(0, 3).map((event) => (
+
+ ))}
+ {dayEvents.length > 3 && (
+
+{dayEvents.length - 3} mais
+ )}
+
+
+ )
+ })}
+
+
+ )
+}
+
+// Week View Component
+function WeekView({
+ currentDate,
+ events,
+ onEventClick,
+ onDragStart,
+ onDragEnd,
+ onDrop,
+ getColorClasses,
+}: {
+ currentDate: Date
+ events: Event[]
+ onEventClick: (event: Event) => void
+ onDragStart: (event: Event) => void
+ onDragEnd: () => void
+ onDrop: (date: Date, hour: number) => void
+ getColorClasses: (color: string) => { bg: string; text: string }
+}) {
+ const startOfWeek = new Date(currentDate)
+ startOfWeek.setDate(currentDate.getDay())
+
+ const weekDays = Array.from({ length: 7 }, (_, i) => {
+ const day = new Date(startOfWeek)
+ day.setDate(startOfWeek.getDate() + i)
+ return day
+ })
+
+ const hours = Array.from({ length: 24 }, (_, i) => i)
+
+ const getEventsForDayAndHour = (date: Date, hour: number) => {
+ return events.filter((event) => {
+ const eventDate = new Date(event.startTime)
+ const eventHour = eventDate.getHours()
+ return (
+ eventDate.getDate() === date.getDate() &&
+ eventDate.getMonth() === date.getMonth() &&
+ eventDate.getFullYear() === date.getFullYear() &&
+ eventHour === hour
+ )
+ })
+ }
+
+ return (
+
+
+
Hora
+ {weekDays.map((day) => (
+
+
{day.toLocaleDateString("pt-BR", { weekday: "short" })}
+
{day.toLocaleDateString("pt-BR", { weekday: "narrow" })}
+
+ {day.toLocaleDateString("pt-BR", { month: "short", day: "numeric" })}
+
+
+ ))}
+
+
+ {hours.map((hour) => (
+ <>
+
+ {hour.toString().padStart(2, "0")}:00
+
+ {weekDays.map((day) => {
+ const dayEvents = getEventsForDayAndHour(day, hour)
+ return (
+
e.preventDefault()}
+ onDrop={() => onDrop(day, hour)}
+ >
+
+ {dayEvents.map((event) => (
+
+ ))}
+
+
+ )
+ })}
+ >
+ ))}
+
+
+ )
+}
+
+// Day View Component
+function DayView({
+ currentDate,
+ events,
+ onEventClick,
+ onDragStart,
+ onDragEnd,
+ onDrop,
+ getColorClasses,
+}: {
+ currentDate: Date
+ events: Event[]
+ onEventClick: (event: Event) => void
+ onDragStart: (event: Event) => void
+ onDragEnd: () => void
+ onDrop: (date: Date, hour: number) => void
+ getColorClasses: (color: string) => { bg: string; text: string }
+}) {
+ const hours = Array.from({ length: 24 }, (_, i) => i)
+
+ const getEventsForHour = (hour: number) => {
+ return events.filter((event) => {
+ const eventDate = new Date(event.startTime)
+ const eventHour = eventDate.getHours()
+ return (
+ eventDate.getDate() === currentDate.getDate() &&
+ eventDate.getMonth() === currentDate.getMonth() &&
+ eventDate.getFullYear() === currentDate.getFullYear() &&
+ eventHour === hour
+ )
+ })
+ }
+
+ return (
+
+
+ {hours.map((hour) => {
+ const hourEvents = getEventsForHour(hour)
+ return (
+
e.preventDefault()}
+ onDrop={() => onDrop(currentDate, hour)}
+ >
+
+ {hour.toString().padStart(2, "0")}:00
+
+
+
+ {hourEvents.map((event) => (
+
+ ))}
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+// List View Component
+function ListView({
+ events,
+ onEventClick,
+ getColorClasses,
+}: {
+ events: Event[]
+ onEventClick: (event: Event) => void
+ getColorClasses: (color: string) => { bg: string; text: string }
+}) {
+ const sortedEvents = [...events].sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
+
+ const groupedEvents = sortedEvents.reduce(
+ (acc, event) => {
+ const dateKey = event.startTime.toLocaleDateString("pt-BR", {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })
+ if (!acc[dateKey]) {
+ acc[dateKey] = []
+ }
+ acc[dateKey].push(event)
+ return acc
+ },
+ {} as Record,
+ )
+
+ return (
+
+
+ {Object.entries(groupedEvents).map(([date, dateEvents]) => (
+
+
{date}
+
+ {dateEvents.map((event) => {
+ const colorClasses = getColorClasses(event.color)
+ return (
+
onEventClick(event)}
+ className="group cursor-pointer rounded-lg border bg-card p-3 transition-all hover:shadow-md hover:scale-[1.01] animate-in fade-in slide-in-from-bottom-2 duration-300 sm:p-4"
+ >
+
+
+
+
+
+
+ {event.title}
+
+ {event.description && (
+
+ {event.description}
+
+ )}
+
+
+ {event.category && (
+
+ {event.category}
+
+ )}
+
+
+
+
+
+ {event.startTime.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" })} - {event.endTime.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" })}
+
+ {event.tags && event.tags.length > 0 && (
+
+ {event.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+
+
+ )
+ })}
+
+
+ ))}
+ {sortedEvents.length === 0 && (
+
Nenhum evento encontrado
+ )}
+
+
+ )
+}
diff --git a/susconecta/components/hero-section.tsx b/susconecta/components/features/general/hero-section.tsx
similarity index 100%
rename from susconecta/components/hero-section.tsx
rename to susconecta/components/features/general/hero-section.tsx
diff --git a/susconecta/components/footer.tsx b/susconecta/components/layout/footer.tsx
similarity index 99%
rename from susconecta/components/footer.tsx
rename to susconecta/components/layout/footer.tsx
index 143206b..6f40380 100644
--- a/susconecta/components/footer.tsx
+++ b/susconecta/components/layout/footer.tsx
@@ -1,3 +1,5 @@
+
+
"use client"
import { ChevronUp } from "lucide-react"
diff --git a/susconecta/components/header.tsx b/susconecta/components/layout/header.tsx
similarity index 98%
rename from susconecta/components/header.tsx
rename to susconecta/components/layout/header.tsx
index 799eb78..e3f7ffc 100644
--- a/susconecta/components/header.tsx
+++ b/susconecta/components/layout/header.tsx
@@ -5,7 +5,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Menu, X } from "lucide-react";
import { usePathname } from "next/navigation";
-import { SimpleThemeToggle } from "@/components/simple-theme-toggle";
+import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
export function Header() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
diff --git a/susconecta/components/dashboard/sidebar.tsx b/susconecta/components/layout/sidebar.tsx
similarity index 100%
rename from susconecta/components/dashboard/sidebar.tsx
rename to susconecta/components/layout/sidebar.tsx
diff --git a/susconecta/components/theme-provider.tsx b/susconecta/components/providers/theme-provider.tsx
similarity index 100%
rename from susconecta/components/theme-provider.tsx
rename to susconecta/components/providers/theme-provider.tsx
diff --git a/susconecta/components/ProtectedRoute.tsx b/susconecta/components/shared/ProtectedRoute.tsx
similarity index 100%
rename from susconecta/components/ProtectedRoute.tsx
rename to susconecta/components/shared/ProtectedRoute.tsx
diff --git a/susconecta/components/ui/chart.tsx b/susconecta/components/ui/chart.tsx
index 97cc280..8e33ca1 100644
--- a/susconecta/components/ui/chart.tsx
+++ b/susconecta/components/ui/chart.tsx
@@ -34,18 +34,15 @@ function useChart() {
return context
}
-function ChartContainer({
- id,
- className,
- children,
- config,
- ...props
-}: React.ComponentProps<"div"> & {
- config: ChartConfig
- children: React.ComponentProps<
- typeof RechartsPrimitive.ResponsiveContainer
- >["children"]
-}) {
+const ChartContainer = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ config: ChartConfig
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"]
+ }
+>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
@@ -55,9 +52,10 @@ function ChartContainer({
data-slot="chart"
data-chart={chartId}
className={cn(
- "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
+ "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-none",
className
)}
+ ref={ref}
{...props}
>
@@ -67,7 +65,8 @@ function ChartContainer({
)
-}
+})
+ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
@@ -104,28 +103,33 @@ ${colorConfig
const ChartTooltip = RechartsPrimitive.Tooltip
-function ChartTooltipContent({
- active,
- payload,
- className,
- indicator = "dot",
- hideLabel = false,
- hideIndicator = false,
- label,
- labelFormatter,
- labelClassName,
- formatter,
- color,
- nameKey,
- labelKey,
-}: React.ComponentProps
&
+const ChartTooltipContent = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
- }) {
+ payload?: any[]
+ label?: any
+ }
+>(({
+ active,
+ payload,
+ className,
+ indicator = "dot",
+ hideLabel = false,
+ hideIndicator = false,
+ label,
+ labelFormatter,
+ labelClassName,
+ formatter,
+ color,
+ nameKey,
+ labelKey
+}, ref) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
@@ -134,14 +138,14 @@ function ChartTooltipContent({
}
const [item] = payload
- const key = `${labelKey || item?.dataKey || item?.name || "value"}`
+ const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
- const value =
+ let value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
- if (labelFormatter) {
+ if (labelFormatter && value) {
return (
{labelFormatter(value, payload)}
@@ -172,27 +176,30 @@ function ChartTooltipContent({
return (
{!nestLabel ? tooltipLabel : null}
- {payload.map((item, index) => {
+ {payload.map((item: any, index: number) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
- const indicatorColor = color || item.payload.fill || item.color
+ const indicatorColor = color || item.color
return (
svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
+ "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
- {formatter && item?.value !== undefined && item.name ? (
+ {formatter &&
+ item.value !== undefined &&
+ item.name !== undefined ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
@@ -202,7 +209,7 @@ function ChartTooltipContent({
!hideIndicator && (
{item.value && (
-
+
{item.value.toLocaleString()}
)}
@@ -246,21 +253,20 @@ function ChartTooltipContent({
)
-}
+})
+ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
-function ChartLegendContent({
- className,
- hideIcon = false,
- payload,
- verticalAlign = "bottom",
- nameKey,
-}: React.ComponentProps<"div"> &
- Pick
& {
+const ChartLegendContent = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
hideIcon?: boolean
+ payload?: any[]
+ verticalAlign?: RechartsPrimitive.LegendProps["verticalAlign"]
nameKey?: string
- }) {
+ }
+>(({ className, hideIcon = false, payload, verticalAlign, nameKey }, ref) => {
const { config } = useChart()
if (!payload?.length) {
@@ -269,13 +275,14 @@ function ChartLegendContent({
return (
- {payload.map((item) => {
+ {payload.map((item: any) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
@@ -283,7 +290,7 @@ function ChartLegendContent({
svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
+ "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
@@ -302,12 +309,13 @@ function ChartLegendContent({
})}
)
-}
+})
+ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
- payload: unknown,
+ payload: any,
key: string
) {
if (typeof payload !== "object" || payload === null) {
diff --git a/susconecta/components/ui/sidebar.tsx b/susconecta/components/ui/sidebar.tsx
index 4409ae3..3b8e753 100644
--- a/susconecta/components/ui/sidebar.tsx
+++ b/susconecta/components/ui/sidebar.tsx
@@ -266,7 +266,7 @@ function SidebarTrigger({
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
- className={cn("size-7 hover:!bg-primary hover:!text-white transition-colors", className)}
+ className={cn("size-7 hover:bg-primary! hover:text-white! transition-colors", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
diff --git a/susconecta/components/simple-theme-toggle.tsx b/susconecta/components/ui/simple-theme-toggle.tsx
similarity index 78%
rename from susconecta/components/simple-theme-toggle.tsx
rename to susconecta/components/ui/simple-theme-toggle.tsx
index 3f96dcd..0c45ca0 100644
--- a/susconecta/components/simple-theme-toggle.tsx
+++ b/susconecta/components/ui/simple-theme-toggle.tsx
@@ -17,7 +17,7 @@ export function SimpleThemeToggle() {
variant="outline"
size="icon"
onClick={toggleTheme}
- className="hover:!bg-primary hover:!text-white hover:!border-primary cursor-pointer !shadow-sm !shadow-black/10 !border-2 !border-black dark:!shadow-none dark:!border-border transition-colors"
+ className="hover:bg-primary! hover:text-white! hover:border-primary! cursor-pointer shadow-sm! shadow-black/10! border-2! border-black! dark:shadow-none! dark:border-border! transition-colors"
>
diff --git a/susconecta/components/theme-toggle.tsx b/susconecta/components/ui/theme-toggle.tsx
similarity index 100%
rename from susconecta/components/theme-toggle.tsx
rename to susconecta/components/ui/theme-toggle.tsx
diff --git a/susconecta/components/ui/three-dwall-calendar.tsx b/susconecta/components/ui/three-dwall-calendar.tsx
new file mode 100644
index 0000000..35c23f3
--- /dev/null
+++ b/susconecta/components/ui/three-dwall-calendar.tsx
@@ -0,0 +1,457 @@
+"use client"
+
+import * as React from "react"
+import { Card, CardContent } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Trash2, Calendar, Clock, User } from "lucide-react"
+import { v4 as uuidv4 } from "uuid"
+import { startOfMonth, endOfMonth, eachDayOfInterval, format } from "date-fns"
+import { ptBR } from "date-fns/locale"
+
+export type CalendarEvent = {
+ id: string
+ title: string
+ date: string // ISO
+ status?: 'confirmed' | 'pending' | 'cancelled' | string
+ patient?: string
+ type?: string
+}
+
+interface ThreeDWallCalendarProps {
+ events: CalendarEvent[]
+ onAddEvent?: (e: CalendarEvent) => void
+ onRemoveEvent?: (id: string) => void
+ panelWidth?: number
+ panelHeight?: number
+ columns?: number
+}
+
+export function ThreeDWallCalendar({
+ events,
+ onAddEvent,
+ onRemoveEvent,
+ panelWidth = 160,
+ panelHeight = 120,
+ columns = 7,
+}: ThreeDWallCalendarProps) {
+ const [dateRef, setDateRef] = React.useState
(new Date())
+ const [title, setTitle] = React.useState("")
+ const [newDate, setNewDate] = React.useState("")
+ const [selectedDay, setSelectedDay] = React.useState(null)
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false)
+ const wallRef = React.useRef(null)
+
+ // 3D tilt state
+ const [tiltX, setTiltX] = React.useState(18)
+ const [tiltY, setTiltY] = React.useState(0)
+ const isDragging = React.useRef(false)
+ const dragStart = React.useRef<{ x: number; y: number } | null>(null)
+ const hasDragged = React.useRef(false)
+ const clickStart = React.useRef<{ x: number; y: number } | null>(null)
+
+ // month days
+ const days = eachDayOfInterval({
+ start: startOfMonth(dateRef),
+ end: endOfMonth(dateRef),
+ })
+
+ const eventsForDay = (d: Date) =>
+ events.filter((ev) => format(new Date(ev.date), "yyyy-MM-dd") === format(d, "yyyy-MM-dd"))
+
+ const selectedDayEvents = selectedDay ? eventsForDay(selectedDay) : []
+
+ const handleDayClick = (day: Date) => {
+ console.log('Day clicked:', format(day, 'dd/MM/yyyy'))
+ setSelectedDay(day)
+ setIsDialogOpen(true)
+ }
+
+ // Add event handler
+ const handleAdd = () => {
+ if (!title.trim() || !newDate) return
+ onAddEvent?.({
+ id: uuidv4(),
+ title: title.trim(),
+ date: new Date(newDate).toISOString(),
+ })
+ setTitle("")
+ setNewDate("")
+ }
+
+ // wheel tilt
+ const onWheel = (e: React.WheelEvent) => {
+ setTiltX((t) => Math.max(0, Math.min(50, t + e.deltaY * 0.02)))
+ setTiltY((t) => Math.max(-45, Math.min(45, t + e.deltaX * 0.05)))
+ }
+
+ // drag tilt
+ const onPointerDown = (e: React.PointerEvent) => {
+ isDragging.current = true
+ hasDragged.current = false
+ dragStart.current = { x: e.clientX, y: e.clientY }
+ ;(e.currentTarget as Element).setPointerCapture(e.pointerId)
+ }
+
+ const onPointerMove = (e: React.PointerEvent) => {
+ if (!isDragging.current || !dragStart.current) return
+ const dx = e.clientX - dragStart.current.x
+ const dy = e.clientY - dragStart.current.y
+
+ // Se moveu mais de 5 pixels, considera como drag
+ if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
+ hasDragged.current = true
+ }
+
+ setTiltY((t) => Math.max(-60, Math.min(60, t + dx * 0.1)))
+ setTiltX((t) => Math.max(0, Math.min(60, t - dy * 0.1)))
+ dragStart.current = { x: e.clientX, y: e.clientY }
+ }
+
+ const onPointerUp = () => {
+ isDragging.current = false
+ dragStart.current = null
+ // Reset hasDragged após um curto delay para permitir o clique ser processado
+ setTimeout(() => {
+ hasDragged.current = false
+ }, 100)
+ }
+
+ const gap = 12
+ const rowCount = Math.ceil(days.length / columns)
+ const wallCenterRow = (rowCount - 1) / 2
+
+ return (
+
+
+
+
setDateRef((d) => new Date(d.getFullYear(), d.getMonth() - 1, 1))}>
+ Mês Anterior
+
+
{format(dateRef, "MMMM yyyy", { locale: ptBR })}
+
setDateRef((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1))}>
+ Próximo Mês
+
+ {/* Botão Pacientes de hoje */}
+
{
+ setSelectedDay(new Date())
+ setIsDialogOpen(true)
+ }}
+ >
+ Pacientes de hoje
+
+
+
+ {/* Legenda de cores */}
+
+
+
+ {/* Wall container */}
+
+
+ 💡 Arraste para rotacionar • Scroll para inclinar
+
+
+
+
+ {days.map((day, idx) => {
+ const row = Math.floor(idx / columns)
+ const rowOffset = row - wallCenterRow
+ const z = Math.max(-80, 40 - Math.abs(rowOffset) * 20)
+ const dayEvents = eventsForDay(day)
+
+ return (
+
{
+ clickStart.current = { x: e.clientX, y: e.clientY }
+ }}
+ onPointerUp={(e) => {
+ if (clickStart.current) {
+ const dx = Math.abs(e.clientX - clickStart.current.x)
+ const dy = Math.abs(e.clientY - clickStart.current.y)
+ // Se moveu menos de 5 pixels, é um clique
+ if (dx < 5 && dy < 5) {
+ e.stopPropagation()
+ handleDayClick(day)
+ }
+ clickStart.current = null
+ }
+ }}
+ >
+
+
+
+
{format(day, "d")}
+
+ {dayEvents.length > 0 && `${dayEvents.length} ${dayEvents.length === 1 ? 'paciente' : 'pacientes'}`}
+
+
+ {format(day, "EEE", { locale: ptBR })}
+
+ {/* events */}
+
+ {dayEvents.map((ev, i) => {
+ // Calcular tamanho da bolinha baseado na quantidade de eventos
+ const eventCount = dayEvents.length
+ const ballSize = eventCount <= 3 ? 20 :
+ eventCount <= 6 ? 16 :
+ eventCount <= 10 ? 14 :
+ eventCount <= 15 ? 12 : 10
+
+ const spacing = ballSize + 4
+ const maxPerRow = Math.floor((panelWidth - 16) / spacing)
+ const col = i % maxPerRow
+ const row = Math.floor(i / maxPerRow)
+ const left = 4 + (col * spacing)
+ const top = 4 + (row * spacing)
+
+ // Cores baseadas no status
+ const getStatusColor = () => {
+ switch(ev.status) {
+ case 'confirmed': return 'bg-green-500 dark:bg-green-600'
+ case 'pending': return 'bg-yellow-500 dark:bg-yellow-600'
+ case 'cancelled': return 'bg-red-500 dark:bg-red-600'
+ default: return 'bg-blue-500 dark:bg-blue-600'
+ }
+ }
+
+ return (
+
+
+
+ •
+
+
+
+
+
{ev.title}
+ {ev.patient && ev.type && (
+
+
Paciente: {ev.patient}
+
Tipo: {ev.type}
+
+ )}
+
+ {format(new Date(ev.date), "PPP 'às' p", { locale: ptBR })}
+
+ {ev.status && (
+
+ Status: {' '}
+
+ {ev.status === 'confirmed' ? 'Confirmado' :
+ ev.status === 'pending' ? 'Pendente' :
+ ev.status === 'cancelled' ? 'Cancelado' : ev.status}
+
+
+ )}
+ {onRemoveEvent && (
+
onRemoveEvent(ev.id)}
+ >
+
+ Remover
+
+ )}
+
+
+
+ )
+ })}
+
+
+
+
+ )
+ })}
+
+
+
+
+
+ {/* Dialog de detalhes do dia */}
+
+
+
+ {/* Navegação de dias */}
+
+ setSelectedDay((prev) => prev ? new Date(prev.getFullYear(), prev.getMonth(), prev.getDate() - 1) : new Date())}
+ aria-label="Dia anterior"
+ >
+ ❮
+
+
+ {selectedDay && format(selectedDay, "dd 'de' MMMM 'de' yyyy", { locale: ptBR })}
+
+ setSelectedDay((prev) => prev ? new Date(prev.getFullYear(), prev.getMonth(), prev.getDate() + 1) : new Date())}
+ aria-label="Próximo dia"
+ >
+ ❯
+
+
+
+ {selectedDayEvents.length} {selectedDayEvents.length === 1 ? 'paciente agendado' : 'pacientes agendados'}
+
+
+
+ {selectedDayEvents.length === 0 ? (
+
+ Nenhum paciente agendado para este dia
+
+ ) : (
+ selectedDayEvents.map((ev) => {
+ const getStatusColor = () => {
+ switch(ev.status) {
+ case 'confirmed': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
+ case 'pending': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
+ case 'cancelled': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
+ default: return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
+ }
+ }
+
+ const getStatusText = () => {
+ switch(ev.status) {
+ case 'confirmed': return 'Confirmado'
+ case 'pending': return 'Pendente'
+ case 'cancelled': return 'Cancelado'
+ default: return ev.status || 'Sem status'
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
{ev.patient || ev.title}
+
+
+ {ev.type && (
+
+
+ {ev.type}
+
+ )}
+
+
+
+ {format(new Date(ev.date), "HH:mm", { locale: ptBR })}
+
+
+
+ {getStatusText()}
+
+
+
+ {onRemoveEvent && (
+
{
+ e.stopPropagation()
+ onRemoveEvent(ev.id)
+ }}
+ >
+
+
+ )}
+
+
+
+ )
+ })
+ )}
+
+
+
+
+ {/* Add event form */}
+
+ setTitle(e.target.value)} />
+ setNewDate(e.target.value)} />
+ Adicionar Paciente
+
+
+ )
+}
diff --git a/susconecta/lib/api.ts b/susconecta/lib/api.ts
index 9ecf796..2b1cfd7 100644
--- a/susconecta/lib/api.ts
+++ b/susconecta/lib/api.ts
@@ -238,7 +238,7 @@ export async function criarDisponibilidade(input: DoctorAvailabilityCreate): Pro
// Normalize weekday to integer expected by the OpenAPI (0=Sunday .. 6=Saturday)
const mapWeekdayToInt = (w?: string | number): number | null => {
- if (w === null || typeof w === 'undefined') return null;
+ if (w === null || w === undefined) return null;
if (typeof w === 'number') return Number(w);
const s = String(w).toLowerCase().trim();
const map: Record = {
@@ -270,7 +270,7 @@ export async function criarDisponibilidade(input: DoctorAvailabilityCreate): Pro
end_time: input.end_time,
slot_minutes: input.slot_minutes ?? 30,
appointment_type: input.appointment_type ?? 'presencial',
- active: typeof input.active === 'undefined' ? true : input.active,
+ active: input.active === undefined ? true : input.active,
created_by: createdBy,
};
@@ -307,7 +307,7 @@ export async function criarDisponibilidade(input: DoctorAvailabilityCreate): Pro
end_time: end,
slot_minutes: input.slot_minutes ?? 30,
appointment_type: input.appointment_type ?? 'presencial',
- active: typeof input.active === 'undefined' ? true : input.active,
+ active: input.active === undefined ? true : input.active,
created_by: createdBy,
};
@@ -349,7 +349,7 @@ export async function criarDisponibilidade(input: DoctorAvailabilityCreate): Pro
end_time: end,
slot_minutes: input.slot_minutes ?? 30,
appointment_type: input.appointment_type ?? 'presencial',
- active: typeof input.active === 'undefined' ? true : input.active,
+ active: input.active === undefined ? true : input.active,
created_by: createdBy,
};
try {
@@ -381,7 +381,7 @@ export async function criarDisponibilidade(input: DoctorAvailabilityCreate): Pro
export async function listarDisponibilidades(params?: { doctorId?: string; active?: boolean }): Promise {
const qs = new URLSearchParams();
if (params?.doctorId) qs.set('doctor_id', `eq.${encodeURIComponent(String(params.doctorId))}`);
- if (typeof params?.active !== 'undefined') qs.set('active', `eq.${params.active ? 'true' : 'false'}`);
+ if (params?.active !== undefined) qs.set('active', `eq.${params.active ? 'true' : 'false'}`);
const url = `${REST}/doctor_availability${qs.toString() ? `?${qs.toString()}` : ''}`;
const res = await fetch(url, { method: 'GET', headers: baseHeaders() });
@@ -616,9 +616,19 @@ function buildRedirectUrl(target?: 'paciente' | 'medico' | 'admin' | 'default',
const base = DEFAULT_REDIRECT_BASE.replace(/\/$/, '');
let path = '/';
- if (target === 'paciente') path = '/paciente';
- else if (target === 'medico') path = '/profissional';
- else if (target === 'admin') path = '/dashboard';
+ switch (target) {
+ case 'paciente':
+ path = '/paciente';
+ break;
+ case 'medico':
+ path = '/profissional';
+ break;
+ case 'admin':
+ path = '/dashboard';
+ break;
+ default:
+ path = '/';
+ }
return `${base}${path}`;
}
@@ -732,7 +742,8 @@ async function parse(res: Response): Promise {
}
// For other errors, log a concise error and try to produce a friendly message
- console.error('[API ERROR] Status:', res.status, json ? 'JSON response' : 'no-json', rawText ? 'raw body present' : 'no raw body');
+ const endpoint = res.url ? new URL(res.url).pathname : 'unknown';
+ console.error('[API ERROR] Status:', res.status, 'Endpoint:', endpoint, json ? 'JSON response' : 'no-json', rawText ? 'raw body present' : 'no raw body', 'Message:', msg || 'N/A');
// Mensagens amigáveis para erros comuns
let friendlyMessage = msg;
@@ -837,7 +848,7 @@ export async function buscarPacientes(termo: string): Promise {
// Busca por ID se parece com UUID
if (searchTerm.includes('-') && searchTerm.length > 10) {
- queries.push(`id=eq.${searchTerm}`);
+ queries.push(`id=eq.${encodeURIComponent(searchTerm)}`);
}
// Busca por CPF (com e sem formatação)
@@ -848,14 +859,14 @@ export async function buscarPacientes(termo: string): Promise {
}
// Busca por nome (usando ilike para busca case-insensitive)
+ // NOTA: apenas full_name existe, social_name foi removido
if (searchTerm.length >= 2) {
- queries.push(`full_name=ilike.*${searchTerm}*`);
- queries.push(`social_name=ilike.*${searchTerm}*`);
+ queries.push(`full_name=ilike.*${q}*`);
}
// Busca por email se contém @
if (searchTerm.includes('@')) {
- queries.push(`email=ilike.*${searchTerm}*`);
+ queries.push(`email=ilike.*${q}*`);
}
const results: Paciente[] = [];
@@ -864,13 +875,8 @@ export async function buscarPacientes(termo: string): Promise {
// Executa as buscas e combina resultados únicos
for (const query of queries) {
try {
- const [key, val] = String(query).split('=');
- const params = new URLSearchParams();
- if (key && typeof val !== 'undefined') params.set(key, val);
- params.set('limit', '10');
- const url = `${REST}/patients?${params.toString()}`;
+ const url = `${REST}/patients?${query}&limit=10`;
const headers = baseHeaders();
- // Logs removidos por segurança
const res = await fetch(url, { method: "GET", headers });
const arr = await parse(res);
@@ -883,7 +889,7 @@ export async function buscarPacientes(termo: string): Promise {
}
}
} catch (error) {
- console.warn(`Erro na busca com query: ${query}`, error);
+ console.warn(`[API] Erro na busca de pacientes com query: ${query}`, error);
}
}
@@ -1115,23 +1121,21 @@ export async function criarAgendamento(input: AppointmentCreate): Promise {
- if (!t) return null;
- const parts = String(t).split(':').map((p) => Number(p));
- if (parts.length >= 2 && !Number.isNaN(parts[0]) && !Number.isNaN(parts[1])) return parts[0] * 60 + parts[1];
- return null;
- };
- const exStart = parseToMinutes(ex.start_time ?? undefined);
- const exEnd = parseToMinutes(ex.end_time ?? undefined);
+ const parseToMinutes = (t?: string | null) => {
+ if (!t) return null;
+ const parts = String(t).split(':').map(Number);
+ if (parts.length >= 2 && !Number.isNaN(parts[0]) && !Number.isNaN(parts[1])) return parts[0] * 60 + parts[1];
+ return null;
+ };
+ const exStart = parseToMinutes(ex.start_time ?? undefined);
+ const exEnd = parseToMinutes(ex.end_time ?? undefined);
const sched = new Date(input.scheduled_at);
const schedMinutes = sched.getHours() * 60 + sched.getMinutes();
const schedDuration = input.duration_minutes ?? 30;
const schedEndMinutes = schedMinutes + Number(schedDuration);
- if (exStart != null && exEnd != null) {
- if (schedMinutes < exEnd && exStart < schedEndMinutes) {
- const reason = ex.reason ? ` Motivo: ${ex.reason}` : '';
- throw new Error(`Não é possível agendar neste horário por uma exceção que bloqueia parte do dia.${reason}`);
- }
+ if (exStart != null && exEnd != null && schedMinutes < exEnd && exStart < schedEndMinutes) {
+ const reason = ex.reason ? ` Motivo: ${ex.reason}` : '';
+ throw new Error(`Não é possível agendar neste horário por uma exceção que bloqueia parte do dia.${reason}`);
}
} catch (inner) {
// Propagate the exception as user-facing error
@@ -1721,8 +1725,7 @@ export async function buscarMedicos(termo: string): Promise {
const searchTerm = termo.toLowerCase().trim();
const digitsOnly = searchTerm.replace(/\D/g, '');
- // Do not pre-encode the searchTerm here; we'll let URLSearchParams handle encoding
- const q = searchTerm;
+ const q = encodeURIComponent(searchTerm);
// Monta queries para buscar em múltiplos campos
const queries = [];
@@ -1734,21 +1737,19 @@ export async function buscarMedicos(termo: string): Promise {
// Busca por CRM (com e sem formatação)
if (digitsOnly.length >= 3) {
- queries.push(`crm=ilike.*${digitsOnly}*`);
+ queries.push(`crm=ilike.*${encodeURIComponent(digitsOnly)}*`);
}
// Busca por nome (usando ilike para busca case-insensitive)
+ // NOTA: apenas full_name existe na tabela, nome_social foi removido
if (searchTerm.length >= 2) {
queries.push(`full_name=ilike.*${q}*`);
- queries.push(`nome_social=ilike.*${q}*`);
}
// Busca por email se contém @
if (searchTerm.includes('@')) {
// Quando o usuário pesquisa por email (contendo '@'), limitar as queries apenas ao campo email.
- // Em alguns esquemas de banco / views, buscar por outros campos com um email pode provocar
- // erros de requisição (400) dependendo das colunas e políticas. Reduzimos o escopo para evitar 400s.
- queries.length = 0; // limpar queries anteriores
+ queries.length = 0;
queries.push(`email=ilike.*${q}*`);
}
@@ -1756,8 +1757,6 @@ export async function buscarMedicos(termo: string): Promise {
if (searchTerm.length >= 2) {
queries.push(`specialty=ilike.*${q}*`);
}
-
- // Debug removido por segurança
const results: Medico[] = [];
const seenIds = new Set();
@@ -1765,15 +1764,8 @@ export async function buscarMedicos(termo: string): Promise {
// Executa as buscas e combina resultados únicos
for (const query of queries) {
try {
- // Build the URL safely using URLSearchParams so special characters (like @) are encoded correctly
- // query is like 'nome_social=ilike.*something*' -> split into key/value
- const [key, val] = String(query).split('=');
- const params = new URLSearchParams();
- if (key && typeof val !== 'undefined') params.set(key, val);
- params.set('limit', '10');
- const url = `${REST}/doctors?${params.toString()}`;
+ const url = `${REST}/doctors?${query}&limit=10`;
const headers = baseHeaders();
- // Logs removidos por segurança
const res = await fetch(url, { method: 'GET', headers });
const arr = await parse(res);
@@ -1786,7 +1778,7 @@ export async function buscarMedicos(termo: string): Promise {
}
}
} catch (error) {
- console.warn(`Erro na busca com query: ${query}`, error);
+ console.warn(`[API] Erro na busca de médicos com query: ${query}`, error);
}
}
@@ -1800,7 +1792,7 @@ export async function buscarMedicoPorId(id: string | number): Promise v.replace(/"/g, '\\"');
+ const escapeQuotes = (v: string) => JSON.stringify(v).slice(1, -1);
try {
// 1) Se parece UUID, busca por id direto
@@ -2085,9 +2077,9 @@ export async function criarMedico(input: MedicoInput): Promise {
crm_uf: crmUf,
create_user: false,
};
- if (input.specialty) fallbackPayload.specialty = input.specialty;
- if (input.phone_mobile) fallbackPayload.phone_mobile = input.phone_mobile;
- if (typeof input.phone2 !== 'undefined') fallbackPayload.phone2 = input.phone2;
+ if (input.specialty) fallbackPayload.specialty = input.specialty;
+ if (input.phone_mobile) fallbackPayload.phone_mobile = input.phone_mobile;
+ if (input.phone2 !== undefined) fallbackPayload.phone2 = input.phone2;
const url = `${API_BASE}/functions/v1/create-doctor`;
const headers = { ...baseHeaders(), 'Content-Type': 'application/json' } as Record;
@@ -2685,7 +2677,8 @@ export async function criarUsuarioPaciente(paciente: { email: string; full_name:
const parsed = await parse(res as Response);
// Attach the generated password so callers (UI) can display it if necessary
- return { ...(parsed || {}), password };
+ if (parsed && typeof parsed === 'object') return { ...(parsed as any), password };
+ return { password };
}
@@ -2797,7 +2790,7 @@ export async function uploadFotoPaciente(_id: string | number, _file: File): Pro
form.append('file', _file, `avatar.${ext}`);
const headers: Record = {
- // Supabase requires the anon key in 'apikey' header for client-side uploads
+ // Supabase requires the anon key in 'apikey' header for client-side uploads
apikey: ENV_CONFIG.SUPABASE_ANON_KEY,
// Accept json
Accept: 'application/json',
diff --git a/susconecta/lib/reports.ts b/susconecta/lib/reports.ts
index 48949ea..a54c0e0 100644
--- a/susconecta/lib/reports.ts
+++ b/susconecta/lib/reports.ts
@@ -162,18 +162,23 @@ export async function listarRelatorios(filtros?: { patient_id?: string; status?:
*/
export async function buscarRelatorioPorId(id: string): Promise {
try {
- // Log removido por segurança
- const resposta = await fetch(`${BASE_API_RELATORIOS}?id=eq.${id}`, {
+ // Validar ID antes de fazer requisição
+ if (!id || typeof id !== 'string' || id.trim() === '') {
+ console.warn('[REPORTS] ID vazio ou inválido ao buscar relatório');
+ throw new Error('ID de relatório inválido');
+ }
+
+ const encodedId = encodeURIComponent(id.trim());
+ const resposta = await fetch(`${BASE_API_RELATORIOS}?id=eq.${encodedId}`, {
method: 'GET',
headers: obterCabecalhos(),
});
const resultado = await tratarRespostaApi(resposta);
const relatorio = Array.isArray(resultado) && resultado.length > 0 ? resultado[0] : null;
- // Log removido por segurança
if (!relatorio) throw new Error('Relatório não encontrado');
return relatorio;
} catch (erro) {
- console.error('❌ [API RELATÓRIOS] Erro ao buscar relatório:', erro);
+ console.error('[REPORTS] Erro ao buscar relatório:', erro);
throw erro;
}
}
@@ -259,39 +264,38 @@ export async function deletarRelatorio(id: string): Promise {
*/
export async function listarRelatoriosPorPaciente(idPaciente: string): Promise {
try {
- // Logs removidos por segurança
+ // Validar ID antes de fazer requisição
+ if (!idPaciente || typeof idPaciente !== 'string' || idPaciente.trim() === '') {
+ console.warn('[REPORTS] ID paciente vazio ou inválido ao listar relatórios');
+ return [];
+ }
+
// Try a strict eq lookup first (encode the id)
- const encodedId = encodeURIComponent(String(idPaciente));
+ const encodedId = encodeURIComponent(String(idPaciente).trim());
let url = `${BASE_API_RELATORIOS}?patient_id=eq.${encodedId}`;
const headers = obterCabecalhos();
- const masked = (headers as any)['Authorization'] ? `${String((headers as any)['Authorization']).slice(0,6)}...${String((headers as any)['Authorization']).slice(-6)}` : null;
- // Logs removidos por segurança
const resposta = await fetch(url, {
method: 'GET',
headers,
});
const resultado = await tratarRespostaApi(resposta);
- // Log removido por segurança
// If eq returned results, return them. Otherwise retry using `in.(id)` which some setups prefer.
if (Array.isArray(resultado) && resultado.length) return resultado;
// Retry with in.(id) clause as a fallback
try {
- const inClause = encodeURIComponent(`(${String(idPaciente)})`);
+ const inClause = encodeURIComponent(`(${String(idPaciente).trim()})`);
const urlIn = `${BASE_API_RELATORIOS}?patient_id=in.${inClause}`;
- // Log removido por segurança
const resp2 = await fetch(urlIn, { method: 'GET', headers });
const res2 = await tratarRespostaApi(resp2);
- // Log removido por segurança
return Array.isArray(res2) ? res2 : [];
} catch (e) {
- // Log removido por segurança
+ // Fallback falhou, retornar vazio
+ return [];
}
-
- return [];
} catch (erro) {
- console.error('❌ [API RELATÓRIOS] Erro ao buscar relatórios do paciente:', erro);
- throw erro;
+ console.error('[REPORTS] Erro ao buscar relatórios do paciente:', erro);
+ return [];
}
}
@@ -300,20 +304,24 @@ export async function listarRelatoriosPorPaciente(idPaciente: string): Promise {
try {
- console.log('👨⚕️ [API RELATÓRIOS] Buscando relatórios do médico:', idMedico);
- const url = `${BASE_API_RELATORIOS}?requested_by=eq.${idMedico}`;
+ // Validar ID antes de fazer requisição
+ if (!idMedico || typeof idMedico !== 'string' || idMedico.trim() === '') {
+ console.warn('[REPORTS] ID médico vazio ou inválido ao listar relatórios');
+ return [];
+ }
+
+ const encodedId = encodeURIComponent(idMedico.trim());
+ const url = `${BASE_API_RELATORIOS}?requested_by=eq.${encodedId}`;
const headers = obterCabecalhos();
- // Logs removidos por segurança
const resposta = await fetch(url, {
method: 'GET',
headers: obterCabecalhos(),
});
const resultado = await tratarRespostaApi(resposta);
- // Log removido por segurança
- return resultado;
+ return Array.isArray(resultado) ? resultado : [];
} catch (erro) {
- console.error('❌ [API RELATÓRIOS] Erro ao buscar relatórios do médico:', erro);
- throw erro;
+ console.error('[REPORTS] Erro ao buscar relatórios do médico:', erro);
+ return [];
}
}
@@ -328,19 +336,17 @@ export async function listarRelatoriosPorPacientes(ids: string[]): Promise String(i).trim()).filter(Boolean);
if (!cleaned.length) return [];
- // monta cláusula in.(id1,id2,...)
- const inClause = cleaned.join(',');
- const url = `${BASE_API_RELATORIOS}?patient_id=in.(${inClause})`;
+ // monta cláusula in.(id1,id2,...) com proper encoding
+ const encodedIds = cleaned.map(id => encodeURIComponent(id)).join(',');
+ const url = `${BASE_API_RELATORIOS}?patient_id=in.(${encodedIds})`;
const headers = obterCabecalhos();
- // Logs removidos por segurança
const resposta = await fetch(url, { method: 'GET', headers });
const resultado = await tratarRespostaApi(resposta);
- // Log removido por segurança
- return resultado;
+ return Array.isArray(resultado) ? resultado : [];
} catch (erro) {
- console.error('❌ [API RELATÓRIOS] Erro ao buscar relatórios para vários pacientes:', erro);
- throw erro;
+ console.error('[REPORTS] Erro ao buscar relatórios para vários pacientes:', erro);
+ return [];
}
}
diff --git a/susconecta/next.config.mjs b/susconecta/next.config.mjs
index f5cbc38..01abbf4 100644
--- a/susconecta/next.config.mjs
+++ b/susconecta/next.config.mjs
@@ -1,3 +1,9 @@
+import { fileURLToPath } from 'url'
+import { dirname } from 'path'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = dirname(__filename)
+
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: {
@@ -9,6 +15,9 @@ const nextConfig = {
images: {
unoptimized: true,
},
+ // Define explicit output tracing root to silence Next.js workspace root warning
+ // Set to the current package directory (susconecta)
+ outputFileTracingRoot: __dirname,
}
export default nextConfig
diff --git a/susconecta/package-lock.json b/susconecta/package-lock.json
index ebf1400..7e517ab 100644
--- a/susconecta/package-lock.json
+++ b/susconecta/package-lock.json
@@ -49,6 +49,7 @@
"cmdk": "latest",
"date-fns": "4.1.0",
"embla-carousel-react": "latest",
+ "framer-motion": "^12.23.24",
"geist": "^1.3.1",
"input-otp": "latest",
"jspdf": "^3.0.3",
@@ -65,6 +66,7 @@
"sonner": "latest",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
+ "uuid": "^13.0.0",
"vaul": "latest",
"zod": "3.25.67"
},
@@ -5738,6 +5740,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
+ "node_modules/framer-motion": {
+ "version": "12.23.24",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
+ "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.23.23",
+ "motion-utils": "^12.23.6",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -7140,6 +7169,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/motion-dom": {
+ "version": "12.23.23",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
+ "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.23.6"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.23.6",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
+ "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -9175,6 +9219,19 @@
"base64-arraybuffer": "^1.0.2"
}
},
+ "node_modules/uuid": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+ "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
diff --git a/susconecta/package.json b/susconecta/package.json
index f185dfd..e7e4ae7 100644
--- a/susconecta/package.json
+++ b/susconecta/package.json
@@ -51,6 +51,7 @@
"cmdk": "latest",
"date-fns": "4.1.0",
"embla-carousel-react": "latest",
+ "framer-motion": "^12.23.24",
"geist": "^1.3.1",
"input-otp": "latest",
"jspdf": "^3.0.3",
@@ -67,6 +68,7 @@
"sonner": "latest",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
+ "uuid": "^13.0.0",
"vaul": "latest",
"zod": "3.25.67"
},
diff --git a/susconecta/pnpm-lock.yaml b/susconecta/pnpm-lock.yaml
index d66aef2..04077ae 100644
--- a/susconecta/pnpm-lock.yaml
+++ b/susconecta/pnpm-lock.yaml
@@ -179,6 +179,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.1.13)
+ uuid:
+ specifier: ^13.0.0
+ version: 13.0.0
vaul:
specifier: latest
version: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -3321,6 +3324,10 @@ packages:
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
+ uuid@13.0.0:
+ resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
+ hasBin: true
+
vaul@1.1.2:
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
peerDependencies:
@@ -6686,6 +6693,8 @@ snapshots:
base64-arraybuffer: 1.0.2
optional: true
+ uuid@13.0.0: {}
+
vaul@1.1.2(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)