fix(calendar): Improvements to the 3D calendar

- 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
This commit is contained in:
M-Gabrielly 2025-10-31 00:28:30 -03:00
parent 5b3faab1bd
commit d2e6d8948e
2 changed files with 259 additions and 76 deletions

View File

@ -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() {
/>
</div>
) : activeTab === "3d" ? (
<div className="flex w-full">
<div className="flex w-full justify-center">
<ThreeDWallCalendar
events={threeDEvents}
onAddEvent={handleAddEvent}

View File

@ -4,9 +4,10 @@ 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 { 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"
@ -15,6 +16,9 @@ export type CalendarEvent = {
id: string
title: string
date: string // ISO
status?: 'confirmed' | 'pending' | 'cancelled' | string
patient?: string
type?: string
}
interface ThreeDWallCalendarProps {
@ -37,6 +41,8 @@ export function ThreeDWallCalendar({
const [dateRef, setDateRef] = React.useState<Date>(new Date())
const [title, setTitle] = React.useState("")
const [newDate, setNewDate] = React.useState("")
const [selectedDay, setSelectedDay] = React.useState<Date | null>(null)
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
const wallRef = React.useRef<HTMLDivElement | null>(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 (
<div className="space-y-4">
<div className="flex gap-2 items-center">
<Button onClick={() => setDateRef((d) => new Date(d.getFullYear(), d.getMonth() - 1, 1))}>
Mês Anterior
</Button>
<div className="font-semibold">{format(dateRef, "MMMM yyyy", { locale: ptBR })}</div>
<Button onClick={() => setDateRef((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1))}>
Próximo Mês
</Button>
<div className="flex gap-4 items-center justify-between flex-wrap">
<div className="flex gap-2 items-center">
<Button onClick={() => setDateRef((d) => new Date(d.getFullYear(), d.getMonth() - 1, 1))}>
Mês Anterior
</Button>
<div className="font-semibold text-lg">{format(dateRef, "MMMM yyyy", { locale: ptBR })}</div>
<Button onClick={() => setDateRef((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1))}>
Próximo Mês
</Button>
</div>
{/* Legenda de cores */}
<div className="flex gap-3 items-center text-xs">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-green-500 dark:bg-green-600"></div>
<span>Confirmado</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-yellow-500 dark:bg-yellow-600"></div>
<span>Pendente</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500 dark:bg-red-600"></div>
<span>Cancelado</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-blue-500 dark:bg-blue-600"></div>
<span>Outros</span>
</div>
</div>
</div>
{/* Wall container */}
<div
ref={wallRef}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className="w-full overflow-auto"
style={{ perspective: 1200 }}
>
<div className="relative">
<div className="absolute top-2 left-2 z-10 bg-background/80 backdrop-blur-sm px-3 py-1.5 rounded-lg text-xs text-muted-foreground border border-border">
💡 Arraste para rotacionar Scroll para inclinar
</div>
<div
ref={wallRef}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className="w-full overflow-auto"
style={{ perspective: 1200 }}
>
<div
className="mx-auto"
style={{
@ -148,71 +199,111 @@ export function ThreeDWallCalendar({
return (
<div
key={day.toISOString()}
className="relative"
className="relative cursor-pointer"
style={{
transform: `translateZ(${z}px)`,
zIndex: Math.round(100 - Math.abs(rowOffset)),
}}
onClick={() => handleDayClick(day)}
>
<Card className="h-full overflow-visible">
<CardContent className="p-3 h-full flex flex-col">
<div className="flex justify-between items-start">
<div className="text-xs font-medium">{format(day, "d")}</div>
<div className="text-xs text-muted-foreground">{format(day, "EEE", { locale: ptBR })}</div>
<Card className="h-full overflow-visible hover:shadow-lg transition-shadow">
<CardContent className="p-2 h-full flex flex-col">
<div className="flex justify-between items-start mb-1">
<div className="text-sm font-medium">{format(day, "d")}</div>
<div className="text-[9px] text-muted-foreground">
{dayEvents.length > 0 && `${dayEvents.length} ${dayEvents.length === 1 ? 'paciente' : 'pacientes'}`}
</div>
</div>
<div className="text-[10px] text-muted-foreground mb-1">{format(day, "EEE", { locale: ptBR })}</div>
{/* events */}
<div className="relative mt-2 flex-1">
<div className="relative flex-1 min-h-0">
{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 (
<Popover key={ev.id}>
<PopoverTrigger asChild>
<HoverCard>
<HoverCardTrigger asChild>
<div
className="absolute w-7 h-7 rounded-full bg-blue-500 dark:bg-blue-600 flex items-center justify-center text-white text-[10px] cursor-pointer shadow"
style={{ left, top, transform: `translateZ(20px)` }}
<HoverCard key={ev.id} openDelay={100}>
<HoverCardTrigger asChild>
<div
className={`absolute rounded-full ${getStatusColor()} flex items-center justify-center text-white cursor-pointer shadow-sm hover:shadow-md hover:scale-110 transition-all`}
style={{
left,
top,
width: ballSize,
height: ballSize,
fontSize: Math.max(6, ballSize / 3),
transform: `translateZ(15px)`
}}
>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-64 p-3" side="top">
<div className="space-y-2">
<div className="font-semibold text-sm">{ev.title}</div>
{ev.patient && ev.type && (
<div className="text-xs space-y-1">
<div><span className="font-medium">Paciente:</span> {ev.patient}</div>
<div><span className="font-medium">Tipo:</span> {ev.type}</div>
</div>
)}
<div className="text-xs text-muted-foreground">
{format(new Date(ev.date), "PPP 'às' p", { locale: ptBR })}
</div>
{ev.status && (
<div className="text-xs">
<span className="font-medium">Status:</span>{' '}
<span className={
ev.status === 'confirmed' ? 'text-green-600 dark:text-green-400' :
ev.status === 'pending' ? 'text-yellow-600 dark:text-yellow-400' :
ev.status === 'cancelled' ? 'text-red-600 dark:text-red-400' :
''
}>
{ev.status === 'confirmed' ? 'Confirmado' :
ev.status === 'pending' ? 'Pendente' :
ev.status === 'cancelled' ? 'Cancelado' : ev.status}
</span>
</div>
)}
{onRemoveEvent && (
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-xs hover:bg-destructive/10 hover:text-destructive"
onClick={() => onRemoveEvent(ev.id)}
>
</div>
</HoverCardTrigger>
<HoverCardContent className="text-xs font-medium">
{ev.title}
</HoverCardContent>
</HoverCard>
</PopoverTrigger>
<PopoverContent className="w-48">
<Card>
<CardContent className="flex justify-between items-center p-2 text-sm">
<div>
<div className="font-medium">{ev.title}</div>
<div className="text-xs text-muted-foreground">
{format(new Date(ev.date), "PPP p", { locale: ptBR })}
</div>
</div>
{onRemoveEvent && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => onRemoveEvent(ev.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</CardContent>
</Card>
</PopoverContent>
</Popover>
<Trash2 className="h-3 w-3 mr-1" />
Remover
</Button>
)}
</div>
</HoverCardContent>
</HoverCard>
)
})}
</div>
<div className="mt-2 text-xs text-muted-foreground">
{dayEvents.length} evento(s)
</div>
</CardContent>
</Card>
</div>
@ -220,13 +311,101 @@ export function ThreeDWallCalendar({
})}
</div>
</div>
</div>
</div>
{/* Dialog de detalhes do dia */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-xl">
{selectedDay && format(selectedDay, "dd 'de' MMMM 'de' yyyy", { locale: ptBR })}
</DialogTitle>
<DialogDescription>
{selectedDayEvents.length} {selectedDayEvents.length === 1 ? 'paciente agendado' : 'pacientes agendados'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 mt-4">
{selectedDayEvents.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Nenhum paciente agendado para este dia
</div>
) : (
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 (
<Card key={ev.id} className="overflow-hidden">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<h3 className="font-semibold">{ev.patient || ev.title}</h3>
</div>
{ev.type && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-3.5 w-3.5" />
<span>{ev.type}</span>
</div>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
<span>{format(new Date(ev.date), "HH:mm", { locale: ptBR })}</span>
</div>
<Badge className={getStatusColor()}>
{getStatusText()}
</Badge>
</div>
{onRemoveEvent && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
onRemoveEvent(ev.id)
}}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
)
})
)}
</div>
</DialogContent>
</Dialog>
{/* Add event form */}
<div className="flex gap-2 items-center">
<Input placeholder="Título do evento" value={title} onChange={(e) => setTitle(e.target.value)} />
<Input placeholder="Nome do paciente" value={title} onChange={(e) => setTitle(e.target.value)} />
<Input type="date" value={newDate} onChange={(e) => setNewDate(e.target.value)} />
<Button onClick={handleAdd}>Adicionar Evento</Button>
<Button onClick={handleAdd}>Adicionar Paciente</Button>
</div>
</div>
)