feat: add comprehensive consolidation index and documentation

Co-authored-by: cetinmustafa83 <30570791+cetinmustafa83@users.noreply.github.com>
This commit is contained in:
v0andcetinmustafa83 committed 2026-08-01 09:40:03 +00:00
1 parent 925feab7fe
commit 3f5bbc5e04
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
+692
View File
@@ -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
+581
View File
@@ -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;
+323
View File
@@ -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;
+9 -7
View File
@@ -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'],
},
],
},
{