From d2e6d8948e2867bf75b269a3818cfbbb54686196 Mon Sep 17 00:00:00 2001 From: M-Gabrielly Date: Fri, 31 Oct 2025 00:28:30 -0300 Subject: [PATCH] fix(calendar): Improvements to the 3D calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adaptive dots based on number of patients (20px→10px) - Color system by status (green/yellow/red/blue) with legend - Optimized layout: grid without overlap, centered on the page - Detailed HoverCard and drag vs. click detection - Complete pt-BR translation and terminology "patients" instead of "events" - Integration with FullCalendar calendar endpoints --- .../app/(main-routes)/calendar/page.tsx | 8 +- .../components/ui/three-dwall-calendar.tsx | 327 ++++++++++++++---- 2 files changed, 259 insertions(+), 76 deletions(-) diff --git a/susconecta/app/(main-routes)/calendar/page.tsx b/susconecta/app/(main-routes)/calendar/page.tsx index bed1d79..0509adf 100644 --- a/susconecta/app/(main-routes)/calendar/page.tsx +++ b/susconecta/app/(main-routes)/calendar/page.tsx @@ -93,11 +93,15 @@ export default function AgendamentoPage() { 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(); + const appointmentType = obj.appointment_type ?? obj.type ?? 'Consulta'; + const title = `${patient}: ${appointmentType}`.trim(); return { id: obj.id || String(Date.now()), title, date: scheduled ? new Date(scheduled).toISOString() : new Date().toISOString(), + status: obj.status || 'pending', + patient, + type: appointmentType, }; }); setThreeDEvents(threeDEvents); @@ -225,7 +229,7 @@ export default function AgendamentoPage() { /> ) : activeTab === "3d" ? ( -
+
(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 @@ -44,6 +50,7 @@ export function ThreeDWallCalendar({ 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) // month days const days = eachDayOfInterval({ @@ -54,6 +61,16 @@ export function ThreeDWallCalendar({ 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) => { + // Só abre o dialog se não foi um drag + if (!hasDragged.current) { + setSelectedDay(day) + setIsDialogOpen(true) + } + } + // Add event handler const handleAdd = () => { if (!title.trim() || !newDate) return @@ -75,18 +92,26 @@ export function ThreeDWallCalendar({ // 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) // ✅ Correct element + ;(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 @@ -98,27 +123,53 @@ export function ThreeDWallCalendar({ return (
-
- -
{format(dateRef, "MMMM yyyy", { locale: ptBR })}
- +
+
+ +
{format(dateRef, "MMMM yyyy", { locale: ptBR })}
+ +
+ + {/* Legenda de cores */} +
+
+
+ Confirmado +
+
+
+ Pendente +
+
+
+ Cancelado +
+
+
+ Outros +
+
{/* Wall container */} -
+
+
+ 💡 Arraste para rotacionar • Scroll para inclinar +
+
handleDayClick(day)} > - - -
-
{format(day, "d")}
-
{format(day, "EEE", { locale: ptBR })}
+ + +
+
{format(day, "d")}
+
+ {dayEvents.length > 0 && `${dayEvents.length} ${dayEvents.length === 1 ? 'paciente' : 'pacientes'}`} +
+
{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 + // 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 && ( +
- - - {ev.title} - - - - - - -
-
{ev.title}
-
- {format(new Date(ev.date), "PPP p", { locale: ptBR })} -
-
- {onRemoveEvent && ( - - )} -
-
-
- + + Remover + + )} +
+ +
) })}
- -
- {dayEvents.length} evento(s) -
@@ -220,13 +311,101 @@ export function ThreeDWallCalendar({ })}
+
+ {/* Dialog de detalhes do dia */} + + + + + {selectedDay && format(selectedDay, "dd 'de' MMMM 'de' yyyy", { locale: ptBR })} + + + {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 && ( + + )} +
+
+
+ ) + }) + )} +
+
+
+ {/* Add event form */}
- setTitle(e.target.value)} /> + setTitle(e.target.value)} /> setNewDate(e.target.value)} /> - +
)