develop #83
@ -1,138 +0,0 @@
|
|||||||
# 3D Wall Calendar Component
|
|
||||||
|
|
||||||
## 📦 Componente Integrado
|
|
||||||
|
|
||||||
Um calendário interativo 3D com efeitos de parede para visualização de eventos.
|
|
||||||
|
|
||||||
## 🎯 Localização
|
|
||||||
|
|
||||||
- **Componente**: `components/ui/three-dwall-calendar.tsx`
|
|
||||||
- **Página**: `app/(main-routes)/calendar/page.tsx`
|
|
||||||
|
|
||||||
## 🚀 Funcionalidades
|
|
||||||
|
|
||||||
- ✅ Visualização 3D interativa com efeito de perspectiva
|
|
||||||
- ✅ Controle de rotação via mouse (drag) e scroll
|
|
||||||
- ✅ Navegação entre meses
|
|
||||||
- ✅ Adição e remoção de eventos
|
|
||||||
- ✅ Visualização de eventos por dia
|
|
||||||
- ✅ Popover com detalhes do evento
|
|
||||||
- ✅ Hover card para preview rápido
|
|
||||||
- ✅ Suporte a localização pt-BR
|
|
||||||
- ✅ Design responsivo
|
|
||||||
|
|
||||||
## 🎮 Como Usar
|
|
||||||
|
|
||||||
### Na Página de Calendário
|
|
||||||
|
|
||||||
Acesse a página de calendário e clique no botão **"3D"** ou pressione a tecla **"3"** para alternar para a visualização 3D.
|
|
||||||
|
|
||||||
### Atalhos de Teclado
|
|
||||||
|
|
||||||
- **C**: Calendário tradicional (FullCalendar)
|
|
||||||
- **3**: Calendário 3D
|
|
||||||
- **F**: Fila de espera
|
|
||||||
|
|
||||||
### Interação 3D
|
|
||||||
|
|
||||||
- **Arrastar (drag)**: Rotaciona o calendário
|
|
||||||
- **Scroll do mouse**: Ajusta a inclinação vertical/horizontal
|
|
||||||
- **Clique nos eventos**: Abre detalhes com opção de remover
|
|
||||||
|
|
||||||
## 📝 API do Componente
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
interface CalendarEvent {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
date: string // ISO format
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThreeDWallCalendarProps {
|
|
||||||
events: CalendarEvent[]
|
|
||||||
onAddEvent?: (e: CalendarEvent) => 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<CalendarEvent[]>([])
|
|
||||||
|
|
||||||
const handleAddEvent = (event: CalendarEvent) => {
|
|
||||||
setEvents((prev) => [...prev, event])
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveEvent = (id: string) => {
|
|
||||||
setEvents((prev) => prev.filter((e) => e.id !== id))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThreeDWallCalendar
|
|
||||||
events={events}
|
|
||||||
onAddEvent={handleAddEvent}
|
|
||||||
onRemoveEvent={handleRemoveEvent}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🐛 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
|
|
||||||
@ -51,6 +51,7 @@ export function ThreeDWallCalendar({
|
|||||||
const isDragging = React.useRef(false)
|
const isDragging = React.useRef(false)
|
||||||
const dragStart = React.useRef<{ x: number; y: number } | null>(null)
|
const dragStart = React.useRef<{ x: number; y: number } | null>(null)
|
||||||
const hasDragged = React.useRef(false)
|
const hasDragged = React.useRef(false)
|
||||||
|
const clickStart = React.useRef<{ x: number; y: number } | null>(null)
|
||||||
|
|
||||||
// month days
|
// month days
|
||||||
const days = eachDayOfInterval({
|
const days = eachDayOfInterval({
|
||||||
@ -64,12 +65,10 @@ export function ThreeDWallCalendar({
|
|||||||
const selectedDayEvents = selectedDay ? eventsForDay(selectedDay) : []
|
const selectedDayEvents = selectedDay ? eventsForDay(selectedDay) : []
|
||||||
|
|
||||||
const handleDayClick = (day: Date) => {
|
const handleDayClick = (day: Date) => {
|
||||||
// Só abre o dialog se não foi um drag
|
console.log('Day clicked:', format(day, 'dd/MM/yyyy'))
|
||||||
if (!hasDragged.current) {
|
|
||||||
setSelectedDay(day)
|
setSelectedDay(day)
|
||||||
setIsDialogOpen(true)
|
setIsDialogOpen(true)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Add event handler
|
// Add event handler
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
@ -115,6 +114,10 @@ export function ThreeDWallCalendar({
|
|||||||
const onPointerUp = () => {
|
const onPointerUp = () => {
|
||||||
isDragging.current = false
|
isDragging.current = false
|
||||||
dragStart.current = null
|
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 gap = 12
|
||||||
@ -132,6 +135,16 @@ export function ThreeDWallCalendar({
|
|||||||
<Button onClick={() => setDateRef((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1))}>
|
<Button onClick={() => setDateRef((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1))}>
|
||||||
Próximo Mês
|
Próximo Mês
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Botão Pacientes de hoje */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedDay(new Date())
|
||||||
|
setIsDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Pacientes de hoje
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legenda de cores */}
|
{/* Legenda de cores */}
|
||||||
@ -168,12 +181,12 @@ export function ThreeDWallCalendar({
|
|||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
onPointerCancel={onPointerUp}
|
onPointerCancel={onPointerUp}
|
||||||
className="w-full overflow-auto"
|
className="w-full overflow-auto"
|
||||||
style={{ perspective: 1200 }}
|
style={{ perspective: 1200, maxWidth: 1100 }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
style={{
|
style={{
|
||||||
width: columns * (panelWidth + gap),
|
width: Math.max(700, columns * (panelWidth + gap)),
|
||||||
transformStyle: "preserve-3d",
|
transformStyle: "preserve-3d",
|
||||||
transform: `rotateX(${tiltX}deg) rotateY(${tiltY}deg)`,
|
transform: `rotateX(${tiltX}deg) rotateY(${tiltY}deg)`,
|
||||||
transition: "transform 120ms linear",
|
transition: "transform 120ms linear",
|
||||||
@ -204,7 +217,21 @@ export function ThreeDWallCalendar({
|
|||||||
transform: `translateZ(${z}px)`,
|
transform: `translateZ(${z}px)`,
|
||||||
zIndex: Math.round(100 - Math.abs(rowOffset)),
|
zIndex: Math.round(100 - Math.abs(rowOffset)),
|
||||||
}}
|
}}
|
||||||
onClick={() => handleDayClick(day)}
|
onPointerDown={(e) => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Card className="h-full overflow-visible hover:shadow-lg transition-shadow">
|
<Card className="h-full overflow-visible hover:shadow-lg transition-shadow">
|
||||||
<CardContent className="p-2 h-full flex flex-col">
|
<CardContent className="p-2 h-full flex flex-col">
|
||||||
@ -318,14 +345,32 @@ export function ThreeDWallCalendar({
|
|||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
{/* Navegação de dias */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setSelectedDay((prev) => prev ? new Date(prev.getFullYear(), prev.getMonth(), prev.getDate() - 1) : new Date())}
|
||||||
|
aria-label="Dia anterior"
|
||||||
|
>
|
||||||
|
❮
|
||||||
|
</Button>
|
||||||
<DialogTitle className="text-xl">
|
<DialogTitle className="text-xl">
|
||||||
{selectedDay && format(selectedDay, "dd 'de' MMMM 'de' yyyy", { locale: ptBR })}
|
{selectedDay && format(selectedDay, "dd 'de' MMMM 'de' yyyy", { locale: ptBR })}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setSelectedDay((prev) => prev ? new Date(prev.getFullYear(), prev.getMonth(), prev.getDate() + 1) : new Date())}
|
||||||
|
aria-label="Próximo dia"
|
||||||
|
>
|
||||||
|
❯
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{selectedDayEvents.length} {selectedDayEvents.length === 1 ? 'paciente agendado' : 'pacientes agendados'}
|
{selectedDayEvents.length} {selectedDayEvents.length === 1 ? 'paciente agendado' : 'pacientes agendados'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 mt-4">
|
<div className="space-y-3 mt-4">
|
||||||
{selectedDayEvents.length === 0 ? (
|
{selectedDayEvents.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
|||||||
57
susconecta/package-lock.json
generated
57
susconecta/package-lock.json
generated
@ -49,6 +49,7 @@
|
|||||||
"cmdk": "latest",
|
"cmdk": "latest",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"embla-carousel-react": "latest",
|
"embla-carousel-react": "latest",
|
||||||
|
"framer-motion": "^12.23.24",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"input-otp": "latest",
|
"input-otp": "latest",
|
||||||
"jspdf": "^3.0.3",
|
"jspdf": "^3.0.3",
|
||||||
@ -65,6 +66,7 @@
|
|||||||
"sonner": "latest",
|
"sonner": "latest",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"vaul": "latest",
|
"vaul": "latest",
|
||||||
"zod": "3.25.67"
|
"zod": "3.25.67"
|
||||||
},
|
},
|
||||||
@ -5738,6 +5740,33 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@ -7140,6 +7169,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@ -9175,6 +9219,19 @@
|
|||||||
"base64-arraybuffer": "^1.0.2"
|
"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": {
|
"node_modules/vaul": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||||
|
|||||||
@ -51,6 +51,7 @@
|
|||||||
"cmdk": "latest",
|
"cmdk": "latest",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"embla-carousel-react": "latest",
|
"embla-carousel-react": "latest",
|
||||||
|
"framer-motion": "^12.23.24",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"input-otp": "latest",
|
"input-otp": "latest",
|
||||||
"jspdf": "^3.0.3",
|
"jspdf": "^3.0.3",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user