Add calendar event filter tabs and grading analytics view
Calendar Enhancement: - Added event filter tabs to ProfessionalCalendar: 'Alle' | 'Prüfungen' | 'Veranstaltungen' | 'Ferien' These replace the removed exam-calendar and school-events nav entries - Added filteredEvents useMemo for efficient filtering - Localized view tabs to German (Monat/Woche/Agenda) Grading Enhancement: - Added Analytics view to UnifiedGradingPanel (3rd view mode) This replaces the removed grade-analytics nav entry - Shows: Durchschnitt, Bestehensquote, Noten gesamt, Fächer - Subject breakdown with progress bars - Grade distribution histogram (5 buckets) - Added ListIcon and TrendingUp icons to view switcher
This commit is contained in:
1 parent
af0b0a5272
commit
0c7a69ffe0
2 files changed
+128
-5
No files matched your search
@@ -94,6 +94,7 @@ export function ProfessionalCalendar({
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [draggedEvent, setDraggedEvent] = useState<CalendarEvent | null>(null);
|
||||
const [view, setView] = useState<'month' | 'week' | 'day' | 'agenda'>(variant);
|
||||
const [eventFilter, setEventFilter] = useState<'all' | 'exams' | 'events' | 'holidays'>('all');
|
||||
const contextMenuRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const dateLocale = locale === 'de' ? deLocale : enLocale;
|
||||
@@ -132,9 +133,21 @@ export function ProfessionalCalendar({
|
||||
}, [events, monthStart, monthEnd]);
|
||||
|
||||
// Get events for a specific date
|
||||
// Filtered events based on the active filter tab
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (eventFilter === 'all') return events;
|
||||
const filterMap: Record<string, string[]> = {
|
||||
exams: ['EXAM', 'TEST', 'ASSESSMENT'],
|
||||
events: ['EVENT', 'ACTIVITY', 'MEETING', 'TRIP'],
|
||||
holidays: ['HOLIDAY', 'BREAK'],
|
||||
};
|
||||
const types = filterMap[eventFilter] || [];
|
||||
return events.filter(e => types.includes(e.type?.toUpperCase() || ''));
|
||||
}, [events, eventFilter]);
|
||||
|
||||
const getEventsForDate = useCallback((date: Date): CalendarEvent[] => {
|
||||
return CalendarManager.getEventsForDate(events, date);
|
||||
}, [events]);
|
||||
return CalendarManager.getEventsForDate(filteredEvents, date);
|
||||
}, [filteredEvents]);
|
||||
|
||||
// Handle event creation
|
||||
const handleCreateEvent = useCallback(
|
||||
@@ -342,14 +355,26 @@ export function ProfessionalCalendar({
|
||||
</div>
|
||||
|
||||
{/* View tabs */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as any)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="month">Month</TabsTrigger>
|
||||
<TabsTrigger value="week">Week</TabsTrigger>
|
||||
<TabsTrigger value="month">Monat</TabsTrigger>
|
||||
<TabsTrigger value="week">Woche</TabsTrigger>
|
||||
<TabsTrigger value="agenda">Agenda</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Event filter tabs (replaces exam-calendar and school-events) */}
|
||||
<Tabs value={eventFilter} onValueChange={(v) => setEventFilter(v as any)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Alle</TabsTrigger>
|
||||
<TabsTrigger value="exams">Prüfungen</TabsTrigger>
|
||||
<TabsTrigger value="events">Veranstaltungen</TabsTrigger>
|
||||
<TabsTrigger value="holidays">Ferien</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="gap-2"
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Smartphone,
|
||||
Monitor,
|
||||
Table as TableIcon,
|
||||
List as ListIcon,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -411,6 +412,91 @@ export function UnifiedGradingPanel({
|
||||
</div>
|
||||
);
|
||||
|
||||
// Analytics view (replaces the removed grade-analytics nav entry)
|
||||
const renderAnalyticsView = () => {
|
||||
const avgScore = grades.length > 0
|
||||
? (grades.reduce((s, g) => s + (g.score / g.maxScore) * 100, 0) / grades.length).toFixed(1)
|
||||
: '0';
|
||||
const passRate = grades.length > 0
|
||||
? Math.round((grades.filter(g => (g.score / g.maxScore) >= 0.5).length / grades.length) * 100)
|
||||
: 0;
|
||||
|
||||
// Distribution by subject
|
||||
const subjectStats: Record<string, { count: number; avg: number }> = {};
|
||||
grades.forEach(g => {
|
||||
if (!subjectStats[g.subject]) subjectStats[g.subject] = { count: 0, avg: 0 };
|
||||
subjectStats[g.subject].count++;
|
||||
subjectStats[g.subject].avg += (g.score / g.maxScore) * 100;
|
||||
});
|
||||
Object.keys(subjectStats).forEach(s => {
|
||||
subjectStats[s].avg = Math.round(subjectStats[s].avg / subjectStats[s].count);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Durchschnitt</p><p className="text-2xl font-bold text-emerald-600">{avgScore}%</p></CardContent></Card>
|
||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Bestehensquote</p><p className="text-2xl font-bold text-sky-600">{passRate}%</p></CardContent></Card>
|
||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Noten gesamt</p><p className="text-2xl font-bold">{grades.length}</p></CardContent></Card>
|
||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Fächer</p><p className="text-2xl font-bold">{Object.keys(subjectStats).length}</p></CardContent></Card>
|
||||
</div>
|
||||
|
||||
{/* Subject breakdown */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Noten nach Fach</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Object.entries(subjectStats).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">Keine Daten verfügbar</p>
|
||||
) : (
|
||||
Object.entries(subjectStats).map(([subject, data]) => (
|
||||
<div key={subject} className="flex items-center gap-3">
|
||||
<div className="w-32 text-sm font-medium truncate">{subject}</div>
|
||||
<div className="flex-1 h-6 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-emerald-400 to-teal-500 flex items-center justify-end pr-2" style={{ width: `${data.avg}%` }}>
|
||||
<span className="text-[10px] font-bold text-white">{data.avg}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground w-16 text-right">{data.count} Noten</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Grade distribution */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Notenverteilung</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-end gap-2 h-40">
|
||||
{[
|
||||
{ label: 'unter 50%', min: 0, max: 50, color: 'bg-rose-500' },
|
||||
{ label: '50-60%', min: 50, max: 60, color: 'bg-orange-500' },
|
||||
{ label: '60-75%', min: 60, max: 75, color: 'bg-amber-500' },
|
||||
{ label: '75-90%', min: 75, max: 90, color: 'bg-sky-500' },
|
||||
{ label: 'ueber 90%', min: 90, max: 101, color: 'bg-emerald-500' },
|
||||
].map(bucket => {
|
||||
const count = grades.filter(g => {
|
||||
const pct = (g.score / g.maxScore) * 100;
|
||||
return pct >= bucket.min && pct < bucket.max;
|
||||
}).length;
|
||||
const max = Math.max(grades.length, 1);
|
||||
const height = max > 0 ? (count / max) * 100 : 0;
|
||||
return (
|
||||
<div key={bucket.label} className="flex-1 flex flex-col items-center gap-1">
|
||||
<span className="text-xs font-bold">{count}</span>
|
||||
<div className={`w-full ${bucket.color} rounded-t transition-all`} style={{ height: `${height}%`, minHeight: count > 0 ? '8px' : '0' }} />
|
||||
<span className="text-[10px] text-muted-foreground">{bucket.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
@@ -478,6 +564,7 @@ export function UnifiedGradingPanel({
|
||||
variant={viewType === 'grid' ? 'default' : 'ghost'}
|
||||
onClick={() => setViewType('grid')}
|
||||
className="flex-1"
|
||||
title="Tabelle"
|
||||
>
|
||||
<TableIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -486,8 +573,18 @@ export function UnifiedGradingPanel({
|
||||
variant={viewType === 'list' ? 'default' : 'ghost'}
|
||||
onClick={() => setViewType('list')}
|
||||
className="flex-1"
|
||||
title="Liste"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
<ListIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={viewType === 'analytics' ? 'default' : 'ghost'}
|
||||
onClick={() => setViewType('analytics')}
|
||||
className="flex-1"
|
||||
title="Analyse"
|
||||
>
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -497,6 +594,7 @@ export function UnifiedGradingPanel({
|
||||
<AnimatePresence mode="wait">
|
||||
{viewType === 'grid' && renderDesktopView()}
|
||||
{viewType === 'list' && renderListView()}
|
||||
{viewType === 'analytics' && renderAnalyticsView()}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Edit grade dialog */}
|
||||
|
||||
Reference in new issue
Block a user