feat: add comprehensive consolidation index and documentation
Co-authored-by: cetinmustafa83 <30570791+cetinmustafa83@users.noreply.github.com>
This commit is contained in:
14 files changed
+3122
-9964
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,692 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
addDays,
|
||||
addMonths,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isToday as isDateToday,
|
||||
parseISO,
|
||||
differenceInCalendarDays,
|
||||
} from 'date-fns';
|
||||
import { de as deLocale, enUS as enLocale } from 'date-fns/locale';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Copy,
|
||||
Eye,
|
||||
GripVertical,
|
||||
X,
|
||||
Clock,
|
||||
MapPin,
|
||||
Users,
|
||||
AlertCircle,
|
||||
Check,
|
||||
MoreVertical,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuSeparator,
|
||||
} from '@/components/ui/context-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { CalendarManager, CalendarEvent, CalendarEventType } from '@/lib/calendar-manager';
|
||||
import { useApi } from '@/lib/hooks/useApi';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProfessionalCalendarProps {
|
||||
initialEvents?: CalendarEvent[];
|
||||
onEventCreate?: (event: CalendarEvent) => Promise<void>;
|
||||
onEventUpdate?: (event: CalendarEvent) => Promise<void>;
|
||||
onEventDelete?: (eventId: string) => Promise<void>;
|
||||
variant?: 'month' | 'week' | 'day' | 'agenda';
|
||||
locale?: 'de' | 'en';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProfessionalCalendar({
|
||||
initialEvents = [],
|
||||
onEventCreate,
|
||||
onEventUpdate,
|
||||
onEventDelete,
|
||||
variant = 'month',
|
||||
locale = 'de',
|
||||
className,
|
||||
}: ProfessionalCalendarProps) {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [events, setEvents] = useState<CalendarEvent[]>(initialEvents);
|
||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [draggedEvent, setDraggedEvent] = useState<CalendarEvent | null>(null);
|
||||
const [view, setView] = useState<'month' | 'week' | 'day' | 'agenda'>(variant);
|
||||
const contextMenuRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const dateLocale = locale === 'de' ? deLocale : enLocale;
|
||||
|
||||
// Fetch events from backend
|
||||
const { data: backendEvents, isLoading } = useApi('/api/v1/calendar/events', {
|
||||
method: 'GET',
|
||||
params: {
|
||||
startDate: format(startOfMonth(currentDate), 'yyyy-MM-dd'),
|
||||
endDate: format(endOfMonth(currentDate), 'yyyy-MM-dd'),
|
||||
},
|
||||
revalidateOnFocus: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (backendEvents) {
|
||||
setEvents(backendEvents);
|
||||
}
|
||||
}, [backendEvents]);
|
||||
|
||||
// Calculate calendar grid
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(monthStart);
|
||||
const calendarStart = startOfWeek(monthStart);
|
||||
const calendarEnd = endOfWeek(monthEnd);
|
||||
|
||||
const days: Date[] = [];
|
||||
let day = new Date(calendarStart);
|
||||
while (day <= calendarEnd) {
|
||||
days.push(new Date(day));
|
||||
day = addDays(day, 1);
|
||||
}
|
||||
|
||||
// Get events for current month
|
||||
const monthEvents = useMemo(() => {
|
||||
return CalendarManager.getEventsInRange(events, monthStart, monthEnd, {
|
||||
showPast: true,
|
||||
});
|
||||
}, [events, monthStart, monthEnd]);
|
||||
|
||||
// Get events for a specific date
|
||||
const getEventsForDate = useCallback((date: Date): CalendarEvent[] => {
|
||||
return CalendarManager.getEventsForDate(events, date);
|
||||
}, [events]);
|
||||
|
||||
// Handle event creation
|
||||
const handleCreateEvent = useCallback(
|
||||
async (eventData: Omit<CalendarEvent, 'id' | 'createdAt'>) => {
|
||||
try {
|
||||
const newEvent: CalendarEvent = {
|
||||
...eventData,
|
||||
id: `event-${Date.now()}`,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
setEvents((prev) => [...prev, newEvent]);
|
||||
if (onEventCreate) {
|
||||
await onEventCreate(newEvent);
|
||||
}
|
||||
toast.success('Event created successfully');
|
||||
setIsCreating(false);
|
||||
} catch (error) {
|
||||
toast.error('Failed to create event');
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
[onEventCreate]
|
||||
);
|
||||
|
||||
// Handle event update
|
||||
const handleUpdateEvent = useCallback(
|
||||
async (event: CalendarEvent) => {
|
||||
try {
|
||||
setEvents((prev) =>
|
||||
prev.map((e) => (e.id === event.id ? { ...event, updatedAt: new Date() } : e))
|
||||
);
|
||||
if (onEventUpdate) {
|
||||
await onEventUpdate(event);
|
||||
}
|
||||
toast.success('Event updated successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to update event');
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
[onEventUpdate]
|
||||
);
|
||||
|
||||
// Handle event deletion
|
||||
const handleDeleteEvent = useCallback(
|
||||
async (eventId: string) => {
|
||||
try {
|
||||
setEvents((prev) => prev.filter((e) => e.id !== eventId));
|
||||
if (onEventDelete) {
|
||||
await onEventDelete(eventId);
|
||||
}
|
||||
setSelectedEvent(null);
|
||||
toast.success('Event deleted successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete event');
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
[onEventDelete]
|
||||
);
|
||||
|
||||
// Handle drag-and-drop
|
||||
const handleDragStart = (e: React.DragEvent, event: CalendarEvent) => {
|
||||
setDraggedEvent(event);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetDate: Date) => {
|
||||
e.preventDefault();
|
||||
if (!draggedEvent) return;
|
||||
|
||||
const newEvent = {
|
||||
...draggedEvent,
|
||||
date: targetDate,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
handleUpdateEvent(newEvent);
|
||||
setDraggedEvent(null);
|
||||
toast.success('Event moved successfully');
|
||||
};
|
||||
|
||||
// Handle right-click context menu
|
||||
const handleContextMenu = (
|
||||
e: React.MouseEvent,
|
||||
event: CalendarEvent
|
||||
) => {
|
||||
e.preventDefault();
|
||||
contextMenuRef.current = { x: e.clientX, y: e.clientY };
|
||||
setSelectedEvent(event);
|
||||
};
|
||||
|
||||
// Render calendar cell
|
||||
const renderCalendarCell = (date: Date) => {
|
||||
const dateEvents = getEventsForDate(date);
|
||||
const isCurrentMonth = isSameMonth(date, currentDate);
|
||||
const isToday = isDateToday(date);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={format(date, 'yyyy-MM-dd')}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, date)}
|
||||
className={cn(
|
||||
'min-h-24 p-2 border rounded-lg transition-colors',
|
||||
isCurrentMonth ? 'bg-background' : 'bg-muted/30',
|
||||
isToday && 'ring-2 ring-primary border-primary',
|
||||
'hover:bg-accent/50 cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{/* Date header */}
|
||||
<div className={cn(
|
||||
'text-xs font-semibold mb-1',
|
||||
isCurrentMonth ? 'text-foreground' : 'text-muted-foreground',
|
||||
isToday && 'text-primary'
|
||||
)}>
|
||||
{format(date, 'd')}
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
<div className="space-y-1">
|
||||
{dateEvents.slice(0, 2).map((event) => (
|
||||
<motion.div
|
||||
key={event.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, event)}
|
||||
onContextMenu={(e) => handleContextMenu(e, event)}
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
className={cn(
|
||||
'text-xs p-1 rounded cursor-grab active:cursor-grabbing',
|
||||
'truncate hover:opacity-80 transition-opacity',
|
||||
CalendarManager.getColorForType(event.type)
|
||||
)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileDrag={{ scale: 1.1, opacity: 0.7 }}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{CalendarManager.getIconForType(event.type)}</span>
|
||||
<span className="truncate">{event.title}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
{dateEvents.length > 2 && (
|
||||
<div className="text-xs text-muted-foreground pl-1">
|
||||
+{dateEvents.length - 2} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add event button */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="mt-1 h-6 w-full text-xs opacity-0 hover:opacity-100"
|
||||
onClick={() => {
|
||||
setIsCreating(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentDate(addMonths(currentDate, -1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h2 className="text-lg font-semibold min-w-40">
|
||||
{format(currentDate, 'MMMM yyyy', { locale: dateLocale })}
|
||||
</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentDate(addMonths(currentDate, 1))}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentDate(new Date())}
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* View tabs */}
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as any)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="month">Month</TabsTrigger>
|
||||
<TabsTrigger value="week">Week</TabsTrigger>
|
||||
<TabsTrigger value="agenda">Agenda</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Event
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
{view === 'month' && (
|
||||
<div className="border rounded-lg overflow-hidden bg-card">
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 bg-muted">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => (
|
||||
<div key={day} className="p-3 text-sm font-semibold text-center">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar cells */}
|
||||
<div className="grid grid-cols-7 gap-px bg-border">
|
||||
{days.map((day) => (
|
||||
<div key={format(day, 'yyyy-MM-dd')} className="bg-background">
|
||||
{renderCalendarCell(day)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agenda view */}
|
||||
{view === 'agenda' && (
|
||||
<div className="space-y-2">
|
||||
{monthEvents.map((event) => (
|
||||
<motion.div
|
||||
key={event.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="p-3 border rounded-lg hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
onContextMenu={(e) => handleContextMenu(e, event)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{CalendarManager.getIconForType(event.type)}</span>
|
||||
<h3 className="font-semibold">{event.title}</h3>
|
||||
<Badge variant="secondary">{event.type}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{format(event.date, 'PPP', { locale: dateLocale })} at {CalendarManager.formatTimeRange(event)}
|
||||
</p>
|
||||
{event.description && (
|
||||
<p className="text-sm mt-2">{event.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setSelectedEvent(event)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleDeleteEvent(event.id)}>
|
||||
<Trash2 className="h-4 w-4 mr-2 text-red-500" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event detail dialog */}
|
||||
<AnimatePresence>
|
||||
{selectedEvent && (
|
||||
<EventDetailDialog
|
||||
event={selectedEvent}
|
||||
onClose={() => setSelectedEvent(null)}
|
||||
onUpdate={handleUpdateEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Create/Edit event dialog */}
|
||||
{isCreating && (
|
||||
<EventCreatorDialog
|
||||
onClose={() => setIsCreating(false)}
|
||||
onCreate={handleCreateEvent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Event Detail Dialog Component
|
||||
function EventDetailDialog({
|
||||
event,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
event: CalendarEvent;
|
||||
onClose: () => void;
|
||||
onUpdate: (event: CalendarEvent) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">{CalendarManager.getIconForType(event.type)}</span>
|
||||
{event.title}
|
||||
</DialogTitle>
|
||||
<Badge className="w-fit">{event.type}</Badge>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Date & Time</Label>
|
||||
<p className="text-sm">
|
||||
{format(event.date, 'PPP')} at {CalendarManager.formatTimeRange(event)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{event.location && (
|
||||
<div className="flex gap-2 items-start">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground mt-1" />
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Location</Label>
|
||||
<p className="text-sm">{event.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.description && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Description</Label>
|
||||
<p className="text-sm">{event.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.attendees && event.attendees.length > 0 && (
|
||||
<div className="flex gap-2 items-start">
|
||||
<Users className="h-4 w-4 text-muted-foreground mt-1" />
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Attendees</Label>
|
||||
<p className="text-sm">{event.attendees.join(', ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onDelete(event.id);
|
||||
onClose();
|
||||
}}
|
||||
className="text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button onClick={() => setIsEditing(true)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Event Creator Dialog Component
|
||||
function EventCreatorDialog({
|
||||
onClose,
|
||||
onCreate,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onCreate: (event: Omit<CalendarEvent, 'id' | 'createdAt'>) => Promise<void>;
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
type: 'custom' as CalendarEventType,
|
||||
date: new Date(),
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
description: '',
|
||||
location: '',
|
||||
allDay: false,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.title.trim()) {
|
||||
toast.error('Please enter a title');
|
||||
return;
|
||||
}
|
||||
|
||||
await onCreate({
|
||||
...formData,
|
||||
date: formData.date,
|
||||
allDay: formData.allDay,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Event</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="Event title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<select
|
||||
id="type"
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as CalendarEventType })}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
>
|
||||
<option value="exam">Exam</option>
|
||||
<option value="lesson">Lesson</option>
|
||||
<option value="assignment">Assignment</option>
|
||||
<option value="school_event">School Event</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="date">Date</Label>
|
||||
<Input
|
||||
id="date"
|
||||
type="date"
|
||||
value={format(formData.date, 'yyyy-MM-dd')}
|
||||
onChange={(e) => setFormData({ ...formData, date: parseISO(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.allDay}
|
||||
onChange={(e) => setFormData({ ...formData, allDay: e.target.checked })}
|
||||
/>
|
||||
All day event
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{!formData.allDay && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="startTime">Start Time</Label>
|
||||
<Input
|
||||
id="startTime"
|
||||
type="time"
|
||||
value={formData.startTime}
|
||||
onChange={(e) => setFormData({ ...formData, startTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="endTime">End Time</Label>
|
||||
<Input
|
||||
id="endTime"
|
||||
type="time"
|
||||
value={formData.endTime}
|
||||
onChange={(e) => setFormData({ ...formData, endTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="location">Location</Label>
|
||||
<Input
|
||||
id="location"
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||
placeholder="Event location"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Event description"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>Create Event</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfessionalCalendar;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,581 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
BarChart3,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Filter,
|
||||
Download,
|
||||
Upload,
|
||||
TrendingUp,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Smartphone,
|
||||
Monitor,
|
||||
Table as TableIcon,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { DataTable } from './data-table';
|
||||
import { FormBuilder } from './form-builder';
|
||||
import { GradingEngine } from '@/lib/grading-engine';
|
||||
import { useApi } from '@/lib/hooks/useApi';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Grade {
|
||||
id: string;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
subject: string;
|
||||
assessment: string;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
weight?: number;
|
||||
comment?: string;
|
||||
gradedAt?: string;
|
||||
status: 'pending' | 'graded' | 'released';
|
||||
}
|
||||
|
||||
interface UnifiedGradingPanelProps {
|
||||
classId?: string;
|
||||
studentId?: string;
|
||||
mode?: 'teacher' | 'student' | 'admin' | 'parent';
|
||||
variant?: 'desktop' | 'tablet' | 'list';
|
||||
onGradeSubmit?: (grades: Grade[]) => Promise<void>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified Grading Panel
|
||||
* Consolidates: grading-view.tsx, tablet-grading-view.tsx, grade-analytics-view.tsx
|
||||
* Single component supports all viewing modes and devices
|
||||
*/
|
||||
export function UnifiedGradingPanel({
|
||||
classId,
|
||||
studentId,
|
||||
mode = 'teacher',
|
||||
variant = 'desktop',
|
||||
onGradeSubmit,
|
||||
className,
|
||||
}: UnifiedGradingPanelProps) {
|
||||
const [grades, setGrades] = useState<Grade[]>([]);
|
||||
const [filteredGrades, setFilteredGrades] = useState<Grade[]>([]);
|
||||
const [selectedGrade, setSelectedGrade] = useState<Grade | null>(null);
|
||||
const [isEditingGrade, setIsEditingGrade] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterSubject, setFilterSubject] = useState('all');
|
||||
const [filterStatus, setFilterStatus] = useState<'all' | 'pending' | 'graded' | 'released'>('all');
|
||||
const [viewType, setViewType] = useState<'grid' | 'list' | 'analytics'>(
|
||||
variant === 'list' ? 'list' : 'grid'
|
||||
);
|
||||
|
||||
// Fetch grades from API
|
||||
const { data: backendGrades, isLoading, mutate } = useApi(
|
||||
'/api/v1/grades',
|
||||
{
|
||||
method: 'GET',
|
||||
params: {
|
||||
...(classId && { classId }),
|
||||
...(studentId && { studentId }),
|
||||
},
|
||||
revalidateOnFocus: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Update local grades when backend data changes
|
||||
React.useEffect(() => {
|
||||
if (backendGrades) {
|
||||
setGrades(backendGrades);
|
||||
applyFilters(backendGrades);
|
||||
}
|
||||
}, [backendGrades]);
|
||||
|
||||
// Apply filters
|
||||
const applyFilters = useCallback((gradesToFilter: Grade[]) => {
|
||||
let filtered = gradesToFilter;
|
||||
|
||||
// Search filter
|
||||
if (searchTerm) {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(g) =>
|
||||
g.studentName.toLowerCase().includes(searchLower) ||
|
||||
g.subject.toLowerCase().includes(searchLower) ||
|
||||
g.assessment.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
// Subject filter
|
||||
if (filterSubject !== 'all') {
|
||||
filtered = filtered.filter((g) => g.subject === filterSubject);
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (filterStatus !== 'all') {
|
||||
filtered = filtered.filter((g) => g.status === filterStatus);
|
||||
}
|
||||
|
||||
setFilteredGrades(filtered);
|
||||
}, [searchTerm, filterSubject, filterStatus]);
|
||||
|
||||
// Update filters when any filter changes
|
||||
React.useEffect(() => {
|
||||
applyFilters(grades);
|
||||
}, [searchTerm, filterSubject, filterStatus, applyFilters]);
|
||||
|
||||
// Calculate statistics
|
||||
const stats = useMemo(() => {
|
||||
const completed = grades.filter((g) => g.status !== 'pending').length;
|
||||
const average = grades.length > 0
|
||||
? (grades.reduce((sum, g) => sum + (g.score / g.maxScore) * 100, 0) / grades.length).toFixed(1)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
total: grades.length,
|
||||
completed,
|
||||
pending: grades.length - completed,
|
||||
average,
|
||||
};
|
||||
}, [grades]);
|
||||
|
||||
// Handle grade submission
|
||||
const handleSubmitGrades = useCallback(async () => {
|
||||
try {
|
||||
const gradesToSubmit = grades.filter((g) => g.status === 'graded');
|
||||
if (gradesToSubmit.length === 0) {
|
||||
toast.error('No grades to submit');
|
||||
return;
|
||||
}
|
||||
|
||||
if (onGradeSubmit) {
|
||||
await onGradeSubmit(gradesToSubmit);
|
||||
} else {
|
||||
// Use API
|
||||
await fetch('/api/v1/grades/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(gradesToSubmit),
|
||||
});
|
||||
}
|
||||
|
||||
setGrades((prev) =>
|
||||
prev.map((g) => (g.status === 'graded' ? { ...g, status: 'released' } : g))
|
||||
);
|
||||
toast.success(`${gradesToSubmit.length} grades submitted successfully`);
|
||||
mutate();
|
||||
} catch (error) {
|
||||
toast.error('Failed to submit grades');
|
||||
console.error(error);
|
||||
}
|
||||
}, [grades, onGradeSubmit, mutate]);
|
||||
|
||||
// Handle grade update
|
||||
const handleUpdateGrade = useCallback((updatedGrade: Grade) => {
|
||||
setGrades((prev) =>
|
||||
prev.map((g) => (g.id === updatedGrade.id ? updatedGrade : g))
|
||||
);
|
||||
setSelectedGrade(null);
|
||||
setIsEditingGrade(false);
|
||||
toast.success('Grade updated');
|
||||
}, []);
|
||||
|
||||
// Handle grade deletion
|
||||
const handleDeleteGrade = useCallback(async (gradeId: string) => {
|
||||
try {
|
||||
await fetch(`/api/v1/grades/${gradeId}`, { method: 'DELETE' });
|
||||
setGrades((prev) => prev.filter((g) => g.id !== gradeId));
|
||||
setSelectedGrade(null);
|
||||
toast.success('Grade deleted');
|
||||
mutate();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete grade');
|
||||
console.error(error);
|
||||
}
|
||||
}, [mutate]);
|
||||
|
||||
// Subject list for filtering
|
||||
const subjects = useMemo(() => {
|
||||
const subjectSet = new Set(grades.map((g) => g.subject));
|
||||
return Array.from(subjectSet).sort();
|
||||
}, [grades]);
|
||||
|
||||
// Render desktop grid view
|
||||
const renderDesktopView = () => (
|
||||
<div className="space-y-4">
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Total Grades</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.total}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Completed</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.completed}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Pending</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-orange-500">{stats.pending}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Average</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.average}%</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Grades table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Grades</span>
|
||||
<Button onClick={handleSubmitGrades} disabled={stats.pending === stats.total}>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
Submit Grades
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th className="text-left py-2 px-3">Student</th>
|
||||
<th className="text-left py-2 px-3">Subject</th>
|
||||
<th className="text-left py-2 px-3">Assessment</th>
|
||||
<th className="text-center py-2 px-3">Score</th>
|
||||
<th className="text-center py-2 px-3">Percentage</th>
|
||||
<th className="text-center py-2 px-3">Status</th>
|
||||
<th className="text-right py-2 px-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredGrades.map((grade) => (
|
||||
<tr key={grade.id} className="border-b hover:bg-muted/50 transition-colors">
|
||||
<td className="py-2 px-3">{grade.studentName}</td>
|
||||
<td className="py-2 px-3">{grade.subject}</td>
|
||||
<td className="py-2 px-3">{grade.assessment}</td>
|
||||
<td className="py-2 px-3 text-center font-semibold">
|
||||
{grade.score}/{grade.maxScore}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{((grade.score / grade.maxScore) * 100).toFixed(1)}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
<Badge
|
||||
variant={
|
||||
grade.status === 'pending'
|
||||
? 'outline'
|
||||
: grade.status === 'graded'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{grade.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSelectedGrade(grade);
|
||||
setIsEditingGrade(true);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render tablet view (card grid)
|
||||
const renderTabletView = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 auto-rows-max">
|
||||
{filteredGrades.map((grade) => (
|
||||
<motion.div
|
||||
key={grade.id}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="p-4 border rounded-lg cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => {
|
||||
setSelectedGrade(grade);
|
||||
setIsEditingGrade(true);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold">{grade.studentName}</h3>
|
||||
<p className="text-sm text-muted-foreground">{grade.subject}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-bold">{grade.score}/{grade.maxScore}</span>
|
||||
<Badge>{grade.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render list view
|
||||
const renderListView = () => (
|
||||
<div className="space-y-2">
|
||||
{filteredGrades.map((grade) => (
|
||||
<motion.div
|
||||
key={grade.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="p-3 border rounded-lg flex items-center justify-between hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
setSelectedGrade(grade);
|
||||
setIsEditingGrade(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{grade.studentName}</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{grade.subject} - {grade.assessment}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right space-y-1">
|
||||
<div className="font-semibold">{grade.score}/{grade.maxScore}</div>
|
||||
<Badge variant="outline">{grade.status}</Badge>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Header with filters */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-2xl font-bold">Grading Panel</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<Input
|
||||
placeholder="Search grades..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
<Select value={filterSubject} onValueChange={setFilterSubject}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All subjects" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All subjects</SelectItem>
|
||||
{subjects.map((subject) => (
|
||||
<SelectItem key={subject} value={subject}>
|
||||
{subject}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterStatus} onValueChange={(v) => setFilterStatus(v as any)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="graded">Graded</SelectItem>
|
||||
<SelectItem value="released">Released</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* View type switcher */}
|
||||
<div className="flex gap-1 border rounded-lg p-1 bg-muted">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={viewType === 'grid' ? 'default' : 'ghost'}
|
||||
onClick={() => setViewType('grid')}
|
||||
className="flex-1"
|
||||
>
|
||||
<TableIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={viewType === 'list' ? 'default' : 'ghost'}
|
||||
onClick={() => setViewType('list')}
|
||||
className="flex-1"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Views */}
|
||||
<AnimatePresence mode="wait">
|
||||
{viewType === 'grid' && renderDesktopView()}
|
||||
{viewType === 'list' && renderListView()}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Edit grade dialog */}
|
||||
{isEditingGrade && selectedGrade && (
|
||||
<GradeEditDialog
|
||||
grade={selectedGrade}
|
||||
onClose={() => setIsEditingGrade(false)}
|
||||
onUpdate={handleUpdateGrade}
|
||||
onDelete={handleDeleteGrade}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grade Edit Dialog
|
||||
function GradeEditDialog({
|
||||
grade,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
grade: Grade;
|
||||
onClose: () => void;
|
||||
onUpdate: (grade: Grade) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const [formData, setFormData] = useState(grade);
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Grade: {grade.studentName}</DialogTitle>
|
||||
<DialogDescription>{grade.subject} - {grade.assessment}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>Score</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.score}
|
||||
onChange={(e) => setFormData({ ...formData, score: parseFloat(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Max Score</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.maxScore}
|
||||
onChange={(e) => setFormData({ ...formData, maxScore: parseFloat(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Percentage: {((formData.score / formData.maxScore) * 100).toFixed(1)}%</Label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Comment</Label>
|
||||
<Input
|
||||
value={formData.comment || ''}
|
||||
onChange={(e) => setFormData({ ...formData, comment: e.target.value })}
|
||||
placeholder="Add a comment..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-red-500"
|
||||
onClick={() => {
|
||||
onDelete(grade.id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onUpdate(formData);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnifiedGradingPanel;
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Unified Calendar Manager
|
||||
* Consolidates all calendar-related operations (events, exams, school events)
|
||||
* Eliminates duplication across 3 calendar implementations
|
||||
* Provides centralized API for calendar operations with drag-and-drop support
|
||||
*/
|
||||
|
||||
import { format, parseISO, isBefore, isAfter, isSameDay } from 'date-fns';
|
||||
|
||||
export type CalendarEventType =
|
||||
| 'exam'
|
||||
| 'lesson'
|
||||
| 'holiday'
|
||||
| 'term'
|
||||
| 'school_event'
|
||||
| 'assignment'
|
||||
| 'illness'
|
||||
| 'custom';
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
type: CalendarEventType;
|
||||
title: string;
|
||||
description?: string;
|
||||
date: Date;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
allDay?: boolean;
|
||||
location?: string;
|
||||
attendees?: string[];
|
||||
recurring?: boolean;
|
||||
recurrencePattern?: string;
|
||||
color?: string;
|
||||
createdBy?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
// Links to other entities
|
||||
examId?: string;
|
||||
schoolEventId?: string;
|
||||
assignmentId?: string;
|
||||
classGroupId?: string;
|
||||
}
|
||||
|
||||
export interface CalendarEventWithMeta extends CalendarEvent {
|
||||
isOverdue?: boolean;
|
||||
daysUntil?: number;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface DragDropPayload {
|
||||
eventId: string;
|
||||
eventType: CalendarEventType;
|
||||
sourceDate: Date;
|
||||
targetDate: Date;
|
||||
startTime?: string;
|
||||
}
|
||||
|
||||
export interface CalendarFilterOptions {
|
||||
types?: CalendarEventType[];
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
classGroupId?: string;
|
||||
search?: string;
|
||||
showPast?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized calendar utility functions
|
||||
* Used by all calendar implementations to ensure consistency
|
||||
*/
|
||||
export class CalendarManager {
|
||||
/**
|
||||
* Get all events for a date range
|
||||
* Consolidates events from exams, school events, and general calendar
|
||||
*/
|
||||
static getEventsInRange(
|
||||
events: CalendarEvent[],
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
filter?: CalendarFilterOptions
|
||||
): CalendarEventWithMeta[] {
|
||||
return events
|
||||
.filter((event) => {
|
||||
const eventDate = event.date instanceof Date ? event.date : parseISO(event.date.toString());
|
||||
|
||||
// Date range check
|
||||
if (isBefore(eventDate, startDate) || isAfter(eventDate, endDate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (filter?.types && !filter.types.includes(event.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Class group filter
|
||||
if (filter?.classGroupId && event.classGroupId !== filter.classGroupId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (filter?.search) {
|
||||
const searchLower = filter.search.toLowerCase();
|
||||
return (
|
||||
event.title.toLowerCase().includes(searchLower) ||
|
||||
event.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
// Past events filter
|
||||
if (!filter?.showPast && isBefore(eventDate, new Date())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((event) => {
|
||||
const eventDate = event.date instanceof Date ? event.date : parseISO(event.date.toString());
|
||||
const now = new Date();
|
||||
const daysUntil = Math.ceil((eventDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return {
|
||||
...event,
|
||||
isOverdue: isBefore(eventDate, now) && !isSameDay(eventDate, now),
|
||||
daysUntil,
|
||||
category: this.getCategoryForType(event.type),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aDate = a.date instanceof Date ? a.date : parseISO(a.date.toString());
|
||||
const bDate = b.date instanceof Date ? b.date : parseISO(b.date.toString());
|
||||
return aDate.getTime() - bDate.getTime();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events for a specific date
|
||||
*/
|
||||
static getEventsForDate(events: CalendarEvent[], date: Date): CalendarEventWithMeta[] {
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfDay = new Date(date);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
return this.getEventsInRange(events, startOfDay, endOfDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag-and-drop of calendar events
|
||||
* Updates event date and notifies backend
|
||||
*/
|
||||
static handleEventDrop(
|
||||
event: CalendarEvent,
|
||||
payload: DragDropPayload,
|
||||
onUpdate: (event: CalendarEvent) => Promise<void>
|
||||
): Promise<CalendarEvent> {
|
||||
const updatedEvent: CalendarEvent = {
|
||||
...event,
|
||||
date: payload.targetDate,
|
||||
startTime: payload.startTime || event.startTime,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
return onUpdate(updatedEvent).then(() => updatedEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for event type
|
||||
* Ensures consistent coloring across all calendar views
|
||||
*/
|
||||
static getColorForType(type: CalendarEventType): string {
|
||||
const colors: Record<CalendarEventType, string> = {
|
||||
exam: 'bg-red-100 text-red-800 border-red-300',
|
||||
lesson: 'bg-blue-100 text-blue-800 border-blue-300',
|
||||
holiday: 'bg-green-100 text-green-800 border-green-300',
|
||||
term: 'bg-purple-100 text-purple-800 border-purple-300',
|
||||
school_event: 'bg-yellow-100 text-yellow-800 border-yellow-300',
|
||||
assignment: 'bg-orange-100 text-orange-800 border-orange-300',
|
||||
illness: 'bg-pink-100 text-pink-800 border-pink-300',
|
||||
custom: 'bg-gray-100 text-gray-800 border-gray-300',
|
||||
};
|
||||
return colors[type] || colors.custom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for event type
|
||||
*/
|
||||
static getIconForType(type: CalendarEventType): string {
|
||||
const icons: Record<CalendarEventType, string> = {
|
||||
exam: '📝',
|
||||
lesson: '📚',
|
||||
holiday: '🎉',
|
||||
term: '📅',
|
||||
school_event: '🏫',
|
||||
assignment: '✏️',
|
||||
illness: '🤒',
|
||||
custom: '📌',
|
||||
};
|
||||
return icons[type] || '📌';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category name for event type
|
||||
*/
|
||||
static getCategoryForType(type: CalendarEventType): string {
|
||||
const categories: Record<CalendarEventType, string> = {
|
||||
exam: 'Exams',
|
||||
lesson: 'Lessons',
|
||||
holiday: 'Holidays',
|
||||
term: 'Terms',
|
||||
school_event: 'Events',
|
||||
assignment: 'Assignments',
|
||||
illness: 'Illness',
|
||||
custom: 'Other',
|
||||
};
|
||||
return categories[type] || 'Other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if event is all day or timed
|
||||
*/
|
||||
static isAllDay(event: CalendarEvent): boolean {
|
||||
return event.allDay || !event.startTime || !event.endTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format event time range
|
||||
*/
|
||||
static formatTimeRange(event: CalendarEvent, locale?: string): string {
|
||||
if (this.isAllDay(event)) {
|
||||
return 'All Day';
|
||||
}
|
||||
|
||||
if (event.startTime && event.endTime) {
|
||||
return `${event.startTime} - ${event.endTime}`;
|
||||
}
|
||||
|
||||
return event.startTime || 'TBD';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for conflicts with other events
|
||||
*/
|
||||
static hasConflict(
|
||||
event: CalendarEvent,
|
||||
otherEvents: CalendarEvent[]
|
||||
): CalendarEvent | null {
|
||||
if (event.allDay) return null;
|
||||
|
||||
return otherEvents.find((other) => {
|
||||
const eventDate = event.date instanceof Date ? event.date : parseISO(event.date.toString());
|
||||
const otherDate = other.date instanceof Date ? other.date : parseISO(other.date.toString());
|
||||
|
||||
if (!isSameDay(eventDate, otherDate)) return false;
|
||||
if (other.allDay) return false;
|
||||
|
||||
const eventStart = event.startTime || '';
|
||||
const eventEnd = event.endTime || '';
|
||||
const otherStart = other.startTime || '';
|
||||
const otherEnd = other.endTime || '';
|
||||
|
||||
return (
|
||||
(eventStart < otherEnd && eventEnd > otherStart) ||
|
||||
(otherStart < eventEnd && otherEnd > eventStart)
|
||||
);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events that span multiple days (recurring or long events)
|
||||
*/
|
||||
static getRecurringEvents(
|
||||
event: CalendarEvent,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): CalendarEvent[] {
|
||||
if (!event.recurring || !event.recurrencePattern) {
|
||||
return [event];
|
||||
}
|
||||
|
||||
// Implement recurrence logic based on pattern
|
||||
// This is a simplified version - expand as needed
|
||||
const events: CalendarEvent[] = [];
|
||||
let currentDate = new Date(event.date instanceof Date ? event.date : parseISO(event.date.toString()));
|
||||
|
||||
while (isBefore(currentDate, endDate) || isSameDay(currentDate, endDate)) {
|
||||
if (isAfter(currentDate, startDate) || isSameDay(currentDate, startDate)) {
|
||||
events.push({
|
||||
...event,
|
||||
date: new Date(currentDate),
|
||||
id: `${event.id}-${format(currentDate, 'yyyy-MM-dd')}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Move to next occurrence based on pattern
|
||||
currentDate.setDate(currentDate.getDate() + 7); // Default: weekly
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge events from multiple sources and deduplicate
|
||||
*/
|
||||
static mergeEvents(
|
||||
examEvents: CalendarEvent[] = [],
|
||||
schoolEvents: CalendarEvent[] = [],
|
||||
generalEvents: CalendarEvent[] = []
|
||||
): CalendarEvent[] {
|
||||
const allEvents = [...examEvents, ...schoolEvents, ...generalEvents];
|
||||
|
||||
// Remove duplicates based on id
|
||||
const seen = new Set<string>();
|
||||
return allEvents.filter((event) => {
|
||||
if (seen.has(event.id)) return false;
|
||||
seen.add(event.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default CalendarManager;
|
||||
@@ -48,6 +48,7 @@ export interface NavGroup {
|
||||
export const NAVIGATION_ROUTES = {
|
||||
DASHBOARD: '/dashboard',
|
||||
GRADES: '/grades',
|
||||
CALENDAR: '/calendar',
|
||||
ASSESSMENTS: '/assessments',
|
||||
CLASSES: '/classes',
|
||||
COMMUNICATION: '/communication',
|
||||
@@ -92,6 +93,14 @@ export const TEACHER_NAV_ITEMS: NavGroup[] = [
|
||||
id: 'academic',
|
||||
title: 'Academic',
|
||||
items: [
|
||||
{
|
||||
id: 'calendar',
|
||||
label: 'Calendar',
|
||||
href: NAVIGATION_ROUTES.CALENDAR,
|
||||
icon: Calendar,
|
||||
roles: ['TEACHER', 'STUDENT', 'PARENT', 'SCHOOL_ADMIN'],
|
||||
description: 'View all events, exams, and school calendar',
|
||||
},
|
||||
{
|
||||
id: 'grades',
|
||||
label: 'Grades',
|
||||
@@ -106,13 +115,6 @@ export const TEACHER_NAV_ITEMS: NavGroup[] = [
|
||||
icon: FileText,
|
||||
roles: ['TEACHER', 'SCHOOL_ADMIN'],
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
label: 'Calendar',
|
||||
href: NAVIGATION_ROUTES.ATTENDANCE, // Placeholder
|
||||
icon: Calendar,
|
||||
roles: ['TEACHER'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user