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;