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 [isCreating, setIsCreating] = useState(false);
|
||||||
const [draggedEvent, setDraggedEvent] = useState<CalendarEvent | null>(null);
|
const [draggedEvent, setDraggedEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [view, setView] = useState<'month' | 'week' | 'day' | 'agenda'>(variant);
|
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 contextMenuRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
const dateLocale = locale === 'de' ? deLocale : enLocale;
|
const dateLocale = locale === 'de' ? deLocale : enLocale;
|
||||||
@@ -132,9 +133,21 @@ export function ProfessionalCalendar({
|
|||||||
}, [events, monthStart, monthEnd]);
|
}, [events, monthStart, monthEnd]);
|
||||||
|
|
||||||
// Get events for a specific date
|
// 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[] => {
|
const getEventsForDate = useCallback((date: Date): CalendarEvent[] => {
|
||||||
return CalendarManager.getEventsForDate(events, date);
|
return CalendarManager.getEventsForDate(filteredEvents, date);
|
||||||
}, [events]);
|
}, [filteredEvents]);
|
||||||
|
|
||||||
// Handle event creation
|
// Handle event creation
|
||||||
const handleCreateEvent = useCallback(
|
const handleCreateEvent = useCallback(
|
||||||
@@ -342,14 +355,26 @@ export function ProfessionalCalendar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* View tabs */}
|
{/* View tabs */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Tabs value={view} onValueChange={(v) => setView(v as any)}>
|
<Tabs value={view} onValueChange={(v) => setView(v as any)}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="month">Month</TabsTrigger>
|
<TabsTrigger value="month">Monat</TabsTrigger>
|
||||||
<TabsTrigger value="week">Week</TabsTrigger>
|
<TabsTrigger value="week">Woche</TabsTrigger>
|
||||||
<TabsTrigger value="agenda">Agenda</TabsTrigger>
|
<TabsTrigger value="agenda">Agenda</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</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
|
<Button
|
||||||
onClick={() => setIsCreating(true)}
|
onClick={() => setIsCreating(true)}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Smartphone,
|
Smartphone,
|
||||||
Monitor,
|
Monitor,
|
||||||
Table as TableIcon,
|
Table as TableIcon,
|
||||||
|
List as ListIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -411,6 +412,91 @@ export function UnifiedGradingPanel({
|
|||||||
</div>
|
</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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
@@ -478,6 +564,7 @@ export function UnifiedGradingPanel({
|
|||||||
variant={viewType === 'grid' ? 'default' : 'ghost'}
|
variant={viewType === 'grid' ? 'default' : 'ghost'}
|
||||||
onClick={() => setViewType('grid')}
|
onClick={() => setViewType('grid')}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
title="Tabelle"
|
||||||
>
|
>
|
||||||
<TableIcon className="h-4 w-4" />
|
<TableIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -486,8 +573,18 @@ export function UnifiedGradingPanel({
|
|||||||
variant={viewType === 'list' ? 'default' : 'ghost'}
|
variant={viewType === 'list' ? 'default' : 'ghost'}
|
||||||
onClick={() => setViewType('list')}
|
onClick={() => setViewType('list')}
|
||||||
className="flex-1"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,6 +594,7 @@ export function UnifiedGradingPanel({
|
|||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{viewType === 'grid' && renderDesktopView()}
|
{viewType === 'grid' && renderDesktopView()}
|
||||||
{viewType === 'list' && renderListView()}
|
{viewType === 'list' && renderListView()}
|
||||||
|
{viewType === 'analytics' && renderAnalyticsView()}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Edit grade dialog */}
|
{/* Edit grade dialog */}
|
||||||
|
|||||||
Reference in new issue
Block a user