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;
|
||||
Reference in new issue
Block a user