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)