@moontra/moonui-pro 2.16.0 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +129 -17
- package/dist/index.mjs +1128 -155
- package/package.json +3 -1
- package/src/components/calendar-pro/index.tsx +1556 -0
- package/src/components/index.ts +3 -0
|
@@ -0,0 +1,1556 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|
5
|
+
import {
|
|
6
|
+
Calendar as CalendarIcon,
|
|
7
|
+
ChevronLeft,
|
|
8
|
+
ChevronRight,
|
|
9
|
+
Plus,
|
|
10
|
+
Search,
|
|
11
|
+
Filter,
|
|
12
|
+
MoreHorizontal,
|
|
13
|
+
Download,
|
|
14
|
+
Upload,
|
|
15
|
+
Settings,
|
|
16
|
+
X,
|
|
17
|
+
Clock,
|
|
18
|
+
MapPin,
|
|
19
|
+
Users,
|
|
20
|
+
Link,
|
|
21
|
+
Tag,
|
|
22
|
+
AlertCircle,
|
|
23
|
+
Check,
|
|
24
|
+
Edit,
|
|
25
|
+
Trash,
|
|
26
|
+
Copy,
|
|
27
|
+
Share2,
|
|
28
|
+
ExternalLink,
|
|
29
|
+
Repeat,
|
|
30
|
+
Bell,
|
|
31
|
+
Video,
|
|
32
|
+
Phone,
|
|
33
|
+
Mail,
|
|
34
|
+
FileText,
|
|
35
|
+
FileSpreadsheet,
|
|
36
|
+
FileCode,
|
|
37
|
+
User,
|
|
38
|
+
Briefcase,
|
|
39
|
+
Home,
|
|
40
|
+
Activity,
|
|
41
|
+
Cake,
|
|
42
|
+
Heart,
|
|
43
|
+
Star,
|
|
44
|
+
Flag,
|
|
45
|
+
Bookmark,
|
|
46
|
+
Archive,
|
|
47
|
+
Send,
|
|
48
|
+
Globe,
|
|
49
|
+
Zap,
|
|
50
|
+
Cpu,
|
|
51
|
+
Cloud,
|
|
52
|
+
Sun,
|
|
53
|
+
Moon,
|
|
54
|
+
Coffee,
|
|
55
|
+
Gift,
|
|
56
|
+
Plane,
|
|
57
|
+
Car,
|
|
58
|
+
Printer,
|
|
59
|
+
Music,
|
|
60
|
+
Camera,
|
|
61
|
+
Book,
|
|
62
|
+
Gamepad2,
|
|
63
|
+
Tv,
|
|
64
|
+
Bike,
|
|
65
|
+
Train,
|
|
66
|
+
Ship,
|
|
67
|
+
Rocket,
|
|
68
|
+
Mountain,
|
|
69
|
+
Trees,
|
|
70
|
+
CloudRain,
|
|
71
|
+
CloudSnow,
|
|
72
|
+
Sunrise,
|
|
73
|
+
Sunset,
|
|
74
|
+
Wind,
|
|
75
|
+
Droplets,
|
|
76
|
+
Thermometer,
|
|
77
|
+
Eye,
|
|
78
|
+
EyeOff,
|
|
79
|
+
Lock,
|
|
80
|
+
Unlock,
|
|
81
|
+
Shield,
|
|
82
|
+
ShieldAlert,
|
|
83
|
+
ShieldCheck,
|
|
84
|
+
CircleCheck,
|
|
85
|
+
CircleX,
|
|
86
|
+
CircleAlert,
|
|
87
|
+
Info,
|
|
88
|
+
HelpCircle,
|
|
89
|
+
FileImage,
|
|
90
|
+
FileVideo,
|
|
91
|
+
FileAudio,
|
|
92
|
+
FolderOpen,
|
|
93
|
+
Database,
|
|
94
|
+
Server,
|
|
95
|
+
Monitor,
|
|
96
|
+
Smartphone,
|
|
97
|
+
Tablet,
|
|
98
|
+
Watch,
|
|
99
|
+
Headphones,
|
|
100
|
+
Speaker,
|
|
101
|
+
Mic,
|
|
102
|
+
MicOff,
|
|
103
|
+
Volume2,
|
|
104
|
+
VolumeX,
|
|
105
|
+
Wifi,
|
|
106
|
+
WifiOff,
|
|
107
|
+
Bluetooth,
|
|
108
|
+
Battery,
|
|
109
|
+
BatteryLow,
|
|
110
|
+
Power,
|
|
111
|
+
Plug,
|
|
112
|
+
Lightbulb,
|
|
113
|
+
Flashlight,
|
|
114
|
+
Sparkles,
|
|
115
|
+
Stars,
|
|
116
|
+
Crown,
|
|
117
|
+
Trophy,
|
|
118
|
+
Medal,
|
|
119
|
+
Award,
|
|
120
|
+
Stamp,
|
|
121
|
+
Ticket,
|
|
122
|
+
Receipt,
|
|
123
|
+
CreditCard,
|
|
124
|
+
Wallet,
|
|
125
|
+
DollarSign,
|
|
126
|
+
Euro,
|
|
127
|
+
IndianRupee,
|
|
128
|
+
Bitcoin,
|
|
129
|
+
Coins,
|
|
130
|
+
PiggyBank,
|
|
131
|
+
Calculator,
|
|
132
|
+
BarChart,
|
|
133
|
+
LineChart,
|
|
134
|
+
PieChart,
|
|
135
|
+
TrendingUp,
|
|
136
|
+
TrendingDown,
|
|
137
|
+
Target,
|
|
138
|
+
Crosshair,
|
|
139
|
+
Compass,
|
|
140
|
+
Map,
|
|
141
|
+
Navigation,
|
|
142
|
+
Milestone,
|
|
143
|
+
Signpost,
|
|
144
|
+
Construction,
|
|
145
|
+
Hammer,
|
|
146
|
+
Wrench,
|
|
147
|
+
Paintbrush,
|
|
148
|
+
Palette as PaletteIcon,
|
|
149
|
+
Layers,
|
|
150
|
+
Layout,
|
|
151
|
+
Grid,
|
|
152
|
+
Columns,
|
|
153
|
+
Rows,
|
|
154
|
+
PanelLeft,
|
|
155
|
+
PanelRight,
|
|
156
|
+
PanelTop,
|
|
157
|
+
PanelBottom,
|
|
158
|
+
Sidebar,
|
|
159
|
+
Terminal,
|
|
160
|
+
Code,
|
|
161
|
+
CodeSquare,
|
|
162
|
+
Binary,
|
|
163
|
+
Braces,
|
|
164
|
+
Brackets,
|
|
165
|
+
Hash,
|
|
166
|
+
Bug,
|
|
167
|
+
GitBranch,
|
|
168
|
+
GitCommit,
|
|
169
|
+
GitMerge,
|
|
170
|
+
GitPullRequest,
|
|
171
|
+
Github,
|
|
172
|
+
Gitlab,
|
|
173
|
+
Package,
|
|
174
|
+
Box,
|
|
175
|
+
Archive as ArchiveIcon,
|
|
176
|
+
SendHorizontal,
|
|
177
|
+
Reply,
|
|
178
|
+
Forward,
|
|
179
|
+
Undo,
|
|
180
|
+
Redo,
|
|
181
|
+
RotateCw,
|
|
182
|
+
RotateCcw,
|
|
183
|
+
Shuffle,
|
|
184
|
+
Play,
|
|
185
|
+
Pause,
|
|
186
|
+
Square,
|
|
187
|
+
Circle,
|
|
188
|
+
Triangle,
|
|
189
|
+
Hexagon,
|
|
190
|
+
Diamond,
|
|
191
|
+
Gem,
|
|
192
|
+
Shapes,
|
|
193
|
+
Brush,
|
|
194
|
+
Eraser,
|
|
195
|
+
Pen,
|
|
196
|
+
PenTool,
|
|
197
|
+
Pencil,
|
|
198
|
+
Highlighter,
|
|
199
|
+
Type,
|
|
200
|
+
Bold,
|
|
201
|
+
Italic,
|
|
202
|
+
Underline,
|
|
203
|
+
Strikethrough,
|
|
204
|
+
AlignLeft,
|
|
205
|
+
AlignCenter,
|
|
206
|
+
AlignRight,
|
|
207
|
+
AlignJustify,
|
|
208
|
+
Indent,
|
|
209
|
+
Outdent,
|
|
210
|
+
ListOrdered,
|
|
211
|
+
ListTree,
|
|
212
|
+
ListChecks,
|
|
213
|
+
ListX,
|
|
214
|
+
CheckSquare,
|
|
215
|
+
Square as SquareIcon,
|
|
216
|
+
CircleDot,
|
|
217
|
+
CircleDashed,
|
|
218
|
+
CircleEllipsis,
|
|
219
|
+
CirclePlus,
|
|
220
|
+
CircleMinus,
|
|
221
|
+
CircleEqual,
|
|
222
|
+
CircleSlash,
|
|
223
|
+
CircleSlash2,
|
|
224
|
+
CircleOff,
|
|
225
|
+
CircleUser,
|
|
226
|
+
CircleUserRound,
|
|
227
|
+
CircleFadingPlus,
|
|
228
|
+
UserPlus,
|
|
229
|
+
UserMinus,
|
|
230
|
+
UserCheck,
|
|
231
|
+
UserX,
|
|
232
|
+
UsersRound,
|
|
233
|
+
Building,
|
|
234
|
+
Building2,
|
|
235
|
+
Warehouse,
|
|
236
|
+
Factory,
|
|
237
|
+
Store,
|
|
238
|
+
ShoppingBag,
|
|
239
|
+
Package2,
|
|
240
|
+
Truck,
|
|
241
|
+
Ship as ShipIcon,
|
|
242
|
+
Anchor,
|
|
243
|
+
Waves,
|
|
244
|
+
Fish,
|
|
245
|
+
Shell,
|
|
246
|
+
Bug as BugIcon,
|
|
247
|
+
Bird,
|
|
248
|
+
GraduationCap,
|
|
249
|
+
ClipboardList,
|
|
250
|
+
ShoppingCart
|
|
251
|
+
} from 'lucide-react'
|
|
252
|
+
import { cn } from '../../lib/utils'
|
|
253
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'
|
|
254
|
+
import { Button } from '../ui/button'
|
|
255
|
+
import { Badge } from '../ui/badge'
|
|
256
|
+
import { Input } from '../ui/input'
|
|
257
|
+
import { Label } from '../ui/label'
|
|
258
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
|
259
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'
|
|
260
|
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog'
|
|
261
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
|
|
262
|
+
import { Textarea } from '../ui/textarea'
|
|
263
|
+
import { Switch } from '../ui/switch'
|
|
264
|
+
import { Checkbox } from '../ui/checkbox'
|
|
265
|
+
import { RadioGroup, RadioGroupItem } from '../ui/radio-group'
|
|
266
|
+
import { Separator } from '../ui/separator'
|
|
267
|
+
import { ScrollArea } from '../ui/scroll-area'
|
|
268
|
+
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'
|
|
269
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
|
270
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '../ui/dropdown-menu'
|
|
271
|
+
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../ui/command'
|
|
272
|
+
import { motion, AnimatePresence, LayoutGroup, useMotionValue, useTransform, animate } from 'framer-motion'
|
|
273
|
+
import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, addWeeks, addMonths, addYears, subMonths, subYears, isSameMonth, isSameDay, isToday, isBefore, isAfter, differenceInDays, differenceInWeeks, differenceInMonths, parseISO, startOfDay, endOfDay, isWithinInterval, eachDayOfInterval, getDay, setHours, setMinutes, startOfYear, endOfYear, eachMonthOfInterval, getDaysInMonth, getWeek, startOfISOWeek, endOfISOWeek, addHours, subHours, isSameHour, differenceInHours, differenceInMinutes, addMinutes, subMinutes, isSameMinute, setSeconds, setMilliseconds, subDays, subWeeks } from 'date-fns'
|
|
274
|
+
import { DragDropContext, Droppable, Draggable, DropResult, DraggableProvided, DraggableStateSnapshot, DroppableProvided } from 'react-beautiful-dnd'
|
|
275
|
+
import { Calendar as CalendarBase } from '../ui/calendar'
|
|
276
|
+
import { HexColorPicker } from 'react-colorful'
|
|
277
|
+
|
|
278
|
+
// Types
|
|
279
|
+
export interface CalendarEvent {
|
|
280
|
+
id: string
|
|
281
|
+
title: string
|
|
282
|
+
description?: string
|
|
283
|
+
start: Date
|
|
284
|
+
end: Date
|
|
285
|
+
allDay?: boolean
|
|
286
|
+
color?: string
|
|
287
|
+
category?: string
|
|
288
|
+
location?: string
|
|
289
|
+
attendees?: Array<{
|
|
290
|
+
id: string
|
|
291
|
+
name: string
|
|
292
|
+
email: string
|
|
293
|
+
avatar?: string
|
|
294
|
+
status?: 'accepted' | 'declined' | 'tentative' | 'pending'
|
|
295
|
+
}>
|
|
296
|
+
reminders?: Array<{
|
|
297
|
+
type: 'email' | 'notification' | 'sms'
|
|
298
|
+
before: number // minutes before event
|
|
299
|
+
}>
|
|
300
|
+
recurring?: {
|
|
301
|
+
pattern: 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'
|
|
302
|
+
interval: number
|
|
303
|
+
endDate?: Date
|
|
304
|
+
endAfter?: number // number of occurrences
|
|
305
|
+
daysOfWeek?: number[] // 0-6 for weekly pattern
|
|
306
|
+
dayOfMonth?: number // for monthly pattern
|
|
307
|
+
monthOfYear?: number // for yearly pattern
|
|
308
|
+
exceptions?: Date[] // dates to skip
|
|
309
|
+
}
|
|
310
|
+
status?: 'confirmed' | 'tentative' | 'cancelled'
|
|
311
|
+
visibility?: 'public' | 'private' | 'confidential'
|
|
312
|
+
priority?: 'low' | 'medium' | 'high'
|
|
313
|
+
tags?: string[]
|
|
314
|
+
attachments?: Array<{
|
|
315
|
+
id: string
|
|
316
|
+
name: string
|
|
317
|
+
url: string
|
|
318
|
+
type: string
|
|
319
|
+
size: number
|
|
320
|
+
}>
|
|
321
|
+
meetingUrl?: string
|
|
322
|
+
phoneNumber?: string
|
|
323
|
+
reminder?: boolean
|
|
324
|
+
reminderTime?: number
|
|
325
|
+
notes?: string
|
|
326
|
+
createdAt?: Date
|
|
327
|
+
updatedAt?: Date
|
|
328
|
+
createdBy?: string
|
|
329
|
+
updatedBy?: string
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export interface CalendarCategory {
|
|
333
|
+
id: string
|
|
334
|
+
name: string
|
|
335
|
+
color: string
|
|
336
|
+
icon?: React.ReactNode
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export interface CalendarView {
|
|
340
|
+
id: string
|
|
341
|
+
name: string
|
|
342
|
+
type: 'day' | 'week' | 'month' | 'year' | 'agenda' | 'custom'
|
|
343
|
+
default?: boolean
|
|
344
|
+
config?: any
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export interface CalendarProProps {
|
|
348
|
+
events?: CalendarEvent[]
|
|
349
|
+
categories?: CalendarCategory[]
|
|
350
|
+
views?: CalendarView[]
|
|
351
|
+
defaultView?: string
|
|
352
|
+
height?: string | number
|
|
353
|
+
className?: string
|
|
354
|
+
sidebarCollapsed?: boolean
|
|
355
|
+
onSidebarToggle?: (collapsed: boolean) => void
|
|
356
|
+
onEventClick?: (event: CalendarEvent, e: React.MouseEvent) => void
|
|
357
|
+
onEventCreate?: (event: Partial<CalendarEvent>) => void
|
|
358
|
+
onEventUpdate?: (event: CalendarEvent) => void
|
|
359
|
+
onEventDelete?: (eventId: string) => void
|
|
360
|
+
onEventDrop?: (eventId: string, start: Date, end: Date) => void
|
|
361
|
+
onDateSelect?: (date: Date) => void
|
|
362
|
+
onViewChange?: (viewId: string) => void
|
|
363
|
+
allowEventCreation?: boolean
|
|
364
|
+
allowEventDeletion?: boolean
|
|
365
|
+
allowEventDragging?: boolean
|
|
366
|
+
showWeekNumbers?: boolean
|
|
367
|
+
firstDayOfWeek?: 0 | 1 | 2 | 3 | 4 | 5 | 6
|
|
368
|
+
timeFormat?: '12h' | '24h'
|
|
369
|
+
locale?: string
|
|
370
|
+
workingHours?: { start: number; end: number }
|
|
371
|
+
nonWorkingDays?: number[]
|
|
372
|
+
holidays?: Array<{ date: Date; name: string }>
|
|
373
|
+
customEventRenderer?: (event: CalendarEvent, view: string) => React.ReactNode
|
|
374
|
+
customHeaderRenderer?: (date: Date, view: string) => React.ReactNode
|
|
375
|
+
customCellRenderer?: (date: Date, events: CalendarEvent[], view: string) => React.ReactNode
|
|
376
|
+
eventColors?: Record<string, string>
|
|
377
|
+
onExport?: (format: 'ics' | 'csv' | 'json', events: CalendarEvent[]) => void
|
|
378
|
+
onImport?: (file: File) => void
|
|
379
|
+
integrations?: {
|
|
380
|
+
google?: boolean
|
|
381
|
+
outlook?: boolean
|
|
382
|
+
apple?: boolean
|
|
383
|
+
}
|
|
384
|
+
theme?: 'light' | 'dark' | 'system'
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Default categories
|
|
388
|
+
const defaultCategories: CalendarCategory[] = [
|
|
389
|
+
{ id: 'personal', name: 'Personal', color: '#3b82f6', icon: <User className="h-4 w-4" /> },
|
|
390
|
+
{ id: 'work', name: 'Work', color: '#10b981', icon: <Briefcase className="h-4 w-4" /> },
|
|
391
|
+
{ id: 'meeting', name: 'Meeting', color: '#f59e0b', icon: <Users className="h-4 w-4" /> },
|
|
392
|
+
{ id: 'task', name: 'Task', color: '#8b5cf6', icon: <ClipboardList className="h-4 w-4" /> },
|
|
393
|
+
{ id: 'reminder', name: 'Reminder', color: '#ef4444', icon: <Bell className="h-4 w-4" /> },
|
|
394
|
+
{ id: 'holiday', name: 'Holiday', color: '#ec4899', icon: <CalendarIcon className="h-4 w-4" /> },
|
|
395
|
+
{ id: 'birthday', name: 'Birthday', color: '#f472b6', icon: <Cake className="h-4 w-4" /> },
|
|
396
|
+
{ id: 'other', name: 'Other', color: '#6b7280', icon: <Tag className="h-4 w-4" /> }
|
|
397
|
+
]
|
|
398
|
+
|
|
399
|
+
// Default views
|
|
400
|
+
const defaultViews: CalendarView[] = [
|
|
401
|
+
{ id: 'day', name: 'Day', type: 'day' },
|
|
402
|
+
{ id: 'week', name: 'Week', type: 'week', default: true },
|
|
403
|
+
{ id: 'month', name: 'Month', type: 'month' },
|
|
404
|
+
{ id: 'year', name: 'Year', type: 'year' },
|
|
405
|
+
{ id: 'agenda', name: 'Agenda', type: 'agenda' }
|
|
406
|
+
]
|
|
407
|
+
|
|
408
|
+
export const CalendarPro = React.forwardRef<HTMLDivElement, CalendarProProps>(({
|
|
409
|
+
events = [],
|
|
410
|
+
categories = defaultCategories,
|
|
411
|
+
views = defaultViews,
|
|
412
|
+
defaultView,
|
|
413
|
+
height = '100%',
|
|
414
|
+
className,
|
|
415
|
+
sidebarCollapsed: controlledSidebarCollapsed,
|
|
416
|
+
onSidebarToggle,
|
|
417
|
+
onEventClick,
|
|
418
|
+
onEventCreate,
|
|
419
|
+
onEventUpdate,
|
|
420
|
+
onEventDelete,
|
|
421
|
+
onEventDrop,
|
|
422
|
+
onDateSelect,
|
|
423
|
+
onViewChange,
|
|
424
|
+
allowEventCreation = true,
|
|
425
|
+
allowEventDeletion = true,
|
|
426
|
+
allowEventDragging = true,
|
|
427
|
+
showWeekNumbers = true,
|
|
428
|
+
firstDayOfWeek = 1,
|
|
429
|
+
timeFormat = '24h',
|
|
430
|
+
locale = 'en-US',
|
|
431
|
+
workingHours = { start: 9, end: 17 },
|
|
432
|
+
nonWorkingDays = [0, 6],
|
|
433
|
+
holidays = [],
|
|
434
|
+
customEventRenderer,
|
|
435
|
+
customHeaderRenderer,
|
|
436
|
+
customCellRenderer,
|
|
437
|
+
eventColors = {},
|
|
438
|
+
onExport,
|
|
439
|
+
onImport,
|
|
440
|
+
integrations = {},
|
|
441
|
+
theme = 'system',
|
|
442
|
+
...props
|
|
443
|
+
}, ref) => {
|
|
444
|
+
const [internalSidebarCollapsed, setInternalSidebarCollapsed] = useState(false)
|
|
445
|
+
const sidebarCollapsed = controlledSidebarCollapsed ?? internalSidebarCollapsed
|
|
446
|
+
|
|
447
|
+
const setSidebarCollapsed = useCallback((collapsed: boolean) => {
|
|
448
|
+
setInternalSidebarCollapsed(collapsed)
|
|
449
|
+
onSidebarToggle?.(collapsed)
|
|
450
|
+
}, [onSidebarToggle])
|
|
451
|
+
|
|
452
|
+
const [currentDate, setCurrentDate] = useState(new Date())
|
|
453
|
+
const [currentView, setCurrentView] = useState(() => {
|
|
454
|
+
if (defaultView) {
|
|
455
|
+
return defaultView
|
|
456
|
+
}
|
|
457
|
+
const defaultViewObj = views.find(v => v.default)
|
|
458
|
+
return defaultViewObj ? defaultViewObj.id : views[0].id
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
|
|
462
|
+
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
|
|
463
|
+
const [isEventDialogOpen, setIsEventDialogOpen] = useState(false)
|
|
464
|
+
const [isCreating, setIsCreating] = useState(false)
|
|
465
|
+
const [newEventStart, setNewEventStart] = useState<Date | null>(null)
|
|
466
|
+
const [newEventEnd, setNewEventEnd] = useState<Date | null>(null)
|
|
467
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
468
|
+
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
|
|
469
|
+
const [editingEvent, setEditingEvent] = useState<Partial<CalendarEvent>>({})
|
|
470
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
471
|
+
const [draggedEvent, setDraggedEvent] = useState<CalendarEvent | null>(null)
|
|
472
|
+
const [dropTarget, setDropTarget] = useState<{ date: Date; time?: string } | null>(null)
|
|
473
|
+
|
|
474
|
+
const calendarRef = useRef<HTMLDivElement>(null)
|
|
475
|
+
|
|
476
|
+
// Get current view config
|
|
477
|
+
const currentViewConfig = useMemo(() => {
|
|
478
|
+
return views.find(v => v.id === currentView) || views[0]
|
|
479
|
+
}, [currentView, views])
|
|
480
|
+
|
|
481
|
+
// Filter events based on search and categories
|
|
482
|
+
const filteredEvents = useMemo(() => {
|
|
483
|
+
return events.filter(event => {
|
|
484
|
+
const matchesSearch = !searchQuery ||
|
|
485
|
+
event.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
486
|
+
event.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
487
|
+
event.location?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
488
|
+
event.tags?.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
489
|
+
|
|
490
|
+
const matchesCategory = selectedCategories.length === 0 ||
|
|
491
|
+
(event.category && selectedCategories.includes(event.category))
|
|
492
|
+
|
|
493
|
+
return matchesSearch && matchesCategory
|
|
494
|
+
})
|
|
495
|
+
}, [events, searchQuery, selectedCategories])
|
|
496
|
+
|
|
497
|
+
// Get events for current view
|
|
498
|
+
const eventsInView = useMemo(() => {
|
|
499
|
+
const viewType = currentViewConfig.type
|
|
500
|
+
let start: Date
|
|
501
|
+
let end: Date
|
|
502
|
+
|
|
503
|
+
switch (viewType) {
|
|
504
|
+
case 'day':
|
|
505
|
+
start = startOfDay(currentDate)
|
|
506
|
+
end = endOfDay(currentDate)
|
|
507
|
+
break
|
|
508
|
+
case 'week':
|
|
509
|
+
start = startOfWeek(currentDate, { weekStartsOn: firstDayOfWeek })
|
|
510
|
+
end = endOfWeek(currentDate, { weekStartsOn: firstDayOfWeek })
|
|
511
|
+
break
|
|
512
|
+
case 'month':
|
|
513
|
+
start = startOfMonth(currentDate)
|
|
514
|
+
end = endOfMonth(currentDate)
|
|
515
|
+
break
|
|
516
|
+
case 'year':
|
|
517
|
+
start = startOfYear(currentDate)
|
|
518
|
+
end = endOfYear(currentDate)
|
|
519
|
+
break
|
|
520
|
+
case 'agenda':
|
|
521
|
+
start = startOfDay(currentDate)
|
|
522
|
+
end = addDays(currentDate, 30)
|
|
523
|
+
break
|
|
524
|
+
default:
|
|
525
|
+
start = startOfDay(currentDate)
|
|
526
|
+
end = endOfDay(currentDate)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return filteredEvents.filter(event => {
|
|
530
|
+
const eventStart = new Date(event.start)
|
|
531
|
+
const eventEnd = new Date(event.end)
|
|
532
|
+
return isWithinInterval(eventStart, { start, end }) ||
|
|
533
|
+
isWithinInterval(eventEnd, { start, end }) ||
|
|
534
|
+
(isBefore(eventStart, start) && isAfter(eventEnd, end))
|
|
535
|
+
})
|
|
536
|
+
}, [filteredEvents, currentDate, currentViewConfig, firstDayOfWeek])
|
|
537
|
+
|
|
538
|
+
// Handle view change
|
|
539
|
+
const handleViewChange = useCallback((viewId: string) => {
|
|
540
|
+
setCurrentView(viewId)
|
|
541
|
+
onViewChange?.(viewId)
|
|
542
|
+
}, [onViewChange])
|
|
543
|
+
|
|
544
|
+
// Handle date navigation
|
|
545
|
+
const navigateDate = useCallback((direction: 'prev' | 'next') => {
|
|
546
|
+
const viewType = currentViewConfig.type
|
|
547
|
+
let newDate: Date
|
|
548
|
+
|
|
549
|
+
switch (viewType) {
|
|
550
|
+
case 'day':
|
|
551
|
+
newDate = direction === 'prev' ? subDays(currentDate, 1) : addDays(currentDate, 1)
|
|
552
|
+
break
|
|
553
|
+
case 'week':
|
|
554
|
+
newDate = direction === 'prev' ? subWeeks(currentDate, 1) : addWeeks(currentDate, 1)
|
|
555
|
+
break
|
|
556
|
+
case 'month':
|
|
557
|
+
newDate = direction === 'prev' ? subMonths(currentDate, 1) : addMonths(currentDate, 1)
|
|
558
|
+
break
|
|
559
|
+
case 'year':
|
|
560
|
+
newDate = direction === 'prev' ? subYears(currentDate, 1) : addYears(currentDate, 1)
|
|
561
|
+
break
|
|
562
|
+
default:
|
|
563
|
+
newDate = currentDate
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
setCurrentDate(newDate)
|
|
567
|
+
}, [currentDate, currentViewConfig])
|
|
568
|
+
|
|
569
|
+
// Handle date selection
|
|
570
|
+
const handleDateSelect = useCallback((date: Date) => {
|
|
571
|
+
setSelectedDate(date)
|
|
572
|
+
setCurrentDate(date)
|
|
573
|
+
onDateSelect?.(date)
|
|
574
|
+
|
|
575
|
+
if (allowEventCreation && currentViewConfig.type !== 'year') {
|
|
576
|
+
setNewEventStart(date)
|
|
577
|
+
setNewEventEnd(addHours(date, 1))
|
|
578
|
+
setIsCreating(true)
|
|
579
|
+
setIsEventDialogOpen(true)
|
|
580
|
+
}
|
|
581
|
+
}, [allowEventCreation, currentViewConfig, onDateSelect])
|
|
582
|
+
|
|
583
|
+
// Handle event click
|
|
584
|
+
const handleEventClick = useCallback((event: CalendarEvent, e: React.MouseEvent) => {
|
|
585
|
+
e.stopPropagation()
|
|
586
|
+
setSelectedEvent(event)
|
|
587
|
+
setEditingEvent(event)
|
|
588
|
+
setIsEventDialogOpen(true)
|
|
589
|
+
setIsCreating(false)
|
|
590
|
+
onEventClick?.(event, e)
|
|
591
|
+
}, [onEventClick])
|
|
592
|
+
|
|
593
|
+
// Handle event save
|
|
594
|
+
const handleEventSave = useCallback(() => {
|
|
595
|
+
if (isCreating && onEventCreate) {
|
|
596
|
+
onEventCreate(editingEvent)
|
|
597
|
+
} else if (selectedEvent && onEventUpdate) {
|
|
598
|
+
onEventUpdate({ ...selectedEvent, ...editingEvent } as CalendarEvent)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
setIsEventDialogOpen(false)
|
|
602
|
+
setSelectedEvent(null)
|
|
603
|
+
setEditingEvent({})
|
|
604
|
+
setIsCreating(false)
|
|
605
|
+
setNewEventStart(null)
|
|
606
|
+
setNewEventEnd(null)
|
|
607
|
+
}, [isCreating, selectedEvent, editingEvent, onEventCreate, onEventUpdate])
|
|
608
|
+
|
|
609
|
+
// Handle event delete
|
|
610
|
+
const handleEventDelete = useCallback(() => {
|
|
611
|
+
if (selectedEvent && onEventDelete) {
|
|
612
|
+
onEventDelete(selectedEvent.id)
|
|
613
|
+
setIsEventDialogOpen(false)
|
|
614
|
+
setSelectedEvent(null)
|
|
615
|
+
setEditingEvent({})
|
|
616
|
+
}
|
|
617
|
+
}, [selectedEvent, onEventDelete])
|
|
618
|
+
|
|
619
|
+
// Handle drag start
|
|
620
|
+
const handleDragStart = useCallback((event: CalendarEvent) => {
|
|
621
|
+
if (!allowEventDragging) return
|
|
622
|
+
setIsDragging(true)
|
|
623
|
+
setDraggedEvent(event)
|
|
624
|
+
}, [allowEventDragging])
|
|
625
|
+
|
|
626
|
+
// Handle drag end
|
|
627
|
+
const handleDragEnd = useCallback((result: DropResult) => {
|
|
628
|
+
setIsDragging(false)
|
|
629
|
+
setDraggedEvent(null)
|
|
630
|
+
setDropTarget(null)
|
|
631
|
+
|
|
632
|
+
if (!result.destination || !draggedEvent || !onEventDrop) return
|
|
633
|
+
|
|
634
|
+
// Calculate new start and end dates based on drop position
|
|
635
|
+
// This is a simplified version - you'd need to implement proper date calculation
|
|
636
|
+
// based on the view and drop position
|
|
637
|
+
const newStart = new Date(result.destination.droppableId)
|
|
638
|
+
const duration = differenceInMinutes(draggedEvent.end, draggedEvent.start)
|
|
639
|
+
const newEnd = addMinutes(newStart, duration)
|
|
640
|
+
|
|
641
|
+
onEventDrop(draggedEvent.id, newStart, newEnd)
|
|
642
|
+
}, [draggedEvent, onEventDrop])
|
|
643
|
+
|
|
644
|
+
// Export calendar
|
|
645
|
+
const exportCalendar = useCallback((format: 'ics' | 'csv' | 'json') => {
|
|
646
|
+
if (onExport) {
|
|
647
|
+
onExport(format, eventsInView)
|
|
648
|
+
} else {
|
|
649
|
+
// Default export implementation
|
|
650
|
+
const data = eventsInView
|
|
651
|
+
let content: string
|
|
652
|
+
let mimeType: string
|
|
653
|
+
let filename: string
|
|
654
|
+
|
|
655
|
+
switch (format) {
|
|
656
|
+
case 'json':
|
|
657
|
+
content = JSON.stringify(data, null, 2)
|
|
658
|
+
mimeType = 'application/json'
|
|
659
|
+
filename = 'calendar.json'
|
|
660
|
+
break
|
|
661
|
+
case 'csv':
|
|
662
|
+
// Simple CSV export
|
|
663
|
+
const headers = ['Title', 'Start', 'End', 'Location', 'Description']
|
|
664
|
+
const rows = data.map(event => [
|
|
665
|
+
event.title,
|
|
666
|
+
event.start.toISOString(),
|
|
667
|
+
event.end.toISOString(),
|
|
668
|
+
event.location || '',
|
|
669
|
+
event.description || ''
|
|
670
|
+
])
|
|
671
|
+
content = [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n')
|
|
672
|
+
mimeType = 'text/csv'
|
|
673
|
+
filename = 'calendar.csv'
|
|
674
|
+
break
|
|
675
|
+
case 'ics':
|
|
676
|
+
// Simple ICS export
|
|
677
|
+
const icsEvents = data.map(event => {
|
|
678
|
+
const start = event.start.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
|
|
679
|
+
const end = event.end.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
|
|
680
|
+
return `BEGIN:VEVENT
|
|
681
|
+
UID:${event.id}@calendar.app
|
|
682
|
+
DTSTART:${start}Z
|
|
683
|
+
DTEND:${end}Z
|
|
684
|
+
SUMMARY:${event.title}
|
|
685
|
+
${event.description ? `DESCRIPTION:${event.description}` : ''}
|
|
686
|
+
${event.location ? `LOCATION:${event.location}` : ''}
|
|
687
|
+
END:VEVENT`
|
|
688
|
+
}).join('\n')
|
|
689
|
+
|
|
690
|
+
content = `BEGIN:VCALENDAR
|
|
691
|
+
VERSION:2.0
|
|
692
|
+
PRODID:-//Calendar App//EN
|
|
693
|
+
${icsEvents}
|
|
694
|
+
END:VCALENDAR`
|
|
695
|
+
mimeType = 'text/calendar'
|
|
696
|
+
filename = 'calendar.ics'
|
|
697
|
+
break
|
|
698
|
+
default:
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const blob = new Blob([content], { type: mimeType })
|
|
703
|
+
const url = URL.createObjectURL(blob)
|
|
704
|
+
const link = document.createElement('a')
|
|
705
|
+
link.href = url
|
|
706
|
+
link.download = filename
|
|
707
|
+
link.click()
|
|
708
|
+
URL.revokeObjectURL(url)
|
|
709
|
+
}
|
|
710
|
+
}, [eventsInView, onExport])
|
|
711
|
+
|
|
712
|
+
// Render day view
|
|
713
|
+
const renderDayView = () => {
|
|
714
|
+
const hours = Array.from({ length: 24 }, (_, i) => i)
|
|
715
|
+
const dayEvents = eventsInView.filter(event =>
|
|
716
|
+
isSameDay(new Date(event.start), currentDate)
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
return (
|
|
720
|
+
<div className="flex flex-1 overflow-hidden">
|
|
721
|
+
<div className="flex-1 overflow-auto">
|
|
722
|
+
<div className="min-h-full">
|
|
723
|
+
{/* All day events */}
|
|
724
|
+
<div className="border-b p-2">
|
|
725
|
+
<div className="text-xs text-muted-foreground mb-1">All Day</div>
|
|
726
|
+
<div className="space-y-1">
|
|
727
|
+
{dayEvents
|
|
728
|
+
.filter(event => event.allDay)
|
|
729
|
+
.map(event => (
|
|
730
|
+
<div
|
|
731
|
+
key={event.id}
|
|
732
|
+
className="p-2 rounded text-xs cursor-pointer hover:opacity-80"
|
|
733
|
+
style={{
|
|
734
|
+
backgroundColor: event.color || eventColors[event.category || ''] || '#3b82f6',
|
|
735
|
+
color: '#ffffff'
|
|
736
|
+
}}
|
|
737
|
+
onClick={(e) => handleEventClick(event, e)}
|
|
738
|
+
>
|
|
739
|
+
{event.title}
|
|
740
|
+
</div>
|
|
741
|
+
))}
|
|
742
|
+
</div>
|
|
743
|
+
</div>
|
|
744
|
+
|
|
745
|
+
{/* Time slots */}
|
|
746
|
+
<div className="relative">
|
|
747
|
+
{hours.map(hour => (
|
|
748
|
+
<div key={hour} className="flex border-b" style={{ height: '60px' }}>
|
|
749
|
+
<div className="w-16 p-2 text-xs text-muted-foreground text-right">
|
|
750
|
+
{format(setHours(new Date(), hour), timeFormat === '12h' ? 'h a' : 'HH:00')}
|
|
751
|
+
</div>
|
|
752
|
+
<div className="flex-1 relative border-l">
|
|
753
|
+
{/* Events in this hour */}
|
|
754
|
+
{dayEvents
|
|
755
|
+
.filter(event => {
|
|
756
|
+
if (event.allDay) return false
|
|
757
|
+
const eventHour = new Date(event.start).getHours()
|
|
758
|
+
return eventHour === hour
|
|
759
|
+
})
|
|
760
|
+
.map(event => {
|
|
761
|
+
const startMinutes = new Date(event.start).getMinutes()
|
|
762
|
+
const duration = differenceInMinutes(new Date(event.end), new Date(event.start))
|
|
763
|
+
const height = (duration / 60) * 60
|
|
764
|
+
const top = (startMinutes / 60) * 60
|
|
765
|
+
|
|
766
|
+
return (
|
|
767
|
+
<div
|
|
768
|
+
key={event.id}
|
|
769
|
+
className="absolute left-0 right-0 mx-1 p-1 rounded text-xs cursor-pointer hover:opacity-80 overflow-hidden"
|
|
770
|
+
style={{
|
|
771
|
+
top: `${top}px`,
|
|
772
|
+
height: `${height}px`,
|
|
773
|
+
backgroundColor: event.color || eventColors[event.category || ''] || '#3b82f6',
|
|
774
|
+
color: '#ffffff',
|
|
775
|
+
zIndex: 10
|
|
776
|
+
}}
|
|
777
|
+
onClick={(e) => handleEventClick(event, e)}
|
|
778
|
+
>
|
|
779
|
+
<div className="font-medium">{event.title}</div>
|
|
780
|
+
<div className="text-xs opacity-80">
|
|
781
|
+
{format(new Date(event.start), 'HH:mm')} - {format(new Date(event.end), 'HH:mm')}
|
|
782
|
+
</div>
|
|
783
|
+
</div>
|
|
784
|
+
)
|
|
785
|
+
})}
|
|
786
|
+
</div>
|
|
787
|
+
</div>
|
|
788
|
+
))}
|
|
789
|
+
</div>
|
|
790
|
+
</div>
|
|
791
|
+
</div>
|
|
792
|
+
</div>
|
|
793
|
+
)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Render week view
|
|
797
|
+
const renderWeekView = () => {
|
|
798
|
+
const weekStart = startOfWeek(currentDate, { weekStartsOn: firstDayOfWeek })
|
|
799
|
+
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
|
|
800
|
+
const hours = Array.from({ length: 24 }, (_, i) => i)
|
|
801
|
+
|
|
802
|
+
return (
|
|
803
|
+
<div className="flex flex-1 overflow-hidden">
|
|
804
|
+
<div className="flex-1 overflow-auto">
|
|
805
|
+
<div className="min-h-full">
|
|
806
|
+
{/* Header */}
|
|
807
|
+
<div className="sticky top-0 z-20 bg-background border-b">
|
|
808
|
+
<div className="flex">
|
|
809
|
+
<div className="w-16" />
|
|
810
|
+
{weekDays.map(day => (
|
|
811
|
+
<div
|
|
812
|
+
key={day.toISOString()}
|
|
813
|
+
className={cn(
|
|
814
|
+
"flex-1 p-2 text-center border-l",
|
|
815
|
+
isToday(day) && "bg-primary/10"
|
|
816
|
+
)}
|
|
817
|
+
>
|
|
818
|
+
<div className="text-xs text-muted-foreground">
|
|
819
|
+
{format(day, 'EEE')}
|
|
820
|
+
</div>
|
|
821
|
+
<div className={cn(
|
|
822
|
+
"text-lg font-medium",
|
|
823
|
+
isToday(day) && "text-primary"
|
|
824
|
+
)}>
|
|
825
|
+
{format(day, 'd')}
|
|
826
|
+
</div>
|
|
827
|
+
</div>
|
|
828
|
+
))}
|
|
829
|
+
</div>
|
|
830
|
+
</div>
|
|
831
|
+
|
|
832
|
+
{/* Time grid */}
|
|
833
|
+
<div className="relative">
|
|
834
|
+
{hours.map(hour => (
|
|
835
|
+
<div key={hour} className="flex" style={{ height: '60px' }}>
|
|
836
|
+
<div className="w-16 p-2 text-xs text-muted-foreground text-right border-b">
|
|
837
|
+
{format(setHours(new Date(), hour), timeFormat === '12h' ? 'h a' : 'HH:00')}
|
|
838
|
+
</div>
|
|
839
|
+
{weekDays.map(day => {
|
|
840
|
+
const dayEvents = eventsInView.filter(event =>
|
|
841
|
+
isSameDay(new Date(event.start), day) && !event.allDay
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
return (
|
|
845
|
+
<div
|
|
846
|
+
key={day.toISOString()}
|
|
847
|
+
className={cn(
|
|
848
|
+
"flex-1 relative border-l border-b",
|
|
849
|
+
isToday(day) && "bg-primary/5"
|
|
850
|
+
)}
|
|
851
|
+
>
|
|
852
|
+
{dayEvents
|
|
853
|
+
.filter(event => {
|
|
854
|
+
const eventHour = new Date(event.start).getHours()
|
|
855
|
+
return eventHour === hour
|
|
856
|
+
})
|
|
857
|
+
.map(event => {
|
|
858
|
+
const startMinutes = new Date(event.start).getMinutes()
|
|
859
|
+
const duration = differenceInMinutes(new Date(event.end), new Date(event.start))
|
|
860
|
+
const height = (duration / 60) * 60
|
|
861
|
+
const top = (startMinutes / 60) * 60
|
|
862
|
+
|
|
863
|
+
return (
|
|
864
|
+
<div
|
|
865
|
+
key={event.id}
|
|
866
|
+
className="absolute left-0 right-0 mx-1 p-1 rounded text-xs cursor-pointer hover:opacity-80 overflow-hidden"
|
|
867
|
+
style={{
|
|
868
|
+
top: `${top}px`,
|
|
869
|
+
height: `${height}px`,
|
|
870
|
+
backgroundColor: event.color || eventColors[event.category || ''] || '#3b82f6',
|
|
871
|
+
color: '#ffffff',
|
|
872
|
+
zIndex: 10
|
|
873
|
+
}}
|
|
874
|
+
onClick={(e) => handleEventClick(event, e)}
|
|
875
|
+
>
|
|
876
|
+
{event.title}
|
|
877
|
+
</div>
|
|
878
|
+
)
|
|
879
|
+
})}
|
|
880
|
+
</div>
|
|
881
|
+
)
|
|
882
|
+
})}
|
|
883
|
+
</div>
|
|
884
|
+
))}
|
|
885
|
+
</div>
|
|
886
|
+
</div>
|
|
887
|
+
</div>
|
|
888
|
+
</div>
|
|
889
|
+
)
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Render month view
|
|
893
|
+
const renderMonthView = () => {
|
|
894
|
+
const monthStart = startOfMonth(currentDate)
|
|
895
|
+
const monthEnd = endOfMonth(currentDate)
|
|
896
|
+
const startDate = startOfWeek(monthStart, { weekStartsOn: firstDayOfWeek })
|
|
897
|
+
const endDate = endOfWeek(monthEnd, { weekStartsOn: firstDayOfWeek })
|
|
898
|
+
const days = eachDayOfInterval({ start: startDate, end: endDate })
|
|
899
|
+
const weeks = []
|
|
900
|
+
|
|
901
|
+
for (let i = 0; i < days.length; i += 7) {
|
|
902
|
+
weeks.push(days.slice(i, i + 7))
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return (
|
|
906
|
+
<div className="flex-1 p-4 overflow-auto">
|
|
907
|
+
<div className="min-h-full">
|
|
908
|
+
{/* Weekday headers */}
|
|
909
|
+
<div className="grid grid-cols-7 gap-px mb-2">
|
|
910
|
+
{weeks[0].map(day => (
|
|
911
|
+
<div
|
|
912
|
+
key={day.toISOString()}
|
|
913
|
+
className="p-2 text-center text-sm font-medium text-muted-foreground"
|
|
914
|
+
>
|
|
915
|
+
{format(day, 'EEE')}
|
|
916
|
+
</div>
|
|
917
|
+
))}
|
|
918
|
+
</div>
|
|
919
|
+
|
|
920
|
+
{/* Calendar grid */}
|
|
921
|
+
{weeks.map((week, weekIndex) => (
|
|
922
|
+
<div key={weekIndex} className="grid grid-cols-7 gap-px">
|
|
923
|
+
{week.map(day => {
|
|
924
|
+
const dayEvents = eventsInView.filter(event =>
|
|
925
|
+
isSameDay(new Date(event.start), day)
|
|
926
|
+
)
|
|
927
|
+
const isCurrentMonth = isSameMonth(day, currentDate)
|
|
928
|
+
|
|
929
|
+
return (
|
|
930
|
+
<div
|
|
931
|
+
key={day.toISOString()}
|
|
932
|
+
className={cn(
|
|
933
|
+
"min-h-[100px] p-2 border rounded-lg cursor-pointer transition-colors",
|
|
934
|
+
!isCurrentMonth && "opacity-50",
|
|
935
|
+
isToday(day) && "bg-primary/10 border-primary",
|
|
936
|
+
"hover:bg-muted/50"
|
|
937
|
+
)}
|
|
938
|
+
onClick={() => handleDateSelect(day)}
|
|
939
|
+
>
|
|
940
|
+
<div className={cn(
|
|
941
|
+
"text-sm font-medium mb-1",
|
|
942
|
+
isToday(day) && "text-primary"
|
|
943
|
+
)}>
|
|
944
|
+
{format(day, 'd')}
|
|
945
|
+
</div>
|
|
946
|
+
<div className="space-y-1">
|
|
947
|
+
{dayEvents.slice(0, 3).map(event => {
|
|
948
|
+
return (
|
|
949
|
+
<motion.div
|
|
950
|
+
key={event.id}
|
|
951
|
+
className="text-xs p-1 rounded cursor-pointer truncate"
|
|
952
|
+
style={{
|
|
953
|
+
backgroundColor: event.color || eventColors[event.category || ''] || '#3b82f6',
|
|
954
|
+
color: '#ffffff'
|
|
955
|
+
}}
|
|
956
|
+
whileHover={{ scale: 1.02 }}
|
|
957
|
+
whileTap={{ scale: 0.98 }}
|
|
958
|
+
onClick={(e) => {
|
|
959
|
+
e.stopPropagation()
|
|
960
|
+
handleEventClick(event, e)
|
|
961
|
+
}}
|
|
962
|
+
>
|
|
963
|
+
{event.allDay ? (
|
|
964
|
+
event.title
|
|
965
|
+
) : (
|
|
966
|
+
<>
|
|
967
|
+
{format(new Date(event.start), 'HH:mm')} {event.title}
|
|
968
|
+
</>
|
|
969
|
+
)}
|
|
970
|
+
</motion.div>
|
|
971
|
+
)
|
|
972
|
+
})}
|
|
973
|
+
{dayEvents.length > 3 && (
|
|
974
|
+
<div className="text-xs text-muted-foreground text-center">
|
|
975
|
+
+{dayEvents.length - 3} more
|
|
976
|
+
</div>
|
|
977
|
+
)}
|
|
978
|
+
</div>
|
|
979
|
+
</div>
|
|
980
|
+
)
|
|
981
|
+
})}
|
|
982
|
+
</div>
|
|
983
|
+
))}
|
|
984
|
+
</div>
|
|
985
|
+
</div>
|
|
986
|
+
)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const renderYearView = () => {
|
|
990
|
+
const yearStart = startOfYear(currentDate)
|
|
991
|
+
const yearEnd = endOfYear(currentDate)
|
|
992
|
+
const months = eachMonthOfInterval({ start: yearStart, end: yearEnd })
|
|
993
|
+
|
|
994
|
+
return (
|
|
995
|
+
<div className="flex-1 overflow-auto p-4">
|
|
996
|
+
<div className="grid grid-cols-3 gap-4">
|
|
997
|
+
{months.map(month => {
|
|
998
|
+
const monthEvents = eventsInView.filter(event => {
|
|
999
|
+
const eventStart = new Date(event.start)
|
|
1000
|
+
return isSameMonth(eventStart, month)
|
|
1001
|
+
})
|
|
1002
|
+
|
|
1003
|
+
return (
|
|
1004
|
+
<Card
|
|
1005
|
+
key={month.toISOString()}
|
|
1006
|
+
className="cursor-pointer hover:shadow-lg transition-shadow"
|
|
1007
|
+
onClick={() => {
|
|
1008
|
+
setCurrentDate(month)
|
|
1009
|
+
handleViewChange('month')
|
|
1010
|
+
}}
|
|
1011
|
+
>
|
|
1012
|
+
<CardHeader className="pb-2">
|
|
1013
|
+
<CardTitle className="text-lg">
|
|
1014
|
+
{format(month, 'MMMM')}
|
|
1015
|
+
</CardTitle>
|
|
1016
|
+
<CardDescription>
|
|
1017
|
+
{monthEvents.length} events
|
|
1018
|
+
</CardDescription>
|
|
1019
|
+
</CardHeader>
|
|
1020
|
+
<CardContent>
|
|
1021
|
+
<div className="grid grid-cols-7 gap-1 text-xs">
|
|
1022
|
+
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(day => (
|
|
1023
|
+
<div key={day} className="text-center text-muted-foreground p-1">
|
|
1024
|
+
{day}
|
|
1025
|
+
</div>
|
|
1026
|
+
))}
|
|
1027
|
+
{Array.from({ length: getDay(startOfMonth(month)) }, (_, i) => (
|
|
1028
|
+
<div key={`empty-${i}`} />
|
|
1029
|
+
))}
|
|
1030
|
+
{Array.from({ length: getDaysInMonth(month) }, (_, i) => {
|
|
1031
|
+
const day = new Date(month.getFullYear(), month.getMonth(), i + 1)
|
|
1032
|
+
const hasEvents = monthEvents.some(event =>
|
|
1033
|
+
isSameDay(new Date(event.start), day)
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
return (
|
|
1037
|
+
<div
|
|
1038
|
+
key={i}
|
|
1039
|
+
className={cn(
|
|
1040
|
+
"text-center p-1 rounded",
|
|
1041
|
+
isToday(day) && "bg-primary text-primary-foreground",
|
|
1042
|
+
hasEvents && !isToday(day) && "bg-primary/20 font-medium"
|
|
1043
|
+
)}
|
|
1044
|
+
>
|
|
1045
|
+
{i + 1}
|
|
1046
|
+
</div>
|
|
1047
|
+
)
|
|
1048
|
+
})}
|
|
1049
|
+
</div>
|
|
1050
|
+
</CardContent>
|
|
1051
|
+
</Card>
|
|
1052
|
+
)
|
|
1053
|
+
})}
|
|
1054
|
+
</div>
|
|
1055
|
+
</div>
|
|
1056
|
+
)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const renderAgendaView = () => {
|
|
1060
|
+
const agendaDays = Array.from({ length: 30 }, (_, i) => addDays(currentDate, i))
|
|
1061
|
+
|
|
1062
|
+
return (
|
|
1063
|
+
<div className="flex-1 overflow-auto p-4">
|
|
1064
|
+
<div className="space-y-4">
|
|
1065
|
+
{agendaDays.map(day => {
|
|
1066
|
+
const dayEvents = eventsInView.filter(event =>
|
|
1067
|
+
isSameDay(new Date(event.start), day)
|
|
1068
|
+
).sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
|
|
1069
|
+
|
|
1070
|
+
if (dayEvents.length === 0) return null
|
|
1071
|
+
|
|
1072
|
+
return (
|
|
1073
|
+
<Card key={day.toISOString()}>
|
|
1074
|
+
<CardHeader className="pb-2">
|
|
1075
|
+
<CardTitle className="text-lg">
|
|
1076
|
+
{format(day, 'EEEE, MMMM d, yyyy')}
|
|
1077
|
+
</CardTitle>
|
|
1078
|
+
<CardDescription>
|
|
1079
|
+
{dayEvents.length} events
|
|
1080
|
+
</CardDescription>
|
|
1081
|
+
</CardHeader>
|
|
1082
|
+
<CardContent>
|
|
1083
|
+
<div className="space-y-2">
|
|
1084
|
+
{dayEvents.map(event => (
|
|
1085
|
+
<div
|
|
1086
|
+
key={event.id}
|
|
1087
|
+
className="flex items-start gap-3 p-3 rounded-lg hover:bg-muted/50 cursor-pointer"
|
|
1088
|
+
onClick={(e) => handleEventClick(event, e)}
|
|
1089
|
+
>
|
|
1090
|
+
<div
|
|
1091
|
+
className="w-1 h-full rounded"
|
|
1092
|
+
style={{
|
|
1093
|
+
backgroundColor: event.color || eventColors[event.category || ''] || '#3b82f6'
|
|
1094
|
+
}}
|
|
1095
|
+
/>
|
|
1096
|
+
<div className="flex-1">
|
|
1097
|
+
<div className="flex items-center gap-2 mb-1">
|
|
1098
|
+
<span className="font-medium">{event.title}</span>
|
|
1099
|
+
{event.priority && (
|
|
1100
|
+
<Badge variant={
|
|
1101
|
+
event.priority === 'high' ? 'destructive' :
|
|
1102
|
+
event.priority === 'medium' ? 'secondary' :
|
|
1103
|
+
'secondary'
|
|
1104
|
+
}>
|
|
1105
|
+
{event.priority}
|
|
1106
|
+
</Badge>
|
|
1107
|
+
)}
|
|
1108
|
+
</div>
|
|
1109
|
+
<div className="text-sm text-muted-foreground">
|
|
1110
|
+
{event.allDay ? (
|
|
1111
|
+
'All day'
|
|
1112
|
+
) : (
|
|
1113
|
+
<>
|
|
1114
|
+
{format(new Date(event.start), 'HH:mm')} - {format(new Date(event.end), 'HH:mm')}
|
|
1115
|
+
</>
|
|
1116
|
+
)}
|
|
1117
|
+
{event.location && (
|
|
1118
|
+
<span className="ml-2">
|
|
1119
|
+
<MapPin className="inline h-3 w-3 mr-1" />
|
|
1120
|
+
{event.location}
|
|
1121
|
+
</span>
|
|
1122
|
+
)}
|
|
1123
|
+
</div>
|
|
1124
|
+
{event.description && (
|
|
1125
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
1126
|
+
{event.description}
|
|
1127
|
+
</p>
|
|
1128
|
+
)}
|
|
1129
|
+
</div>
|
|
1130
|
+
</div>
|
|
1131
|
+
))}
|
|
1132
|
+
</div>
|
|
1133
|
+
</CardContent>
|
|
1134
|
+
</Card>
|
|
1135
|
+
)
|
|
1136
|
+
})}
|
|
1137
|
+
</div>
|
|
1138
|
+
</div>
|
|
1139
|
+
)
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
return (
|
|
1143
|
+
<TooltipProvider>
|
|
1144
|
+
<div className={cn("flex h-full bg-background", className)} style={{ height }}>
|
|
1145
|
+
{/* Sidebar */}
|
|
1146
|
+
<motion.div
|
|
1147
|
+
className={cn(
|
|
1148
|
+
"border-r bg-muted/30 flex flex-col",
|
|
1149
|
+
sidebarCollapsed ? "w-16" : "w-80"
|
|
1150
|
+
)}
|
|
1151
|
+
animate={{ width: sidebarCollapsed ? 64 : 320 }}
|
|
1152
|
+
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
|
1153
|
+
>
|
|
1154
|
+
{/* Mini Calendar */}
|
|
1155
|
+
{!sidebarCollapsed && (
|
|
1156
|
+
<div className="p-4">
|
|
1157
|
+
<CalendarBase
|
|
1158
|
+
mode="single"
|
|
1159
|
+
selected={currentDate}
|
|
1160
|
+
onSelect={(date) => {
|
|
1161
|
+
if (date instanceof Date) {
|
|
1162
|
+
handleDateSelect(date);
|
|
1163
|
+
}
|
|
1164
|
+
}}
|
|
1165
|
+
className="rounded-md border"
|
|
1166
|
+
/>
|
|
1167
|
+
</div>
|
|
1168
|
+
)}
|
|
1169
|
+
|
|
1170
|
+
{/* Categories */}
|
|
1171
|
+
<div className="flex-1 overflow-auto p-4">
|
|
1172
|
+
{sidebarCollapsed ? (
|
|
1173
|
+
<div className="space-y-2">
|
|
1174
|
+
{categories.map(category => (
|
|
1175
|
+
<Tooltip key={category.id}>
|
|
1176
|
+
<TooltipTrigger asChild>
|
|
1177
|
+
<Button
|
|
1178
|
+
variant="ghost"
|
|
1179
|
+
size="icon"
|
|
1180
|
+
className="w-full"
|
|
1181
|
+
onClick={() => {
|
|
1182
|
+
setSelectedCategories(prev =>
|
|
1183
|
+
prev.includes(category.id)
|
|
1184
|
+
? prev.filter(c => c !== category.id)
|
|
1185
|
+
: [...prev, category.id]
|
|
1186
|
+
)
|
|
1187
|
+
}}
|
|
1188
|
+
>
|
|
1189
|
+
<div
|
|
1190
|
+
className="w-4 h-4 rounded"
|
|
1191
|
+
style={{ backgroundColor: category.color }}
|
|
1192
|
+
/>
|
|
1193
|
+
</Button>
|
|
1194
|
+
</TooltipTrigger>
|
|
1195
|
+
<TooltipContent side="right">
|
|
1196
|
+
{category.name}
|
|
1197
|
+
</TooltipContent>
|
|
1198
|
+
</Tooltip>
|
|
1199
|
+
))}
|
|
1200
|
+
</div>
|
|
1201
|
+
) : (
|
|
1202
|
+
<div className="space-y-2">
|
|
1203
|
+
<div className="flex items-center justify-between mb-2">
|
|
1204
|
+
<h3 className="font-medium">Categories</h3>
|
|
1205
|
+
<Button
|
|
1206
|
+
variant="ghost"
|
|
1207
|
+
size="sm"
|
|
1208
|
+
onClick={() => setSelectedCategories([])}
|
|
1209
|
+
>
|
|
1210
|
+
Clear
|
|
1211
|
+
</Button>
|
|
1212
|
+
</div>
|
|
1213
|
+
{categories.map(category => (
|
|
1214
|
+
<div
|
|
1215
|
+
key={category.id}
|
|
1216
|
+
className={cn(
|
|
1217
|
+
"flex items-center gap-2 p-2 rounded-lg cursor-pointer hover:bg-muted",
|
|
1218
|
+
selectedCategories.includes(category.id) && "bg-muted"
|
|
1219
|
+
)}
|
|
1220
|
+
onClick={() => {
|
|
1221
|
+
setSelectedCategories(prev =>
|
|
1222
|
+
prev.includes(category.id)
|
|
1223
|
+
? prev.filter(c => c !== category.id)
|
|
1224
|
+
: [...prev, category.id]
|
|
1225
|
+
)
|
|
1226
|
+
}}
|
|
1227
|
+
>
|
|
1228
|
+
<Checkbox
|
|
1229
|
+
checked={selectedCategories.includes(category.id)}
|
|
1230
|
+
onCheckedChange={() => {}}
|
|
1231
|
+
/>
|
|
1232
|
+
<div
|
|
1233
|
+
className="w-4 h-4 rounded"
|
|
1234
|
+
style={{ backgroundColor: category.color }}
|
|
1235
|
+
/>
|
|
1236
|
+
{category.icon}
|
|
1237
|
+
<span className="text-sm">{category.name}</span>
|
|
1238
|
+
</div>
|
|
1239
|
+
))}
|
|
1240
|
+
</div>
|
|
1241
|
+
)}
|
|
1242
|
+
</div>
|
|
1243
|
+
|
|
1244
|
+
{/* Sidebar Toggle */}
|
|
1245
|
+
<div className="p-2 border-t">
|
|
1246
|
+
<Button
|
|
1247
|
+
variant="ghost"
|
|
1248
|
+
size="icon"
|
|
1249
|
+
className="w-full"
|
|
1250
|
+
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
|
1251
|
+
>
|
|
1252
|
+
{sidebarCollapsed ? <PanelLeft /> : <PanelRight />}
|
|
1253
|
+
</Button>
|
|
1254
|
+
</div>
|
|
1255
|
+
</motion.div>
|
|
1256
|
+
|
|
1257
|
+
{/* Main Content */}
|
|
1258
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
1259
|
+
{/* Header */}
|
|
1260
|
+
<div className="border-b p-4 flex items-center justify-between">
|
|
1261
|
+
<div className="flex items-center gap-4">
|
|
1262
|
+
<div className="flex items-center gap-2">
|
|
1263
|
+
<Button
|
|
1264
|
+
variant="outline"
|
|
1265
|
+
size="icon"
|
|
1266
|
+
onClick={() => navigateDate('prev')}
|
|
1267
|
+
>
|
|
1268
|
+
<ChevronLeft className="h-4 w-4" />
|
|
1269
|
+
</Button>
|
|
1270
|
+
<Button
|
|
1271
|
+
variant="outline"
|
|
1272
|
+
size="icon"
|
|
1273
|
+
onClick={() => navigateDate('next')}
|
|
1274
|
+
>
|
|
1275
|
+
<ChevronRight className="h-4 w-4" />
|
|
1276
|
+
</Button>
|
|
1277
|
+
<Button
|
|
1278
|
+
variant="outline"
|
|
1279
|
+
onClick={() => setCurrentDate(new Date())}
|
|
1280
|
+
>
|
|
1281
|
+
Today
|
|
1282
|
+
</Button>
|
|
1283
|
+
</div>
|
|
1284
|
+
|
|
1285
|
+
<h2 className="text-xl font-semibold">
|
|
1286
|
+
{format(currentDate,
|
|
1287
|
+
currentViewConfig.type === 'day' ? 'EEEE, MMMM d, yyyy' :
|
|
1288
|
+
currentViewConfig.type === 'week' ? "'Week of' MMMM d, yyyy" :
|
|
1289
|
+
currentViewConfig.type === 'month' ? 'MMMM yyyy' :
|
|
1290
|
+
currentViewConfig.type === 'year' ? 'yyyy' :
|
|
1291
|
+
'MMMM yyyy'
|
|
1292
|
+
)}
|
|
1293
|
+
</h2>
|
|
1294
|
+
</div>
|
|
1295
|
+
|
|
1296
|
+
<div className="flex items-center gap-2">
|
|
1297
|
+
{/* Search */}
|
|
1298
|
+
<div className="relative">
|
|
1299
|
+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
|
1300
|
+
<Input
|
|
1301
|
+
placeholder="Search events..."
|
|
1302
|
+
value={searchQuery}
|
|
1303
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
1304
|
+
className="pl-9 w-64"
|
|
1305
|
+
/>
|
|
1306
|
+
</div>
|
|
1307
|
+
|
|
1308
|
+
{/* View Selector */}
|
|
1309
|
+
<Tabs value={currentView} onValueChange={handleViewChange}>
|
|
1310
|
+
<TabsList>
|
|
1311
|
+
{views.map(view => (
|
|
1312
|
+
<TabsTrigger key={view.id} value={view.id}>
|
|
1313
|
+
{view.name}
|
|
1314
|
+
</TabsTrigger>
|
|
1315
|
+
))}
|
|
1316
|
+
</TabsList>
|
|
1317
|
+
</Tabs>
|
|
1318
|
+
|
|
1319
|
+
{/* Actions */}
|
|
1320
|
+
<DropdownMenu>
|
|
1321
|
+
<DropdownMenuTrigger asChild>
|
|
1322
|
+
<Button variant="outline" size="icon">
|
|
1323
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
1324
|
+
</Button>
|
|
1325
|
+
</DropdownMenuTrigger>
|
|
1326
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
1327
|
+
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
1328
|
+
<DropdownMenuSeparator />
|
|
1329
|
+
{allowEventCreation && (
|
|
1330
|
+
<DropdownMenuItem
|
|
1331
|
+
onClick={() => {
|
|
1332
|
+
setNewEventStart(new Date())
|
|
1333
|
+
setNewEventEnd(addHours(new Date(), 1))
|
|
1334
|
+
setIsCreating(true)
|
|
1335
|
+
setIsEventDialogOpen(true)
|
|
1336
|
+
}}
|
|
1337
|
+
>
|
|
1338
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
1339
|
+
Create Event
|
|
1340
|
+
</DropdownMenuItem>
|
|
1341
|
+
)}
|
|
1342
|
+
<DropdownMenuSeparator />
|
|
1343
|
+
<DropdownMenuSub>
|
|
1344
|
+
<DropdownMenuSubTrigger>
|
|
1345
|
+
<Download className="h-4 w-4 mr-2" />
|
|
1346
|
+
Export
|
|
1347
|
+
</DropdownMenuSubTrigger>
|
|
1348
|
+
<DropdownMenuSubContent>
|
|
1349
|
+
<DropdownMenuItem onClick={() => exportCalendar('ics')}>
|
|
1350
|
+
<FileText className="h-4 w-4 mr-2" />
|
|
1351
|
+
Export as ICS
|
|
1352
|
+
</DropdownMenuItem>
|
|
1353
|
+
<DropdownMenuItem onClick={() => exportCalendar('csv')}>
|
|
1354
|
+
<FileSpreadsheet className="h-4 w-4 mr-2" />
|
|
1355
|
+
Export as CSV
|
|
1356
|
+
</DropdownMenuItem>
|
|
1357
|
+
<DropdownMenuItem onClick={() => exportCalendar('json')}>
|
|
1358
|
+
<FileCode className="h-4 w-4 mr-2" />
|
|
1359
|
+
Export as JSON
|
|
1360
|
+
</DropdownMenuItem>
|
|
1361
|
+
</DropdownMenuSubContent>
|
|
1362
|
+
</DropdownMenuSub>
|
|
1363
|
+
<DropdownMenuItem>
|
|
1364
|
+
<Printer className="h-4 w-4 mr-2" />
|
|
1365
|
+
Print
|
|
1366
|
+
</DropdownMenuItem>
|
|
1367
|
+
<DropdownMenuItem>
|
|
1368
|
+
<Share2 className="h-4 w-4 mr-2" />
|
|
1369
|
+
Share
|
|
1370
|
+
</DropdownMenuItem>
|
|
1371
|
+
<DropdownMenuSeparator />
|
|
1372
|
+
<DropdownMenuItem>
|
|
1373
|
+
<Settings className="h-4 w-4 mr-2" />
|
|
1374
|
+
Settings
|
|
1375
|
+
</DropdownMenuItem>
|
|
1376
|
+
</DropdownMenuContent>
|
|
1377
|
+
</DropdownMenu>
|
|
1378
|
+
</div>
|
|
1379
|
+
</div>
|
|
1380
|
+
|
|
1381
|
+
{/* Calendar View */}
|
|
1382
|
+
<LayoutGroup>
|
|
1383
|
+
<AnimatePresence mode="wait">
|
|
1384
|
+
<motion.div
|
|
1385
|
+
key={currentView}
|
|
1386
|
+
className="flex-1 overflow-hidden"
|
|
1387
|
+
initial={{ opacity: 0, y: 20 }}
|
|
1388
|
+
animate={{ opacity: 1, y: 0 }}
|
|
1389
|
+
exit={{ opacity: 0, y: -20 }}
|
|
1390
|
+
transition={{ duration: 0.2 }}
|
|
1391
|
+
>
|
|
1392
|
+
{currentViewConfig.type === 'day' && renderDayView()}
|
|
1393
|
+
{currentViewConfig.type === 'week' && renderWeekView()}
|
|
1394
|
+
{currentViewConfig.type === 'month' && renderMonthView()}
|
|
1395
|
+
{currentViewConfig.type === 'year' && renderYearView()}
|
|
1396
|
+
{currentViewConfig.type === 'agenda' && renderAgendaView()}
|
|
1397
|
+
</motion.div>
|
|
1398
|
+
</AnimatePresence>
|
|
1399
|
+
</LayoutGroup>
|
|
1400
|
+
</div>
|
|
1401
|
+
|
|
1402
|
+
{/* Event Dialog */}
|
|
1403
|
+
<Dialog open={isEventDialogOpen} onOpenChange={setIsEventDialogOpen}>
|
|
1404
|
+
<DialogContent className="sm:max-w-[600px]">
|
|
1405
|
+
<DialogHeader>
|
|
1406
|
+
<DialogTitle>
|
|
1407
|
+
{isCreating ? 'Create Event' : 'Edit Event'}
|
|
1408
|
+
</DialogTitle>
|
|
1409
|
+
<DialogDescription>
|
|
1410
|
+
{isCreating ? 'Add a new event to your calendar' : 'Update event details'}
|
|
1411
|
+
</DialogDescription>
|
|
1412
|
+
</DialogHeader>
|
|
1413
|
+
|
|
1414
|
+
<div className="space-y-4">
|
|
1415
|
+
<div className="space-y-2">
|
|
1416
|
+
<Label htmlFor="title">Title</Label>
|
|
1417
|
+
<Input
|
|
1418
|
+
id="title"
|
|
1419
|
+
value={editingEvent.title || ''}
|
|
1420
|
+
onChange={(e) => setEditingEvent({ ...editingEvent, title: e.target.value })}
|
|
1421
|
+
placeholder="Event title"
|
|
1422
|
+
/>
|
|
1423
|
+
</div>
|
|
1424
|
+
|
|
1425
|
+
<div className="grid grid-cols-2 gap-4">
|
|
1426
|
+
<div className="space-y-2">
|
|
1427
|
+
<Label htmlFor="start">Start</Label>
|
|
1428
|
+
<Input
|
|
1429
|
+
id="start"
|
|
1430
|
+
type="datetime-local"
|
|
1431
|
+
value={editingEvent.start ? format(editingEvent.start, "yyyy-MM-dd'T'HH:mm") : ''}
|
|
1432
|
+
onChange={(e) => setEditingEvent({ ...editingEvent, start: new Date(e.target.value) })}
|
|
1433
|
+
/>
|
|
1434
|
+
</div>
|
|
1435
|
+
|
|
1436
|
+
<div className="space-y-2">
|
|
1437
|
+
<Label htmlFor="end">End</Label>
|
|
1438
|
+
<Input
|
|
1439
|
+
id="end"
|
|
1440
|
+
type="datetime-local"
|
|
1441
|
+
value={editingEvent.end ? format(editingEvent.end, "yyyy-MM-dd'T'HH:mm") : ''}
|
|
1442
|
+
onChange={(e) => setEditingEvent({ ...editingEvent, end: new Date(e.target.value) })}
|
|
1443
|
+
/>
|
|
1444
|
+
</div>
|
|
1445
|
+
</div>
|
|
1446
|
+
|
|
1447
|
+
<div className="flex items-center space-x-2">
|
|
1448
|
+
<Switch
|
|
1449
|
+
id="allDay"
|
|
1450
|
+
checked={editingEvent.allDay || false}
|
|
1451
|
+
onCheckedChange={(checked) => setEditingEvent({ ...editingEvent, allDay: checked })}
|
|
1452
|
+
/>
|
|
1453
|
+
<Label htmlFor="allDay">All day</Label>
|
|
1454
|
+
</div>
|
|
1455
|
+
|
|
1456
|
+
<div className="space-y-2">
|
|
1457
|
+
<Label htmlFor="category">Category</Label>
|
|
1458
|
+
<Select
|
|
1459
|
+
value={editingEvent.category || ''}
|
|
1460
|
+
onValueChange={(value) => setEditingEvent({ ...editingEvent, category: value })}
|
|
1461
|
+
>
|
|
1462
|
+
<SelectTrigger id="category">
|
|
1463
|
+
<SelectValue placeholder="Select a category" />
|
|
1464
|
+
</SelectTrigger>
|
|
1465
|
+
<SelectContent>
|
|
1466
|
+
{categories.map(category => (
|
|
1467
|
+
<SelectItem key={category.id} value={category.id}>
|
|
1468
|
+
<div className="flex items-center gap-2">
|
|
1469
|
+
<div
|
|
1470
|
+
className="w-3 h-3 rounded"
|
|
1471
|
+
style={{ backgroundColor: category.color }}
|
|
1472
|
+
/>
|
|
1473
|
+
{category.name}
|
|
1474
|
+
</div>
|
|
1475
|
+
</SelectItem>
|
|
1476
|
+
))}
|
|
1477
|
+
</SelectContent>
|
|
1478
|
+
</Select>
|
|
1479
|
+
</div>
|
|
1480
|
+
|
|
1481
|
+
<div className="space-y-2">
|
|
1482
|
+
<Label htmlFor="location">Location</Label>
|
|
1483
|
+
<Input
|
|
1484
|
+
id="location"
|
|
1485
|
+
value={editingEvent.location || ''}
|
|
1486
|
+
onChange={(e) => setEditingEvent({ ...editingEvent, location: e.target.value })}
|
|
1487
|
+
placeholder="Event location"
|
|
1488
|
+
/>
|
|
1489
|
+
</div>
|
|
1490
|
+
|
|
1491
|
+
<div className="space-y-2">
|
|
1492
|
+
<Label htmlFor="description">Description</Label>
|
|
1493
|
+
<Textarea
|
|
1494
|
+
id="description"
|
|
1495
|
+
value={editingEvent.description || ''}
|
|
1496
|
+
onChange={(e) => setEditingEvent({ ...editingEvent, description: e.target.value })}
|
|
1497
|
+
placeholder="Event description"
|
|
1498
|
+
/>
|
|
1499
|
+
</div>
|
|
1500
|
+
|
|
1501
|
+
<div className="space-y-2">
|
|
1502
|
+
<Label>Priority</Label>
|
|
1503
|
+
<RadioGroup
|
|
1504
|
+
value={editingEvent.priority || 'medium'}
|
|
1505
|
+
onValueChange={(value) => setEditingEvent({ ...editingEvent, priority: value as any })}
|
|
1506
|
+
>
|
|
1507
|
+
<div className="flex items-center space-x-2">
|
|
1508
|
+
<RadioGroupItem value="low" id="low" />
|
|
1509
|
+
<Label htmlFor="low">Low</Label>
|
|
1510
|
+
</div>
|
|
1511
|
+
<div className="flex items-center space-x-2">
|
|
1512
|
+
<RadioGroupItem value="medium" id="medium" />
|
|
1513
|
+
<Label htmlFor="medium">Medium</Label>
|
|
1514
|
+
</div>
|
|
1515
|
+
<div className="flex items-center space-x-2">
|
|
1516
|
+
<RadioGroupItem value="high" id="high" />
|
|
1517
|
+
<Label htmlFor="high">High</Label>
|
|
1518
|
+
</div>
|
|
1519
|
+
</RadioGroup>
|
|
1520
|
+
</div>
|
|
1521
|
+
</div>
|
|
1522
|
+
|
|
1523
|
+
<DialogFooter>
|
|
1524
|
+
{!isCreating && allowEventDeletion && (
|
|
1525
|
+
<Button
|
|
1526
|
+
variant="destructive"
|
|
1527
|
+
onClick={handleEventDelete}
|
|
1528
|
+
>
|
|
1529
|
+
Delete
|
|
1530
|
+
</Button>
|
|
1531
|
+
)}
|
|
1532
|
+
<Button
|
|
1533
|
+
variant="outline"
|
|
1534
|
+
onClick={() => {
|
|
1535
|
+
setIsEventDialogOpen(false)
|
|
1536
|
+
setEditingEvent({})
|
|
1537
|
+
setSelectedEvent(null)
|
|
1538
|
+
setIsCreating(false)
|
|
1539
|
+
}}
|
|
1540
|
+
>
|
|
1541
|
+
Cancel
|
|
1542
|
+
</Button>
|
|
1543
|
+
<Button onClick={handleEventSave}>
|
|
1544
|
+
{isCreating ? 'Create' : 'Save'}
|
|
1545
|
+
</Button>
|
|
1546
|
+
</DialogFooter>
|
|
1547
|
+
</DialogContent>
|
|
1548
|
+
</Dialog>
|
|
1549
|
+
</div>
|
|
1550
|
+
</TooltipProvider>
|
|
1551
|
+
)
|
|
1552
|
+
})
|
|
1553
|
+
|
|
1554
|
+
CalendarPro.displayName = 'CalendarPro'
|
|
1555
|
+
|
|
1556
|
+
export default CalendarPro
|