From 489f25b2e9b35e6074534cd1f0ddf84365deca32 Mon Sep 17 00:00:00 2001 From: M-Gabrielly Date: Thu, 30 Oct 2025 23:50:29 -0300 Subject: [PATCH 01/13] feat(calendar): add calendar 3d --- .../app/(main-routes)/calendar/page.tsx | 58 ++++- .../components/ui/THREE_D_CALENDAR_README.md | 138 +++++++++++ .../components/ui/three-dwall-calendar.tsx | 233 ++++++++++++++++++ susconecta/package.json | 1 + susconecta/pnpm-lock.yaml | 9 + 5 files changed, 433 insertions(+), 6 deletions(-) create mode 100644 susconecta/components/ui/THREE_D_CALENDAR_README.md create mode 100644 susconecta/components/ui/three-dwall-calendar.tsx diff --git a/susconecta/app/(main-routes)/calendar/page.tsx b/susconecta/app/(main-routes)/calendar/page.tsx index 65c2348..bed1d79 100644 --- a/susconecta/app/(main-routes)/calendar/page.tsx +++ b/susconecta/app/(main-routes)/calendar/page.tsx @@ -20,6 +20,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { ThreeDWallCalendar, CalendarEvent } from "@/components/ui/three-dwall-calendar"; const ListaEspera = dynamic( () => import("@/components/agendamento/ListaEspera"), @@ -29,8 +30,9 @@ const ListaEspera = dynamic( export default function AgendamentoPage() { const [appointments, setAppointments] = useState([]); const [waitingList, setWaitingList] = useState(mockWaitingList); - const [activeTab, setActiveTab] = useState<"calendar" | "espera">("calendar"); + const [activeTab, setActiveTab] = useState<"calendar" | "espera" | "3d">("calendar"); const [requestsList, setRequestsList] = useState(); + const [threeDEvents, setThreeDEvents] = useState([]); useEffect(() => { document.addEventListener("keydown", (event) => { @@ -40,6 +42,9 @@ export default function AgendamentoPage() { if (event.key === "f") { setActiveTab("espera"); } + if (event.key === "3") { + setActiveTab("3d"); + } }); }, []); @@ -49,17 +54,19 @@ export default function AgendamentoPage() { (async () => { try { // listarAgendamentos accepts a query string; request a reasonable limit and order - const arr = await (await import('@/lib/api')).listarAgendamentos('select=*&order=scheduled_at.desc&limit=500').catch(() => []); + const api = await import('@/lib/api'); + const arr = await api.listarAgendamentos('select=*&order=scheduled_at.desc&limit=500').catch(() => []); if (!mounted) return; if (!arr || !arr.length) { setAppointments([]); setRequestsList([]); + setThreeDEvents([]); return; } // Batch-fetch patient names for display const patientIds = Array.from(new Set(arr.map((a: any) => a.patient_id).filter(Boolean))); - const patients = (patientIds && patientIds.length) ? await (await import('@/lib/api')).buscarPacientesPorIds(patientIds) : []; + const patients = (patientIds && patientIds.length) ? await api.buscarPacientesPorIds(patientIds) : []; const patientsById: Record = {}; (patients || []).forEach((p: any) => { if (p && p.id) patientsById[String(p.id)] = p; }); @@ -81,10 +88,24 @@ export default function AgendamentoPage() { } as EventInput; }); setRequestsList(events || []); + + // Convert to 3D calendar events + const threeDEvents: CalendarEvent[] = (arr || []).map((obj: any) => { + const scheduled = obj.scheduled_at || obj.scheduledAt || obj.time || null; + const patient = (patientsById[String(obj.patient_id)]?.full_name) || obj.patient_name || obj.patient_full_name || obj.patient || 'Paciente'; + const title = `${patient}: ${obj.appointment_type ?? obj.type ?? ''}`.trim(); + return { + id: obj.id || String(Date.now()), + title, + date: scheduled ? new Date(scheduled).toISOString() : new Date().toISOString(), + }; + }); + setThreeDEvents(threeDEvents); } catch (err) { console.warn('[AgendamentoPage] falha ao carregar agendamentos', err); setAppointments([]); setRequestsList([]); + setThreeDEvents([]); } })(); return () => { mounted = false; }; @@ -109,16 +130,25 @@ export default function AgendamentoPage() { console.log(`Notificando paciente ${patientId}`); }; + const handleAddEvent = (event: CalendarEvent) => { + setThreeDEvents((prev) => [...prev, event]); + }; + + const handleRemoveEvent = (id: string) => { + setThreeDEvents((prev) => prev.filter((e) => e.id !== id)); + }; + return (
-

{activeTab === "calendar" ? "Calendário" : "Lista de Espera"}

+

+ {activeTab === "calendar" ? "Calendário" : activeTab === "3d" ? "Calendário 3D" : "Lista de Espera"} +

- Navegue através dos atalhos: Calendário (C) ou Fila de espera - (F). + Navegue através dos atalhos: Calendário (C), Fila de espera (F) ou 3D (3).

@@ -153,6 +183,14 @@ export default function AgendamentoPage() { Calendário + +
+ ) : activeTab === "3d" ? ( +
+ +
) : ( void + onRemoveEvent?: (id: string) => void + panelWidth?: number // default: 160 + panelHeight?: number // default: 120 + columns?: number // default: 7 +} +``` + +## 🔧 Dependências Instaladas + +- `uuid` - Geração de IDs únicos +- `date-fns` - Manipulação de datas +- `@radix-ui/react-popover` - Popovers +- `@radix-ui/react-hover-card` - Hover cards +- `lucide-react` - Ícones + +## 🎨 Personalização + +O componente utiliza as variáveis CSS do tema shadcn/ui: +- `bg-blue-500` / `dark:bg-blue-600` para eventos +- Componentes shadcn/ui: `Card`, `Button`, `Input`, `Popover`, `HoverCard` + +## 📱 Responsividade + +O calendário ajusta automaticamente: +- 7 colunas para desktop (padrão) +- Scroll horizontal para telas menores +- Cards responsivos com overflow visível + +## 🔄 Integração com Backend + +Os eventos são convertidos automaticamente dos agendamentos do sistema: + +```tsx +// Conversão automática de agendamentos para eventos 3D +const threeDEvents: CalendarEvent[] = appointments.map((obj: any) => ({ + id: obj.id || String(Date.now()), + title: `${patient}: ${appointment_type}`, + date: new Date(scheduled_at).toISOString(), +})) +``` + +## ✨ Exemplo de Uso + +```tsx +import { ThreeDWallCalendar, CalendarEvent } from "@/components/ui/three-dwall-calendar" + +export default function MyPage() { + const [events, setEvents] = useState([]) + + const handleAddEvent = (event: CalendarEvent) => { + setEvents((prev) => [...prev, event]) + } + + const handleRemoveEvent = (id: string) => { + setEvents((prev) => prev.filter((e) => e.id !== id)) + } + + return ( + + ) +} +``` + +## 🐛 Troubleshooting + +### Eventos não aparecem +- Verifique se o formato da data está em ISO (`new Date().toISOString()`) +- Confirme que o array `events` está sendo passado corretamente + +### Rotação não funciona +- Certifique-se de que o navegador suporta `transform-style: preserve-3d` +- Verifique se não há conflitos de CSS sobrescrevendo as propriedades 3D + +### Performance +- Limite o número de eventos por dia para melhor performance +- Considere virtualização para calendários com muitos meses + +--- + +**Data de Integração**: 30 de outubro de 2025 +**Versão**: 1.0.0 diff --git a/susconecta/components/ui/three-dwall-calendar.tsx b/susconecta/components/ui/three-dwall-calendar.tsx new file mode 100644 index 0000000..dc3cc90 --- /dev/null +++ b/susconecta/components/ui/three-dwall-calendar.tsx @@ -0,0 +1,233 @@ +"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 { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card" +import { Trash2 } 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 +} + +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 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) + + // 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")) + + // 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 + dragStart.current = { x: e.clientX, y: e.clientY } + ;(e.currentTarget as Element).setPointerCapture(e.pointerId) // ✅ Correct element + } + + 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 + 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 + } + + const gap = 12 + const rowCount = Math.ceil(days.length / columns) + const wallCenterRow = (rowCount - 1) / 2 + + return ( +
+
+ +
{format(dateRef, "MMMM yyyy", { locale: ptBR })}
+ +
+ + {/* Wall container */} +
+
+
+ {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 ( +
+ + +
+
{format(day, "d")}
+
{format(day, "EEE", { locale: ptBR })}
+
+ + {/* events */} +
+ {dayEvents.map((ev, i) => { + const left = 8 + (i * 34) % (panelWidth - 40) + const top = 8 + Math.floor((i * 34) / (panelWidth - 40)) * 28 + return ( + + + + +
+ • +
+
+ + {ev.title} + +
+
+ + + +
+
{ev.title}
+
+ {format(new Date(ev.date), "PPP p", { locale: ptBR })} +
+
+ {onRemoveEvent && ( + + )} +
+
+
+
+ ) + })} +
+ +
+ {dayEvents.length} evento(s) +
+
+
+
+ ) + })} +
+
+
+ + {/* Add event form */} +
+ setTitle(e.target.value)} /> + setNewDate(e.target.value)} /> + +
+
+ ) +} diff --git a/susconecta/package.json b/susconecta/package.json index f185dfd..fe34a22 100644 --- a/susconecta/package.json +++ b/susconecta/package.json @@ -67,6 +67,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) -- 2.47.2 From 44ddc4d03ad9b80aee255bfe315e8eb72b0ab33b Mon Sep 17 00:00:00 2001 From: Jonas Francisco Date: Fri, 31 Oct 2025 00:27:48 -0300 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20Substitui=20calend=C3=A1rio=20ant?= =?UTF-8?q?igo=20pelo=20novo=20EventManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(main-routes)/calendar/page.tsx | 479 +++--- susconecta/app/calendarComponente/page.tsx | 1493 +++++++++++++++++ susconecta/components/event-manager.tsx | 1493 +++++++++++++++++ susconecta/package-lock.json | 14 + 4 files changed, 3265 insertions(+), 214 deletions(-) create mode 100644 susconecta/app/calendarComponente/page.tsx create mode 100644 susconecta/components/event-manager.tsx diff --git a/susconecta/app/(main-routes)/calendar/page.tsx b/susconecta/app/(main-routes)/calendar/page.tsx index bed1d79..d0cfb55 100644 --- a/susconecta/app/(main-routes)/calendar/page.tsx +++ b/susconecta/app/(main-routes)/calendar/page.tsx @@ -1,246 +1,297 @@ "use client"; +// Imports mantidos import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; -import pt_br_locale from "@fullcalendar/core/locales/pt-br"; -import FullCalendar from "@fullcalendar/react"; -import dayGridPlugin from "@fullcalendar/daygrid"; -import interactionPlugin from "@fullcalendar/interaction"; -import timeGridPlugin from "@fullcalendar/timegrid"; -import { EventInput } from "@fullcalendar/core/index.js"; +import Link from "next/link"; + +// --- Imports do FullCalendar (ANTIGO) - REMOVIDOS --- +// import pt_br_locale from "@fullcalendar/core/locales/pt-br"; +// import FullCalendar from "@fullcalendar/react"; +// import dayGridPlugin from "@fullcalendar/daygrid"; +// import interactionPlugin from "@fullcalendar/interaction"; +// import timeGridPlugin from "@fullcalendar/timegrid"; +// import { EventInput } from "@fullcalendar/core/index.js"; + +// --- Imports do EventManager (NOVO) - ADICIONADOS --- +import { EventManager, type Event } from "@/components/event-manager"; +import { v4 as uuidv4 } from 'uuid'; + +// Imports mantidos import { Sidebar } from "@/components/dashboard/sidebar"; import { PagesHeader } from "@/components/dashboard/header"; import { Button } from "@/components/ui/button"; import { mockWaitingList } from "@/lib/mocks/appointment-mocks"; import "./index.css"; -import Link from "next/link"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, +  DropdownMenu, +  DropdownMenuContent, +  DropdownMenuItem, +  DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { ThreeDWallCalendar, CalendarEvent } from "@/components/ui/three-dwall-calendar"; +import { ThreeDWallCalendar, CalendarEvent } from "@/components/ui/three-dwall-calendar"; // Calendário 3D mantido const ListaEspera = dynamic( - () => import("@/components/agendamento/ListaEspera"), - { ssr: false } +  () => import("@/components/agendamento/ListaEspera"), +  { ssr: false } ); export default function AgendamentoPage() { - const [appointments, setAppointments] = useState([]); - const [waitingList, setWaitingList] = useState(mockWaitingList); - const [activeTab, setActiveTab] = useState<"calendar" | "espera" | "3d">("calendar"); - const [requestsList, setRequestsList] = useState(); +  const [appointments, setAppointments] = useState([]); +  const [waitingList, setWaitingList] = useState(mockWaitingList); +  const [activeTab, setActiveTab] = useState<"calendar" | "espera" | "3d">("calendar"); +  + // O 'requestsList' do FullCalendar foi removido. + // const [requestsList, setRequestsList] = useState(); +  const [threeDEvents, setThreeDEvents] = useState([]); - useEffect(() => { - document.addEventListener("keydown", (event) => { - if (event.key === "c") { - setActiveTab("calendar"); - } - if (event.key === "f") { - setActiveTab("espera"); - } - if (event.key === "3") { - setActiveTab("3d"); - } - }); - }, []); + // --- Dados de Exemplo para o NOVO Calendário --- + // (Colado do exemplo do 21st.dev) + const demoEvents: Event[] = [ + { + id: uuidv4(), + title: "Team Standup", + description: "Daily sync with the engineering team.", + startTime: new Date(2025, 9, 20, 9, 0, 0), // Mês 9 = Outubro + endTime: new Date(2025, 9, 20, 9, 30, 0), + color: "blue", + }, + { + id: uuidv4(), + title: "Code Review", + description: "Review PRs for the new feature.", + startTime: new Date(2025, 9, 21, 14, 0, 0), + endTime: new Date(2025, 9, 21, 15, 0, 0), + color: "green", + }, + { + id: uuidv4(), + title: "Client Presentation", + description: "Present the new designs to the client.", + startTime: new Date(2025, 9, 22, 11, 0, 0), + endTime: new Date(2025, 9, 22, 12, 0, 0), + color: "orange", + }, + { + id: uuidv4(), + title: "Sprint Planning", + description: "Plan the next sprint tasks.", + startTime: new Date(2025, 9, 23, 10, 0, 0), + endTime: new Date(2025, 9, 23, 11, 30, 0), + color: "purple", + }, + { + id: uuidv4(), + title: "Doctor Appointment", + description: "Annual check-up.", + startTime: new Date(2025, 9, 24, 16, 0, 0), + endTime: new Date(2025, 9, 24, 17, 0, 0), + color: "red", + }, + { + id: uuidv4(), + title: "Deploy to Production", + description: "Deploy the new release.", + startTime: new Date(2025, 9, 25, 15, 0, 0), + endTime: new Date(2025, 9, 25, 16, 0, 0), + color: "teal", + }, + { + id: uuidv4(), + title: "Product Design Review", + description: "Review the new product design mockups.", + startTime: new Date(2025, 9, 20, 13, 0, 0), + endTime: new Date(2025, 9, 20, 14, 30, 0), + color: "pink", + }, + { + id: uuidv4(), + title: "Gym Session", + description: "Leg day.", + startTime: new Date(2025, 9, 20, 18, 0, 0), + endTime: new Date(2025, 9, 20, 19, 0, 0), + color: "gray", + }, + ]; + // --- Fim dos Dados de Exemplo --- - useEffect(() => { - // Fetch real appointments and map to calendar events - let mounted = true; - (async () => { - try { - // listarAgendamentos accepts a query string; request a reasonable limit and order - const api = await import('@/lib/api'); - const arr = await api.listarAgendamentos('select=*&order=scheduled_at.desc&limit=500').catch(() => []); - if (!mounted) return; - if (!arr || !arr.length) { - setAppointments([]); - setRequestsList([]); - setThreeDEvents([]); - return; - } +  useEffect(() => { +    document.addEventListener("keydown", (event) => { +      if (event.key === "c") { +        setActiveTab("calendar"); +      } +      if (event.key === "f") { +        setActiveTab("espera"); +      } +      if (event.key === "3") { +        setActiveTab("3d"); +      } +    }); +  }, []); - // Batch-fetch patient names for display - const patientIds = Array.from(new Set(arr.map((a: any) => a.patient_id).filter(Boolean))); - const patients = (patientIds && patientIds.length) ? await api.buscarPacientesPorIds(patientIds) : []; - const patientsById: Record = {}; - (patients || []).forEach((p: any) => { if (p && p.id) patientsById[String(p.id)] = p; }); +  useEffect(() => { + // Este useEffect foi mantido, pois ele busca dados para o Calendário 3D +    let mounted = true; +    (async () => { +      try { +        const api = await import('@/lib/api'); +        const arr = await api.listarAgendamentos('select=*&order=scheduled_at.desc&limit=500').catch(() => []); +        if (!mounted) return; +        if (!arr || !arr.length) { +          setAppointments([]); +          // setRequestsList([]); // Removido +          setThreeDEvents([]); +          return; +        } - setAppointments(arr || []); +        const patientIds = Array.from(new Set(arr.map((a: any) => a.patient_id).filter(Boolean))); +        const patients = (patientIds && patientIds.length) ? await api.buscarPacientesPorIds(patientIds) : []; +        const patientsById: Record = {}; +        (patients || []).forEach((p: any) => { if (p && p.id) patientsById[String(p.id)] = p; }); - const events: EventInput[] = (arr || []).map((obj: any) => { - const scheduled = obj.scheduled_at || obj.scheduledAt || obj.time || null; - const start = scheduled ? new Date(scheduled) : null; - const duration = Number(obj.duration_minutes ?? obj.duration ?? 30) || 30; - const patient = (patientsById[String(obj.patient_id)]?.full_name) || obj.patient_name || obj.patient_full_name || obj.patient || 'Paciente'; - const title = `${patient}: ${obj.appointment_type ?? obj.type ?? ''}`.trim(); - const color = obj.status === 'confirmed' ? '#68d68a' : obj.status === 'pending' ? '#ffe55f' : '#ff5f5fff'; - return { - title, - start: start || new Date(), - end: start ? new Date(start.getTime() + duration * 60 * 1000) : undefined, - color, - extendedProps: { raw: obj }, - } as EventInput; - }); - setRequestsList(events || []); +        setAppointments(arr || []); - // Convert to 3D calendar events - const threeDEvents: CalendarEvent[] = (arr || []).map((obj: any) => { - const scheduled = obj.scheduled_at || obj.scheduledAt || obj.time || null; - const patient = (patientsById[String(obj.patient_id)]?.full_name) || obj.patient_name || obj.patient_full_name || obj.patient || 'Paciente'; - const title = `${patient}: ${obj.appointment_type ?? obj.type ?? ''}`.trim(); - return { - id: obj.id || String(Date.now()), - title, - date: scheduled ? new Date(scheduled).toISOString() : new Date().toISOString(), - }; - }); - setThreeDEvents(threeDEvents); - } catch (err) { - console.warn('[AgendamentoPage] falha ao carregar agendamentos', err); - setAppointments([]); - setRequestsList([]); - setThreeDEvents([]); - } - })(); - return () => { mounted = false; }; - }, []); + // --- Mapeamento para o FullCalendar (ANTIGO) - REMOVIDO --- +        // const events: EventInput[] = (arr || []).map((obj: any) => { +        //   ... +        // }); +        // setRequestsList(events || []); - // mantive para caso a lógica de salvar consulta passe a funcionar - const handleSaveAppointment = (appointment: any) => { - if (appointment.id) { - setAppointments((prev) => - prev.map((a) => (a.id === appointment.id ? appointment : a)) - ); - } else { - const newAppointment = { - ...appointment, - id: Date.now().toString(), - }; - setAppointments((prev) => [...prev, newAppointment]); - } - }; +        // Convert to 3D calendar events (MANTIDO) +        const threeDEvents: CalendarEvent[] = (arr || []).map((obj: any) => { +          const scheduled = obj.scheduled_at || obj.scheduledAt || obj.time || null; +          const patient = (patientsById[String(obj.patient_id)]?.full_name) || obj.patient_name || obj.patient_full_name || obj.patient || 'Paciente'; +          const title = `${patient}: ${obj.appointment_type ?? obj.type ?? ''}`.trim(); +          return { +            id: obj.id || String(Date.now()), +            title, +            date: scheduled ? new Date(scheduled).toISOString() : new Date().toISOString(), +          }; +        }); +        setThreeDEvents(threeDEvents); +      } catch (err) { +        console.warn('[AgendamentoPage] falha ao carregar agendamentos', err); +        setAppointments([]); +        // setRequestsList([]); // Removido +        setThreeDEvents([]); +      } +    })(); +    return () => { mounted = false; }; +  }, []); - const handleNotifyPatient = (patientId: string) => { - console.log(`Notificando paciente ${patientId}`); - }; +  // Handlers mantidos +  const handleSaveAppointment = (appointment: any) => { +    if (appointment.id) { +      setAppointments((prev) => +        prev.map((a) => (a.id === appointment.id ? appointment : a)) +      ); +    } else { +      const newAppointment = { +        ...appointment, +        id: Date.now().toString(), +      }; +      setAppointments((prev) => [...prev, newAppointment]); +    } +  }; - const handleAddEvent = (event: CalendarEvent) => { - setThreeDEvents((prev) => [...prev, event]); - }; +  const handleNotifyPatient = (patientId: string) => { +    console.log(`Notificando paciente ${patientId}`); +  }; - const handleRemoveEvent = (id: string) => { - setThreeDEvents((prev) => prev.filter((e) => e.id !== id)); - }; +  const handleAddEvent = (event: CalendarEvent) => { +    setThreeDEvents((prev) => [...prev, event]); +  }; - return ( -
-
-
-
-
-

- {activeTab === "calendar" ? "Calendário" : activeTab === "3d" ? "Calendário 3D" : "Lista de Espera"} -

-

- Navegue através dos atalhos: Calendário (C), Fila de espera (F) ou 3D (3). -

-
-
- {/* - - */} - - - Opções » - - - - Agendamento - - - Procedimento - - - Financeiro - - - +  const handleRemoveEvent = (id: string) => { +    setThreeDEvents((prev) => prev.filter((e) => e.id !== id)); +  }; -
- +  return ( +   
+     
+       
+         
+ {/* Todo o cabeçalho foi mantido */} +           
+             

+                {activeTab === "calendar" ? "Calendário" : activeTab === "3d" ? "Calendário 3D" : "Lista de Espera"} +             

+             

+                Navegue através dos atalhos: Calendário (C), Fila de espera (F) ou 3D (3). +             

+           
+           
+              +                +                  Opções » +                +                +                  +                    Agendamento +                  +                  +                    Procedimento +                  +                  +                    Financeiro +                  +                +              - +             
+                - -
-
-
+                - {activeTab === "calendar" ? ( -
- { - info.view.calendar.changeView("timeGridDay", info.dateStr); - }} - selectable={true} - selectMirror={true} - dayMaxEvents={true} - dayMaxEventRows={3} - /> -
- ) : activeTab === "3d" ? ( -
- -
- ) : ( - {}} - /> - )} -
-
-
- ); -} +                +             
+           
+         
+ + {/* --- AQUI ESTÁ A MUDANÇA --- */} +          {activeTab === "calendar" ? ( +           
+ {/* O FullCalendar antigo foi substituído por este */} + +           
+          ) : activeTab === "3d" ? ( + // O calendário 3D foi mantido intacto +           
+              +           
+          ) : ( + // A Lista de Espera foi mantida intacta +            {}} +            /> +          )} +       
+     
+   
+  ); +} \ No newline at end of file diff --git a/susconecta/app/calendarComponente/page.tsx b/susconecta/app/calendarComponente/page.tsx new file mode 100644 index 0000000..6373718 --- /dev/null +++ b/susconecta/app/calendarComponente/page.tsx @@ -0,0 +1,1493 @@ +"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"} +

+
+ + + +
+
+ +
+ {/* Mobile: Select dropdown */} +
+ +
+ + {/* Desktop: Button group */} +
+ + + + +
+ + +
+
+ +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> + {searchQuery && ( + + )} +
+ + {/* Mobile: Horizontal scroll with full-length buttons */} +
+
+ {/* Color Filter */} + + + + + + Filter by Color + + {colors.map((color) => ( + { + setSelectedColors((prev) => + checked ? [...prev, color.value] : prev.filter((c) => c !== color.value), + ) + }} + > +
+
+ {color.name} +
+ + ))} + + + + {/* Tag Filter */} + + + + + + Filter by Tag + + {availableTags.map((tag) => ( + { + setSelectedTags((prev) => (checked ? [...prev, tag] : prev.filter((t) => t !== tag))) + }} + > + {tag} + + ))} + + + + {/* Category Filter */} + + + + + + Filter by Category + + {categories.map((category) => ( + { + setSelectedCategories((prev) => + checked ? [...prev, category] : prev.filter((c) => c !== category), + ) + }} + > + {category} + + ))} + + + + {hasActiveFilters && ( + + )} +
+
+ + {/* Desktop: Original layout */} +
+ {/* Color Filter */} + + + + + + Filter by Color + + {colors.map((color) => ( + { + setSelectedColors((prev) => + checked ? [...prev, color.value] : prev.filter((c) => c !== color.value), + ) + }} + > +
+
+ {color.name} +
+ + ))} + + + + {/* Tag Filter */} + + + + + + Filter by Tag + + {availableTags.map((tag) => ( + { + setSelectedTags((prev) => (checked ? [...prev, tag] : prev.filter((t) => t !== tag))) + }} + > + {tag} + + ))} + + + + {/* Category Filter */} + + + + + + Filter by Category + + {categories.map((category) => ( + { + setSelectedCategories((prev) => + checked ? [...prev, category] : prev.filter((c) => c !== category), + ) + }} + > + {category} + + ))} + + + + {hasActiveFilters && ( + + )} +
+
+ + {hasActiveFilters && ( +
+ Active filters: + {selectedColors.map((colorValue) => { + const color = getColorClasses(colorValue) + return ( + +
+ {color.name} + + + ) + })} + {selectedTags.map((tag) => ( + + {tag} + + + ))} + {selectedCategories.map((category) => ( + + {category} + + + ))} +
+ )} + + {/* 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"} + + + +
+
+ + + isCreating + ? setNewEvent((prev) => ({ ...prev, title: e.target.value })) + : setSelectedEvent((prev) => (prev ? { ...prev, title: e.target.value } : null)) + } + placeholder="Event title" + /> +
+ +
+ +