Round 22: Exam Calendar, Tablet Grading, Student Achievements, Notification Center, Announcements, Styling

- Exam Calendar View: Teacher exam planning with calendar integration
  - Teachers create/modify exams, admin full CRUD, students read-only
  - CalendarEvent integration, 2-week warning, AI test availability badge
  - Monthly/weekly calendar view, list view, statistics

- Tablet Grading View: Stylus annotation and AI grading audit
  - HTML5 Canvas with PointerEvent pressure sensitivity
  - Tool palette: pen, highlighter, eraser, text, stamp
  - Quick grade buttons (German 1-6 scale)
  - AI grading audit with detailed feedback and suggestions

- Student Achievements View: Badges, XP, leaderboard
  - Badge collection grid with category filters
  - Level/XP system with progress bars
  - Leaderboard with opt-in
  - Daily/weekly challenges

- Student Study Planner View: Pomodoro timer, weekly schedule
  - Pomodoro timer with focus score
  - Weekly study schedule with drag-and-drop
  - AI study recommendations
  - Session history and statistics

- Notification Center View: Categories, filters, bulk actions
  - 6 categories, 4 priority levels
  - Real-time polling (10 seconds)
  - Preferences with quiet hours
  - Statistics charts

- School Announcements View: CRUD, read receipts
  - 6 announcement types with gradient headers
  - Pin-to-top, read receipts, target audience
  - Admin/teacher create, students/parents view

- Bug fixes: AnnouncementRead model user relation, User API schoolId field
- i18n: 200+ new keys, breadcrumb navigation fixes
- Styling: Gradient headers, animations, hover effects across all new views
This commit is contained in:
Mustafa Cetin committed 2026-07-29 22:56:40 +00:00
1 parent 9e22214470
commit 490bd0650f
30 files changed
+10844 -31

No files matched your search

+38 -1
View File
@@ -63,6 +63,9 @@ import {
MessageSquare,
Brain,
Sparkles,
Tablet,
CalendarClock,
Timer,
} from 'lucide-react';
import {
Sidebar,
@@ -168,6 +171,12 @@ import AITestsView from './ai-tests-view';
import AIChatWidget from './ai-chat-widget';
import VirtualCharacter from './virtual-character';
import ParentPortalView from './parent-portal-view';
import NotificationCenterView from './notification-center-view';
import SchoolAnnouncementsView from './school-announcements-view';
import TabletGradingView from './tablet-grading-view';
import ExamCalendarView from './exam-calendar-view';
import StudentAchievementsView from './student-achievements-view';
import StudentStudyPlannerView from './student-study-planner-view';
// Dynamic imports for heavy components with loading skeletons
const AnalyticsView = dynamic(() => import('./analytics-view'), {
@@ -232,6 +241,10 @@ const navSections: NavSection[] = [
{ key: 'counseling', icon: Heart, labelKey: 'counseling.title' },
{ key: 'disciplinary', icon: Shield, labelKey: 'disciplinary.title' },
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
{ key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' },
{ key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' },
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
],
},
{
@@ -266,7 +279,13 @@ const studentNavSections: NavSection[] = [
{ key: 'communication', icon: MessageSquare, labelKey: 'nav.communication' },
{ key: 'counseling', icon: Heart, labelKey: 'counseling.title' },
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
{ key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' },
{ key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' },
{ key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' },
{ key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' },
{ key: 'resources', icon: FolderOpen, labelKey: 'nav.resources' },
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
{ key: 'settings', icon: Settings, labelKey: 'nav.settings' },
],
},
@@ -287,6 +306,10 @@ const parentNavSections: NavSection[] = [
{ key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' },
{ key: 'illness', icon: Heart, labelKey: 'nav.illness' },
{ key: 'communication', icon: MessageSquare, labelKey: 'nav.communication' },
{ key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' },
{ key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' },
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
],
},
];
@@ -426,6 +449,12 @@ function renderView(view: ViewName) {
case 'disciplinary': return <DisciplinaryView />;
case 'ai-tests': return <AITestsView />;
case 'parent-portal': return <ParentPortalView />;
case 'notification-center': return <NotificationCenterView />;
case 'announcements': return <SchoolAnnouncementsView />;
case 'tablet-grading': return <TabletGradingView />;
case 'exam-calendar': return <ExamCalendarView />;
case 'student-achievements': return <StudentAchievementsView />;
case 'student-study-planner': return <StudentStudyPlannerView />;
default: return <DashboardView />;
}
}
@@ -1246,7 +1275,15 @@ export default function AppLayout() {
</ScrollArea>
{/* Footer */}
{!notifLoading && notifData && notifData.notifications.length > 0 && (
<div className="px-3 py-2 border-t border-emerald-100/30 dark:border-emerald-900/20 flex gap-2">
<div className="px-3 py-2 border-t border-emerald-100/30 dark:border-emerald-900/20 flex gap-2 flex-wrap">
<Button
variant="ghost"
size="sm"
onClick={() => { setNotifOpen(false); setCurrentView('notification-center'); }}
className="h-7 text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 font-semibold"
>
<Bell className="w-3 h-3 mr-0.5" /> {t('notif_center.title')}
</Button>
<Button
variant="ghost"
size="sm"
File diff suppressed because it is too large. Load diff
+947
View File
@@ -0,0 +1,947 @@
'use client';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Bell,
Check,
CheckCircle2,
Trash2,
Archive,
Filter,
BookOpen,
Shield,
Building2,
Calendar,
MessageSquare,
Settings,
ChevronDown,
ChevronUp,
X,
AlertTriangle,
Clock,
Eye,
BarChart3,
Volume2,
VolumeX,
Mail,
RefreshCw,
ChevronRight,
Info,
} 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 { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPut, apiPost, apiDelete } from '@/lib/api';
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from 'recharts';
/* ── Types ─────────────────────────────────────────────────────── */
interface NotificationItem {
id: string;
type: string;
category: string;
priority: string;
title: string;
message: string;
isRead: boolean;
isArchived: boolean;
actionUrl: string | null;
relatedId: string | null;
createdAt: string;
}
interface NotificationStats {
byCategory: { category: string; count: number }[];
}
interface NotificationData {
notifications: NotificationItem[];
unreadCount: number;
stats: NotificationStats;
}
interface PreferencesData {
id: string;
userId: string;
schoolId: string;
academicEnabled: boolean;
behavioralEnabled: boolean;
administrativeEnabled: boolean;
calendarEnabled: boolean;
communicationEnabled: boolean;
systemEnabled: boolean;
quietHoursStart: string | null;
quietHoursEnd: string | null;
quietHoursEnabled: boolean;
emailDigestEnabled: boolean;
emailDigestFrequency: string;
}
/* ── Category Config ───────────────────────────────────────────── */
const CATEGORIES = [
{ key: 'academic', labelKey: 'notif_center.category_academic', icon: BookOpen, color: 'bg-emerald-500', lightColor: 'bg-emerald-100 dark:bg-emerald-900/30', textColor: 'text-emerald-600 dark:text-emerald-400' },
{ key: 'behavioral', labelKey: 'notif_center.category_behavioral', icon: Shield, color: 'bg-rose-500', lightColor: 'bg-rose-100 dark:bg-rose-900/30', textColor: 'text-rose-600 dark:text-rose-400' },
{ key: 'administrative', labelKey: 'notif_center.category_administrative', icon: Building2, color: 'bg-amber-500', lightColor: 'bg-amber-100 dark:bg-amber-900/30', textColor: 'text-amber-600 dark:text-amber-400' },
{ key: 'calendar', labelKey: 'notif_center.category_calendar', icon: Calendar, color: 'bg-teal-500', lightColor: 'bg-teal-100 dark:bg-teal-900/30', textColor: 'text-teal-600 dark:text-teal-400' },
{ key: 'communication', labelKey: 'notif_center.category_communication', icon: MessageSquare, color: 'bg-violet-500', lightColor: 'bg-violet-100 dark:bg-violet-900/30', textColor: 'text-violet-600 dark:text-violet-400' },
{ key: 'system', labelKey: 'notif_center.category_system', icon: Settings, color: 'bg-gray-500', lightColor: 'bg-gray-100 dark:bg-gray-900/30', textColor: 'text-gray-600 dark:text-gray-400' },
] as const;
const PRIORITIES = [
{ key: 'urgent', labelKey: 'notif_center.priority_urgent', color: 'from-rose-500 to-red-600', badgeColor: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 border-rose-200 dark:border-rose-800' },
{ key: 'high', labelKey: 'notif_center.priority_high', color: 'from-amber-500 to-orange-500', badgeColor: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 border-amber-200 dark:border-amber-800' },
{ key: 'normal', labelKey: 'notif_center.priority_normal', color: 'from-emerald-500 to-teal-500', badgeColor: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800' },
{ key: 'low', labelKey: 'notif_center.priority_low', color: 'from-gray-400 to-gray-500', badgeColor: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-200 dark:border-gray-700' },
] as const;
/* ── Helpers ───────────────────────────────────────────────────── */
function getCategoryConfig(category: string) {
return CATEGORIES.find((c) => c.key === category) || CATEGORIES[5];
}
function getPriorityConfig(priority: string) {
return PRIORITIES.find((p) => p.key === priority) || PRIORITIES[2];
}
function formatRelativeTime(dateStr: string): string {
const now = new Date();
const date = new Date(dateStr);
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return t('notif_center.just_now');
if (diffMin < 60) return t('notif_center.minutes_ago').replace('{n}', String(diffMin));
if (diffHour < 24) return t('notif_center.hours_ago').replace('{n}', String(diffHour));
if (diffDay < 7) return t('notif_center.days_ago').replace('{n}', String(diffDay));
return date.toLocaleDateString();
}
const CHART_COLORS = ['#10b981', '#f43f5e', '#f59e0b', '#14b8a6', '#8b5cf6', '#6b7280'];
/* ── Component ─────────────────────────────────────────────────── */
export default function NotificationCenterView() {
const currentUser = useAppStore((s) => s.currentUser);
const setCurrentView = useAppStore((s) => s.setCurrentView);
const [data, setData] = useState<NotificationData | null>(null);
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [expandedId, setExpandedId] = useState<string | null>(null);
// Filters
const [filterCategory, setFilterCategory] = useState('all');
const [filterPriority, setFilterPriority] = useState('all');
const [filterRead, setFilterRead] = useState('all');
const [showArchived, setShowArchived] = useState(false);
const [showFilters, setShowFilters] = useState(false);
// Preferences
const [prefs, setPrefs] = useState<PreferencesData | null>(null);
const [prefsOpen, setPrefsOpen] = useState(false);
// Active tab
const [activeTab, setActiveTab] = useState('all');
const role = currentUser?.role || 'TEACHER';
const isStudent = role === 'STUDENT';
const isParent = role === 'PARENT';
/* ── Load Data ──────────────────────────────────────────────── */
const loadData = useCallback(async () => {
try {
const params = new URLSearchParams();
if (filterCategory !== 'all') params.set('category', filterCategory);
if (filterPriority !== 'all') params.set('priority', filterPriority);
if (filterRead !== 'all') params.set('isRead', filterRead);
if (showArchived) params.set('isArchived', 'true');
else params.set('isArchived', 'false');
const result = await apiGet<NotificationData>(`/api/notifications?${params.toString()}`);
setData(result);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [filterCategory, filterPriority, filterRead, showArchived]);
const loadPrefs = useCallback(async () => {
try {
const result = await apiGet<PreferencesData>('/api/notifications/preferences');
setPrefs(result);
} catch {
// ignore
}
}, []);
useEffect(() => {
loadData();
loadPrefs();
}, [loadData, loadPrefs]);
// Polling every 10 seconds
useEffect(() => {
const interval = setInterval(loadData, 10000);
return () => clearInterval(interval);
}, [loadData]);
/* ── Actions ────────────────────────────────────────────────── */
const handleMarkRead = useCallback(async (id: string) => {
try {
await apiPut(`/api/notifications/${id}`, { action: 'read' });
setData((prev) => prev ? {
...prev,
notifications: prev.notifications.map((n) =>
n.id === id ? { ...n, isRead: true } : n
),
unreadCount: Math.max(0, prev.unreadCount - 1),
} : prev);
} catch {
// ignore
}
}, []);
const handleMarkAllRead = useCallback(async () => {
try {
await apiPut('/api/notifications', { markAll: true });
setData((prev) => prev ? {
...prev,
notifications: prev.notifications.map((n) => ({ ...n, isRead: true })),
unreadCount: 0,
} : prev);
} catch {
// ignore
}
}, []);
const handleArchive = useCallback(async (id: string) => {
try {
await apiPut(`/api/notifications/${id}`, { action: 'archive' });
setData((prev) => prev ? {
...prev,
notifications: prev.notifications.map((n) =>
n.id === id ? { ...n, isArchived: true } : n
),
} : prev);
} catch {
// ignore
}
}, []);
const handleDelete = useCallback(async (id: string) => {
try {
await apiDelete(`/api/notifications/${id}`);
setData((prev) => prev ? {
...prev,
notifications: prev.notifications.filter((n) => n.id !== id),
} : prev);
} catch {
// ignore
}
}, []);
const handleBulkAction = useCallback(async (action: 'read' | 'archive' | 'delete') => {
if (selectedIds.size === 0) return;
try {
const ids = Array.from(selectedIds);
if (action === 'delete') {
await apiPut('/api/notifications', { deleteIds: ids });
} else {
await apiPut('/api/notifications', { ids, action });
}
setSelectedIds(new Set());
loadData();
} catch {
// ignore
}
}, [selectedIds, loadData]);
const handleToggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleNavigate = useCallback((actionUrl: string | null) => {
if (actionUrl) {
setCurrentView(actionUrl as 'dashboard');
}
}, [setCurrentView]);
const handleSavePrefs = useCallback(async () => {
if (!prefs) return;
try {
await apiPut('/api/notifications/preferences', prefs);
setPrefsOpen(false);
} catch {
// ignore
}
}, [prefs]);
/* ── Filtered Data ──────────────────────────────────────────── */
const filteredNotifications = useMemo(() => {
if (!data?.notifications) return [];
let items = data.notifications;
// Tab-based filter
if (activeTab === 'unread') {
items = items.filter((n) => !n.isRead);
} else if (activeTab === 'archived') {
items = items.filter((n) => n.isArchived);
}
return items;
}, [data?.notifications, activeTab]);
/* ── Chart Data ─────────────────────────────────────────────── */
const chartData = useMemo(() => {
if (!data?.stats?.byCategory) return [];
return data.stats.byCategory.map((item) => ({
name: t(`notif_center.category_${item.category}`),
value: item.count,
category: item.category,
}));
}, [data?.stats]);
/* ── Render ─────────────────────────────────────────────────── */
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="flex items-center gap-3">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
<span className="text-gray-500">{t('notif_center.loading')}</span>
</div>
</div>
);
}
return (
<div className="space-y-6 p-4 md:p-6">
{/* Header with gradient banner */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-emerald-600 via-teal-500 to-emerald-500 p-6 shadow-lg"
>
{/* Decorative background shapes */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-4 -right-4 w-20 h-20 rounded-full bg-white/10" />
<div className="absolute bottom-0 left-1/3 w-16 h-16 rounded-full bg-white/5" />
</div>
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Bell className="h-6 w-6" />
{t('notif_center.title')}
{data && data.unreadCount > 0 && (
<motion.span
className="ml-1 inline-flex items-center justify-center h-6 min-w-[24px] rounded-full bg-white/20 text-white text-xs font-semibold px-2"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300 }}
>
{data.unreadCount}
</motion.span>
)}
</h1>
<p className="text-sm text-emerald-100 mt-1">
{t('notif_center.subtitle')}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={loadData}
className="bg-white/10 hover:bg-white/20 text-white border-white/30"
>
<RefreshCw className="h-4 w-4 mr-1" />
{t('action.refresh')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleMarkAllRead}
disabled={!data || data.unreadCount === 0}
className="bg-white/10 hover:bg-white/20 text-white border-white/30"
>
<CheckCircle2 className="h-4 w-4 mr-1" />
{t('notif_center.mark_all_read')}
</Button>
<Dialog open={prefsOpen} onOpenChange={setPrefsOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="bg-white/10 hover:bg-white/20 text-white border-white/30"
>
<Settings className="h-4 w-4 mr-1" />
{t('notif_center.preferences')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings className="h-5 w-5 text-emerald-600" />
{t('notif_center.preferences_title')}
</DialogTitle>
</DialogHeader>
{prefs && (
<div className="space-y-6">
{/* Category Toggles */}
<div>
<h4 className="text-sm font-semibold mb-3 text-gray-700 dark:text-gray-300">
{t('notif_center.category_toggles')}
</h4>
<div className="space-y-3">
{CATEGORIES.map((cat) => {
const key = `${cat.key}Enabled` as keyof PreferencesData;
const enabled = prefs[key] as boolean;
const Icon = cat.icon;
return (
<div key={cat.key} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${cat.lightColor}`}>
<Icon className={`h-4 w-4 ${cat.textColor}`} />
</div>
<Label className="text-sm">{t(cat.labelKey)}</Label>
</div>
<Switch
checked={enabled}
onCheckedChange={(checked) =>
setPrefs((prev) => prev ? { ...prev, [key]: checked } : prev)
}
/>
</div>
);
})}
</div>
</div>
<Separator />
{/* Quiet Hours */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{t('notif_center.quiet_hours')}
</h4>
<Switch
checked={prefs.quietHoursEnabled}
onCheckedChange={(checked) =>
setPrefs((prev) => prev ? { ...prev, quietHoursEnabled: checked } : prev)
}
/>
</div>
{prefs.quietHoursEnabled && (
<div className="flex items-center gap-2">
<Input
type="time"
value={prefs.quietHoursStart || '22:00'}
onChange={(e) =>
setPrefs((prev) => prev ? { ...prev, quietHoursStart: e.target.value } : prev)
}
className="w-32"
/>
<span className="text-gray-400">—</span>
<Input
type="time"
value={prefs.quietHoursEnd || '07:00'}
onChange={(e) =>
setPrefs((prev) => prev ? { ...prev, quietHoursEnd: e.target.value } : prev)
}
className="w-32"
/>
</div>
)}
</div>
<Separator />
{/* Email Digest */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 flex items-center gap-2">
<Mail className="h-4 w-4" />
{t('notif_center.email_digest')}
</h4>
<Switch
checked={prefs.emailDigestEnabled}
onCheckedChange={(checked) =>
setPrefs((prev) => prev ? { ...prev, emailDigestEnabled: checked } : prev)
}
/>
</div>
{prefs.emailDigestEnabled && (
<Select
value={prefs.emailDigestFrequency}
onValueChange={(value) =>
setPrefs((prev) => prev ? { ...prev, emailDigestFrequency: value } : prev)
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">{t('notif_center.digest_daily')}</SelectItem>
<SelectItem value="weekly">{t('notif_center.digest_weekly')}</SelectItem>
</SelectContent>
</Select>
)}
</div>
<Button onClick={handleSavePrefs} className="w-full bg-emerald-600 hover:bg-emerald-700">
{t('action.save')}
</Button>
</div>
)}
</DialogContent>
</Dialog>
</div>
</div>
</motion.div>
{/* Stats Cards with glassmorphism */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0 }}>
<Card className="border-emerald-200/50 dark:border-emerald-900/30 backdrop-blur-sm bg-card/80 hover:shadow-md transition-all">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
<Bell className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{data?.notifications.length ?? 0}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('notif_center.total')}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }}>
<Card className="border-rose-200/50 dark:border-rose-900/30 backdrop-blur-sm bg-card/80 hover:shadow-md transition-all">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-rose-100 dark:bg-rose-900/30 flex items-center justify-center">
<Eye className="h-5 w-5 text-rose-600 dark:text-rose-400" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{data?.unreadCount ?? 0}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('notif_center.unread')}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
<Card className="border-amber-200/50 dark:border-amber-900/30 backdrop-blur-sm bg-card/80 hover:shadow-md transition-all">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{data?.notifications.filter((n) => n.priority === 'urgent' || n.priority === 'high').length ?? 0}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('notif_center.urgent_high')}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }}>
<Card className="border-teal-200/50 dark:border-teal-900/30 backdrop-blur-sm bg-card/80 hover:shadow-md transition-all">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-teal-100 dark:bg-teal-900/30 flex items-center justify-center">
<Archive className="h-5 w-5 text-teal-600 dark:text-teal-400" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{data?.notifications.filter((n) => n.isArchived).length ?? 0}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('notif_center.archived')}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</div>
{/* Tabs + Filters */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<TabsList className="bg-emerald-50 dark:bg-emerald-950/30">
<TabsTrigger value="all" className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white">
{t('notif_center.tab_all')}
</TabsTrigger>
<TabsTrigger value="unread" className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white">
{t('notif_center.tab_unread')}
{data && data.unreadCount > 0 && (
<Badge className="ml-1 h-5 min-w-[20px] text-[10px] bg-emerald-600 text-white">{data.unreadCount}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="archived" className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white">
{t('notif_center.tab_archived')}
</TabsTrigger>
</TabsList>
<Button
variant="outline"
size="sm"
onClick={() => setShowFilters(!showFilters)}
className="text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-800"
>
<Filter className="h-4 w-4 mr-1" />
{t('action.filter')}
{showFilters ? <ChevronUp className="h-3 w-3 ml-1" /> : <ChevronDown className="h-3 w-3 ml-1" />}
</Button>
</div>
{/* Filter Panel */}
<AnimatePresence>
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<Card className="mt-4 border-emerald-200/50 dark:border-emerald-900/30">
<CardContent className="p-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<Label className="text-xs text-gray-500 mb-1">{t('notif_center.filter_category')}</Label>
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('notif_center.all_categories')}</SelectItem>
{CATEGORIES.map((cat) => (
<SelectItem key={cat.key} value={cat.key}>{t(cat.labelKey)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-gray-500 mb-1">{t('notif_center.filter_priority')}</Label>
<Select value={filterPriority} onValueChange={setFilterPriority}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('notif_center.all_priorities')}</SelectItem>
{PRIORITIES.map((p) => (
<SelectItem key={p.key} value={p.key}>{t(p.labelKey)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-gray-500 mb-1">{t('notif_center.filter_read')}</Label>
<Select value={filterRead} onValueChange={setFilterRead}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('notif_center.all_status')}</SelectItem>
<SelectItem value="false">{t('notif_center.unread_only')}</SelectItem>
<SelectItem value="true">{t('notif_center.read_only')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
{/* Bulk Actions */}
{selectedIds.size > 0 && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 mt-4 p-3 bg-emerald-50 dark:bg-emerald-950/30 rounded-xl border border-emerald-200 dark:border-emerald-800"
>
<span className="text-sm text-emerald-700 dark:text-emerald-300 font-medium">
{selectedIds.size} {t('notif_center.selected')}
</span>
<Button variant="outline" size="sm" onClick={() => handleBulkAction('read')} className="text-emerald-600 border-emerald-200 dark:border-emerald-800">
<Check className="h-3 w-3 mr-1" /> {t('notif_center.mark_read')}
</Button>
<Button variant="outline" size="sm" onClick={() => handleBulkAction('archive')} className="text-emerald-600 border-emerald-200 dark:border-emerald-800">
<Archive className="h-3 w-3 mr-1" /> {t('notif_center.archive')}
</Button>
<Button variant="outline" size="sm" onClick={() => handleBulkAction('delete')} className="text-rose-600 border-rose-200 dark:border-rose-800">
<Trash2 className="h-3 w-3 mr-1" /> {t('action.delete')}
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelectedIds(new Set())}>
<X className="h-3 w-3" />
</Button>
</motion.div>
)}
{/* Notification List */}
<TabsContent value={activeTab} className="mt-4">
{filteredNotifications.length === 0 ? (
<Card className="border-emerald-200/50 dark:border-emerald-900/30">
<CardContent className="p-12 text-center">
<Bell className="h-12 w-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
<p className="text-gray-500 dark:text-gray-400">{t('notif_center.no_notifications')}</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
<AnimatePresence>
{filteredNotifications.map((notification, index) => {
const catConfig = getCategoryConfig(notification.category);
const priConfig = getPriorityConfig(notification.priority);
const CatIcon = catConfig.icon;
const isExpanded = expandedId === notification.id;
const isSelected = selectedIds.has(notification.id);
return (
<motion.div
key={notification.id}
initial={{ opacity: 0, x: 30 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -30 }}
transition={{ delay: index * 0.02, type: 'spring', stiffness: 200, damping: 20 }}
layout
>
<Card
className={`overflow-hidden transition-all hover:shadow-lg cursor-pointer ${
!notification.isRead
? 'border-emerald-300 dark:border-emerald-700 bg-emerald-50/30 dark:bg-emerald-950/20'
: 'border-gray-200 dark:border-gray-800'
} ${isSelected ? 'ring-2 ring-emerald-400' : ''}`}
>
{/* Gradient left border based on priority */}
<div className="flex">
<div className={`w-1.5 shrink-0 bg-gradient-to-b ${priConfig.color}`} />
<div className="flex-1">
<CardContent className="p-4">
<div className="flex items-start gap-3">
{/* Select checkbox */}
<button
onClick={(e) => { e.stopPropagation(); handleToggleSelect(notification.id); }}
className={`mt-0.5 w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors ${
isSelected
? 'bg-emerald-600 border-emerald-600 text-white'
: 'border-gray-300 dark:border-gray-600 hover:border-emerald-400'
}`}
>
{isSelected && <Check className="h-3 w-3" />}
</button>
{/* Category icon */}
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${catConfig.lightColor}`}>
<CatIcon className={`h-4 w-4 ${catConfig.textColor}`} />
</div>
{/* Content */}
<div className="flex-1 min-w-0" onClick={() => setExpandedId(isExpanded ? null : notification.id)}>
<div className="flex items-center gap-2 flex-wrap">
<p className={`text-sm font-medium ${!notification.isRead ? 'text-gray-900 dark:text-gray-100' : 'text-gray-600 dark:text-gray-400'}`}>
{notification.title}
</p>
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${priConfig.badgeColor}`}>
{t(priConfig.labelKey)}
</Badge>
{!notification.isRead && (
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{notification.message}
</p>
<div className="flex items-center gap-2 mt-1">
<Clock className="h-3 w-3 text-gray-400" />
<span className="text-[11px] text-gray-400">{formatRelativeTime(notification.createdAt)}</span>
<Badge variant="outline" className="text-[10px] px-1 py-0 bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400">
{t(catConfig.labelKey)}
</Badge>
</div>
{/* Expanded detail */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-800">
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{notification.message}
</p>
{notification.actionUrl && (
<Button
variant="outline"
size="sm"
onClick={(e) => { e.stopPropagation(); handleNavigate(notification.actionUrl); }}
className="mt-2 text-emerald-600 border-emerald-200 dark:border-emerald-800"
>
<ChevronRight className="h-3 w-3 mr-1" />
{t('notif_center.go_to')}
</Button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Action buttons */}
<div className="flex flex-col items-end gap-1 shrink-0">
{!notification.isRead && (
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleMarkRead(notification.id); }}
className="h-7 w-7 p-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
title={t('notif_center.mark_read')}
>
<Check className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleArchive(notification.id); }}
className="h-7 w-7 p-0 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
title={t('notif_center.archive')}
>
<Archive className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleDelete(notification.id); }}
className="h-7 w-7 p-0 text-rose-400 hover:bg-rose-100 dark:hover:bg-rose-900/20"
title={t('action.delete')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardContent>
</div>
</div>
</Card>
</motion.div>
);
})}
</AnimatePresence>
</div>
)}
</TabsContent>
</Tabs>
{/* Statistics Section */}
{chartData.length > 0 && !isStudent && !isParent && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<Card className="border-emerald-200/50 dark:border-emerald-900/30">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold text-gray-700 dark:text-gray-300 flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-emerald-600" />
{t('notif_center.by_category')}
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={chartData}>
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card className="border-emerald-200/50 dark:border-emerald-900/30">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold text-gray-700 dark:text-gray-300 flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-emerald-600" />
{t('notif_center.distribution')}
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
dataKey="value"
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
>
{chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
)}
</div>
);
}
@@ -0,0 +1,939 @@
'use client';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Megaphone,
Plus,
Pencil,
Trash2,
Pin,
Eye,
Clock,
Calendar,
AlertTriangle,
GraduationCap,
BookOpen,
PartyPopper,
ClipboardCheck,
Hourglass,
Info,
Users,
Check,
X,
Filter,
ChevronDown,
RefreshCw,
Star,
UserCheck,
} 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 { Textarea } from '@/components/ui/textarea';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
/* ── Types ─────────────────────────────────────────────────────── */
interface AnnouncementAuthor {
id: string;
firstName: string;
lastName: string;
}
interface AnnouncementRead {
id: string;
userId: string;
readAt: string;
user: { id: string; firstName: string; lastName: string };
}
interface AnnouncementItem {
id: string;
schoolId: string;
authorId: string;
title: string;
content: string;
priority: string;
announcementType: string;
targetAudience: string;
classGroupId: string | null;
isPinned: boolean;
expiresAt: string | null;
isDemo: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
author: AnnouncementAuthor;
classGroup: { id: string; name: string } | null;
isReadByCurrentUser?: boolean;
totalReads?: number;
reads?: AnnouncementRead[];
}
interface ClassGroup {
id: string;
name: string;
}
/* ── Config ────────────────────────────────────────────────────── */
const ANNOUNCEMENT_TYPES = [
{ key: 'general', labelKey: 'announcements.type_general', icon: Megaphone, gradient: 'from-emerald-500 to-teal-500', bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-600 dark:text-emerald-400' },
{ key: 'urgent', labelKey: 'announcements.type_urgent', icon: AlertTriangle, gradient: 'from-rose-500 to-red-600', bg: 'bg-rose-100 dark:bg-rose-900/30', text: 'text-rose-600 dark:text-rose-400' },
{ key: 'event', labelKey: 'announcements.type_event', icon: PartyPopper, gradient: 'from-violet-500 to-purple-600', bg: 'bg-violet-100 dark:bg-violet-900/30', text: 'text-violet-600 dark:text-violet-400' },
{ key: 'holiday', labelKey: 'announcements.type_holiday', icon: Star, gradient: 'from-amber-500 to-orange-500', bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-600 dark:text-amber-400' },
{ key: 'exam', labelKey: 'announcements.type_exam', icon: ClipboardCheck, gradient: 'from-teal-500 to-cyan-500', bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-600 dark:text-teal-400' },
{ key: 'deadline', labelKey: 'announcements.type_deadline', icon: Hourglass, gradient: 'from-rose-500 to-amber-500', bg: 'bg-rose-100 dark:bg-amber-900/30', text: 'text-amber-600 dark:text-amber-400' },
] as const;
const PRIORITIES = [
{ key: 'low', labelKey: 'announcements.priority_low', badgeColor: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-200 dark:border-gray-700' },
{ key: 'normal', labelKey: 'announcements.priority_normal', badgeColor: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800' },
{ key: 'high', labelKey: 'announcements.priority_high', badgeColor: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 border-amber-200 dark:border-amber-800' },
{ key: 'urgent', labelKey: 'announcements.priority_urgent', badgeColor: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 border-rose-200 dark:border-rose-800' },
] as const;
const TARGET_AUDIENCES = [
{ key: 'all', labelKey: 'announcements.target_all' },
{ key: 'teachers', labelKey: 'announcements.target_teachers' },
{ key: 'students', labelKey: 'announcements.target_students' },
{ key: 'parents', labelKey: 'announcements.target_parents' },
{ key: 'class', labelKey: 'announcements.target_class' },
] as const;
/* ── Helpers ───────────────────────────────────────────────────── */
function getTypeConfig(type: string) {
return ANNOUNCEMENT_TYPES.find((t) => t.key === type) || ANNOUNCEMENT_TYPES[0];
}
function getPriorityConfig(priority: string) {
return PRIORITIES.find((p) => p.key === priority) || PRIORITIES[1];
}
function formatRelativeTime(dateStr: string): string {
const now = new Date();
const date = new Date(dateStr);
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return t('announcements.just_now');
if (diffMin < 60) return t('announcements.minutes_ago').replace('{n}', String(diffMin));
if (diffHour < 24) return t('announcements.hours_ago').replace('{n}', String(diffHour));
if (diffDay < 7) return t('announcements.days_ago').replace('{n}', String(diffDay));
return date.toLocaleDateString();
}
function getInitials(firstName: string, lastName: string): string {
return `${firstName[0]}${lastName[0]}`.toUpperCase();
}
/* ── Component ─────────────────────────────────────────────────── */
export default function SchoolAnnouncementsView() {
const currentUser = useAppStore((s) => s.currentUser);
const [announcements, setAnnouncements] = useState<AnnouncementItem[]>([]);
const [loading, setLoading] = useState(true);
const [classes, setClasses] = useState<ClassGroup[]>([]);
const [createOpen, setCreateOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [filterType, setFilterType] = useState('all');
const [filterPriority, setFilterPriority] = useState('all');
const [showFilters, setShowFilters] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
// Form state
const [formTitle, setFormTitle] = useState('');
const [formContent, setFormContent] = useState('');
const [formPriority, setFormPriority] = useState('normal');
const [formType, setFormType] = useState('general');
const [formTarget, setFormTarget] = useState('all');
const [formClassId, setFormClassId] = useState('');
const [formIsPinned, setFormIsPinned] = useState(false);
const [formExpiresAt, setFormExpiresAt] = useState('');
const [formSaving, setFormSaving] = useState(false);
const role = currentUser?.role || 'TEACHER';
const schoolId = currentUser?.schoolId;
const isAdmin = role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL';
const isTeacher = role === 'TEACHER';
const canCreate = isAdmin || isTeacher;
const canEdit = isAdmin || isTeacher;
/* ── Load Data ──────────────────────────────────────────────── */
const loadData = useCallback(async () => {
if (!schoolId) return;
try {
const params = new URLSearchParams({
schoolId,
includeReads: 'true',
limit: '100',
});
if (filterType !== 'all') params.set('announcementType', filterType);
if (filterPriority !== 'all') params.set('priority', filterPriority);
const data = await apiGet<AnnouncementItem[]>(`/api/announcements?${params.toString()}`);
setAnnouncements(data);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [schoolId, filterType, filterPriority]);
const loadClasses = useCallback(async () => {
if (!schoolId) return;
try {
const data = await apiGet<ClassGroup[]>(`/api/classes?schoolId=${schoolId}`);
setClasses(data);
} catch {
// ignore
}
}, [schoolId]);
useEffect(() => {
loadData();
loadClasses();
}, [loadData, loadClasses]);
/* ── Actions ────────────────────────────────────────────────── */
const handleMarkRead = useCallback(async (id: string) => {
try {
await apiPost(`/api/announcements/${id}/read`, {});
setAnnouncements((prev) =>
prev.map((a) => a.id === id ? { ...a, isReadByCurrentUser: true } : a)
);
} catch {
// ignore
}
}, []);
const handleCreate = useCallback(async () => {
if (!schoolId || !formTitle.trim() || !formContent.trim()) return;
setFormSaving(true);
try {
await apiPost('/api/announcements', {
schoolId,
title: formTitle,
content: formContent,
priority: formPriority,
announcementType: formType,
targetAudience: formTarget,
classGroupId: formTarget === 'class' ? formClassId : null,
isPinned: formIsPinned,
expiresAt: formExpiresAt || null,
});
setCreateOpen(false);
resetForm();
loadData();
} catch {
// ignore
} finally {
setFormSaving(false);
}
}, [schoolId, formTitle, formContent, formPriority, formType, formTarget, formClassId, formIsPinned, formExpiresAt, loadData]);
const handleEdit = useCallback(async () => {
if (!editId || !formTitle.trim() || !formContent.trim()) return;
setFormSaving(true);
try {
await apiPut(`/api/announcements/${editId}`, {
title: formTitle,
content: formContent,
priority: formPriority,
announcementType: formType,
targetAudience: formTarget,
classGroupId: formTarget === 'class' ? formClassId : null,
isPinned: formIsPinned,
expiresAt: formExpiresAt || null,
});
setEditId(null);
resetForm();
loadData();
} catch {
// ignore
} finally {
setFormSaving(false);
}
}, [editId, formTitle, formContent, formPriority, formType, formTarget, formClassId, formIsPinned, formExpiresAt, loadData]);
const handleDelete = useCallback(async (id: string) => {
try {
await apiDelete(`/api/announcements/${id}`);
setAnnouncements((prev) => prev.filter((a) => a.id !== id));
} catch {
// ignore
}
}, []);
const handleTogglePin = useCallback(async (id: string, isPinned: boolean) => {
try {
await apiPut(`/api/announcements/${id}`, { isPinned: !isPinned });
setAnnouncements((prev) =>
prev.map((a) => a.id === id ? { ...a, isPinned: !isPinned } : a)
);
} catch {
// ignore
}
}, []);
const openEdit = useCallback((announcement: AnnouncementItem) => {
setEditId(announcement.id);
setFormTitle(announcement.title);
setFormContent(announcement.content);
setFormPriority(announcement.priority);
setFormType(announcement.announcementType);
setFormTarget(announcement.targetAudience);
setFormClassId(announcement.classGroupId || '');
setFormIsPinned(announcement.isPinned);
setFormExpiresAt(announcement.expiresAt ? new Date(announcement.expiresAt).toISOString().split('T')[0] : '');
}, []);
const resetForm = useCallback(() => {
setFormTitle('');
setFormContent('');
setFormPriority('normal');
setFormType('general');
setFormTarget('all');
setFormClassId('');
setFormIsPinned(false);
setFormExpiresAt('');
}, []);
/* ── Sorted Announcements ───────────────────────────────────── */
const sortedAnnouncements = useMemo(() => {
return [...announcements].sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
}, [announcements]);
/* ── Render ─────────────────────────────────────────────────── */
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="flex items-center gap-3">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
<span className="text-gray-500">{t('announcements.loading')}</span>
</div>
</div>
);
}
return (
<div className="space-y-6 p-4 md:p-6">
{/* Header with gradient banner */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-emerald-600 via-teal-500 to-emerald-500 p-6 shadow-lg"
>
{/* Decorative background shapes */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-4 -right-4 w-20 h-20 rounded-full bg-white/10" />
<div className="absolute bottom-0 left-1/3 w-16 h-16 rounded-full bg-white/5" />
</div>
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Megaphone className="h-6 w-6" />
{t('announcements.title')}
</h1>
<p className="text-sm text-emerald-100 mt-1">
{t('announcements.subtitle')}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={loadData}
className="bg-white/10 hover:bg-white/20 text-white border-white/30"
>
<RefreshCw className="h-4 w-4 mr-1" />
{t('action.refresh')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowFilters(!showFilters)}
className="bg-white/10 hover:bg-white/20 text-white border-white/30"
>
<Filter className="h-4 w-4 mr-1" />
{t('action.filter')}
{showFilters ? <ChevronDown className="h-3 w-3 ml-1" /> : <ChevronDown className="h-3 w-3 ml-1" />}
</Button>
{canCreate && (
<Dialog open={createOpen} onOpenChange={(open) => { setCreateOpen(open); if (!open) resetForm(); }}>
<DialogTrigger asChild>
<Button className="bg-white/20 hover:bg-white/30 text-white backdrop-blur-sm border border-white/30 shadow-md">
<Plus className="h-4 w-4 mr-1" />
{t('announcements.create')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Megaphone className="h-5 w-5 text-emerald-600" />
{t('announcements.create_title')}
</DialogTitle>
</DialogHeader>
<AnnouncementForm
title={formTitle} setTitle={setFormTitle}
content={formContent} setContent={setFormContent}
priority={formPriority} setPriority={setFormPriority}
type={formType} setType={setFormType}
target={formTarget} setTarget={setFormTarget}
classId={formClassId} setClassId={setFormClassId}
isPinned={formIsPinned} setIsPinned={setFormIsPinned}
expiresAt={formExpiresAt} setExpiresAt={setFormExpiresAt}
classes={classes}
onSave={handleCreate}
saving={formSaving}
saveLabel={t('action.create')}
/>
</DialogContent>
</Dialog>
)}
</div>
</div>
</motion.div>
{/* Filter Panel */}
<AnimatePresence>
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<Card className="border-emerald-200/50 dark:border-emerald-900/30">
<CardContent className="p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label className="text-xs text-gray-500 mb-1">{t('announcements.filter_type')}</Label>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('announcements.all_types')}</SelectItem>
{ANNOUNCEMENT_TYPES.map((type) => (
<SelectItem key={type.key} value={type.key}>{t(type.labelKey)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-gray-500 mb-1">{t('announcements.filter_priority')}</Label>
<Select value={filterPriority} onValueChange={setFilterPriority}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('announcements.all_priorities')}</SelectItem>
{PRIORITIES.map((p) => (
<SelectItem key={p.key} value={p.key}>{t(p.labelKey)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
{/* Edit Dialog */}
<Dialog open={!!editId} onOpenChange={(open) => { if (!open) { setEditId(null); resetForm(); } }}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Pencil className="h-5 w-5 text-emerald-600" />
{t('announcements.edit_title')}
</DialogTitle>
</DialogHeader>
<AnnouncementForm
title={formTitle} setTitle={setFormTitle}
content={formContent} setContent={setFormContent}
priority={formPriority} setPriority={setFormPriority}
type={formType} setType={setFormType}
target={formTarget} setTarget={setFormTarget}
classId={formClassId} setClassId={setFormClassId}
isPinned={formIsPinned} setIsPinned={setFormIsPinned}
expiresAt={formExpiresAt} setExpiresAt={setFormExpiresAt}
classes={classes}
onSave={handleEdit}
saving={formSaving}
saveLabel={t('action.save')}
/>
</DialogContent>
</Dialog>
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0 }}>
<Card className="border-emerald-200/50 dark:border-emerald-900/30">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
<Megaphone className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{announcements.length}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('announcements.total')}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }}>
<Card className="border-rose-200/50 dark:border-rose-900/30">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-rose-100 dark:bg-rose-900/30 flex items-center justify-center">
<Pin className="h-5 w-5 text-rose-600 dark:text-rose-400" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{announcements.filter((a) => a.isPinned).length}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('announcements.pinned')}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
<Card className="border-amber-200/50 dark:border-amber-900/30">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{announcements.filter((a) => a.priority === 'urgent' || a.priority === 'high').length}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('announcements.urgent_high')}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }}>
<Card className="border-teal-200/50 dark:border-teal-900/30">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-teal-100 dark:bg-teal-900/30 flex items-center justify-center">
<Eye className="h-5 w-5 text-teal-600 dark:text-teal-400" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{announcements.filter((a) => !a.isReadByCurrentUser).length}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('announcements.unread')}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</div>
{/* Announcements List */}
{sortedAnnouncements.length === 0 ? (
<Card className="border-emerald-200/50 dark:border-emerald-900/30">
<CardContent className="p-12 text-center">
<Megaphone className="h-12 w-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
<p className="text-gray-500 dark:text-gray-400">{t('announcements.no_announcements')}</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
<AnimatePresence>
{sortedAnnouncements.map((announcement, index) => {
const typeConfig = getTypeConfig(announcement.announcementType);
const priConfig = getPriorityConfig(announcement.priority);
const TypeIcon = typeConfig.icon;
const isExpanded = expandedId === announcement.id;
return (
<motion.div
key={announcement.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ delay: index * 0.03 }}
layout
>
<Card className={`overflow-hidden transition-all hover:shadow-lg backdrop-blur-sm bg-card/80 ${
announcement.isPinned
? 'border-emerald-300 dark:border-emerald-700 ring-1 ring-emerald-200/50 dark:ring-emerald-800/50'
: !announcement.isReadByCurrentUser
? 'border-amber-200 dark:border-amber-800 bg-amber-50/20 dark:bg-amber-950/10'
: 'border-gray-200 dark:border-gray-800'
}`}>
{/* Image header area with gradient */}
<div className={`h-2 bg-gradient-to-r ${typeConfig.gradient}`} />
<CardContent className="p-4 sm:p-6">
<div className="flex items-start gap-4">
{/* Type icon */}
<div className={`w-12 h-12 rounded-xl flex items-center justify-center shrink-0 ${typeConfig.bg}`}>
<TypeIcon className={`h-6 w-6 ${typeConfig.text}`} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
{announcement.title}
</h3>
{announcement.isPinned && (
<motion.div
animate={{ rotate: [0, -10, 10, -10, 0] }}
transition={{ duration: 2, repeat: Infinity, repeatDelay: 3 }}
>
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800 text-[10px] shadow-sm">
<Pin className="h-3 w-3 mr-0.5" />
{t('announcements.pinned')}
</Badge>
</motion.div>
)}
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${priConfig.badgeColor}`}>
{t(priConfig.labelKey)}
</Badge>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400">
{t(typeConfig.labelKey)}
</Badge>
{!announcement.isReadByCurrentUser && (
<motion.span
className="w-2 h-2 rounded-full bg-emerald-500"
animate={{ opacity: [1, 0.3, 1], scale: [1, 1.2, 1] }}
transition={{ duration: 1.5, repeat: Infinity }}
/>
)}
</div>
{/* Author & time */}
<div className="flex items-center gap-2 mb-2">
<Avatar className="h-5 w-5">
<AvatarFallback className="text-[8px] bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300">
{getInitials(announcement.author.firstName, announcement.author.lastName)}
</AvatarFallback>
</Avatar>
<span className="text-xs text-gray-500 dark:text-gray-400">
{announcement.author.firstName} {announcement.author.lastName}
</span>
<span className="text-xs text-gray-400">·</span>
<Clock className="h-3 w-3 text-gray-400" />
<span className="text-xs text-gray-400">{formatRelativeTime(announcement.createdAt)}</span>
{announcement.targetAudience !== 'all' && (
<>
<span className="text-xs text-gray-400">·</span>
<Users className="h-3 w-3 text-gray-400" />
<span className="text-xs text-gray-400">
{t(`announcements.target_${announcement.targetAudience}`)}
</span>
</>
)}
{announcement.classGroup && (
<span className="text-xs text-gray-400">
({announcement.classGroup.name})
</span>
)}
{announcement.expiresAt && (
<>
<span className="text-xs text-gray-400">·</span>
<Calendar className="h-3 w-3 text-gray-400" />
<motion.span
className="text-xs text-gray-400 flex items-center gap-1"
animate={new Date(announcement.expiresAt) < new Date(Date.now() + 7 * 86400000) ? { opacity: [1, 0.5, 1] } : {}}
transition={{ duration: 2, repeat: Infinity }}
>
{t('announcements.expires')} {new Date(announcement.expiresAt).toLocaleDateString()}
</motion.span>
</>
)}
</div>
{/* Content (truncated unless expanded) */}
<p className={`text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap ${
!isExpanded ? 'line-clamp-3' : ''
}`}>
{announcement.content}
</p>
{announcement.content.length > 200 && (
<Button
variant="ghost"
size="sm"
onClick={() => setExpandedId(isExpanded ? null : announcement.id)}
className="mt-1 text-emerald-600 dark:text-emerald-400 h-7 text-xs"
>
{isExpanded ? t('announcements.show_less') : t('announcements.show_more')}
</Button>
)}
{/* Read receipts */}
{announcement.totalReads !== undefined && announcement.totalReads > 0 && (
<div className="flex items-center gap-1 mt-2">
<UserCheck className="h-3 w-3 text-emerald-500" />
<span className="text-[11px] text-emerald-600 dark:text-emerald-400">
{announcement.totalReads} {t('announcements.have_read')}
</span>
{announcement.reads && announcement.reads.length > 0 && (
<div className="flex -space-x-1 ml-1">
{announcement.reads.slice(0, 5).map((r) => (
<Avatar key={r.id} className="h-4 w-4 border border-white dark:border-gray-800">
<AvatarFallback className="text-[6px] bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300">
{getInitials(r.user.firstName, r.user.lastName)}
</AvatarFallback>
</Avatar>
))}
{announcement.reads.length > 5 && (
<span className="text-[10px] text-gray-400 ml-1">+{announcement.reads.length - 5}</span>
)}
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 mt-3 flex-wrap">
{!announcement.isReadByCurrentUser && (
<Button
variant="outline"
size="sm"
onClick={() => handleMarkRead(announcement.id)}
className="h-7 text-xs text-emerald-600 border-emerald-200 dark:border-emerald-800"
>
<Check className="h-3 w-3 mr-1" />
{t('announcements.mark_read')}
</Button>
)}
{canEdit && (isAdmin || announcement.authorId === currentUser?.id) && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => handleTogglePin(announcement.id, announcement.isPinned)}
className="h-7 text-xs text-emerald-600 dark:text-emerald-400"
>
<Pin className="h-3 w-3 mr-1" />
{announcement.isPinned ? t('announcements.unpin') : t('announcements.pin')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openEdit(announcement)}
className="h-7 text-xs text-gray-600 dark:text-gray-400"
>
<Pencil className="h-3 w-3 mr-1" />
{t('action.edit')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(announcement.id)}
className="h-7 text-xs text-rose-600 dark:text-rose-400"
>
<Trash2 className="h-3 w-3 mr-1" />
{t('action.delete')}
</Button>
</>
)}
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</AnimatePresence>
</div>
)}
</div>
);
}
/* ── Announcement Form Sub-component ───────────────────────────── */
function AnnouncementForm({
title, setTitle,
content, setContent,
priority, setPriority,
type, setType,
target, setTarget,
classId, setClassId,
isPinned, setIsPinned,
expiresAt, setExpiresAt,
classes,
onSave,
saving,
saveLabel,
}: {
title: string;
setTitle: (v: string) => void;
content: string;
setContent: (v: string) => void;
priority: string;
setPriority: (v: string) => void;
type: string;
setType: (v: string) => void;
target: string;
setTarget: (v: string) => void;
classId: string;
setClassId: (v: string) => void;
isPinned: boolean;
setIsPinned: (v: boolean) => void;
expiresAt: string;
setExpiresAt: (v: string) => void;
classes: ClassGroup[];
onSave: () => void;
saving: boolean;
saveLabel: string;
}) {
return (
<div className="space-y-4">
<div>
<Label className="text-sm font-medium">{t('announcements.field_title')}</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('announcements.field_title_placeholder')}
className="mt-1"
/>
</div>
<div>
<Label className="text-sm font-medium">{t('announcements.field_content')}</Label>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t('announcements.field_content_placeholder')}
className="mt-1 min-h-[120px]"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium">{t('announcements.field_type')}</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ANNOUNCEMENT_TYPES.map((at) => (
<SelectItem key={at.key} value={at.key}>{t(at.labelKey)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm font-medium">{t('announcements.field_priority')}</Label>
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIORITIES.map((p) => (
<SelectItem key={p.key} value={p.key}>{t(p.labelKey)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium">{t('announcements.field_target')}</Label>
<Select value={target} onValueChange={setTarget}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TARGET_AUDIENCES.map((a) => (
<SelectItem key={a.key} value={a.key}>{t(a.labelKey)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{target === 'class' && (
<div>
<Label className="text-sm font-medium">{t('announcements.field_class')}</Label>
<Select value={classId} onValueChange={setClassId}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch checked={isPinned} onCheckedChange={setIsPinned} />
<Label className="text-sm">{t('announcements.pin_to_top')}</Label>
</div>
</div>
<div>
<Label className="text-sm font-medium">{t('announcements.field_expiry')}</Label>
<Input
type="date"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
className="mt-1"
/>
</div>
<Button
onClick={onSave}
disabled={saving || !title.trim() || !content.trim()}
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white"
>
{saving ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : (
saveLabel
)}
</Button>
</div>
);
}
File diff suppressed because it is too large. Load diff
+236 -1
View File
@@ -1,6 +1,6 @@
'use client';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Target,
@@ -51,6 +51,7 @@ import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
import { toast } from 'sonner';
import { Radar, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip } from 'recharts';
// ── Types ──────────────────────────────────────────────────────────────
@@ -737,6 +738,240 @@ export default function StudentPortalView() {
</Card>
</motion.div>
{/* ── Quick Action Cards ──────────────────────────────────────────── */}
<motion.div variants={itemVariants}>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: t('student_portal.quick_practice'), icon: BookOpen, color: 'from-emerald-400 to-teal-500', view: 'subjects' as const },
{ label: t('student_portal.quick_homework'), icon: ClipboardCheck, color: 'from-amber-400 to-orange-500', view: 'homework' as const },
{ label: t('student_portal.quick_achievements'), icon: Trophy, color: 'from-violet-400 to-purple-500', view: 'student-achievements' as const },
{ label: t('student_portal.quick_study'), icon: Clock, color: 'from-sky-400 to-cyan-500', view: 'student-study-planner' as const },
].map((action, idx) => {
const Icon = action.icon;
return (
<motion.div
key={idx}
whileHover={{ scale: 1.03, y: -3 }}
whileTap={{ scale: 0.97 }}
>
<Card
className="border-0 shadow-sm rounded-xl overflow-hidden cursor-pointer hover:shadow-md transition-all duration-300"
onClick={() => {
const store = useAppStore.getState();
store.setCurrentView(action.view);
}}
>
<CardContent className="p-4 flex flex-col items-center text-center gap-2">
<div className={`flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br ${action.color} text-white shadow-md`}>
<Icon className="h-5 w-5" />
</div>
<p className="text-xs font-semibold text-gray-900 dark:text-gray-100">{action.label}</p>
</CardContent>
</Card>
</motion.div>
);
})}
</div>
</motion.div>
{/* ── Charts Section: Radar + Grade Trend ─────────────────────────── */}
<motion.div variants={itemVariants}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Competency Radar Chart */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
<Target className="h-3.5 w-3.5" />
</div>
{t('student_portal.competency_radar')}
</CardTitle>
</CardHeader>
<CardContent className="p-2">
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={competencies.map(c => ({
subject: c.subject.length > 8 ? c.subject.substring(0, 8) + '…' : c.subject,
progress: c.progress,
level: c.level * 25,
}))}>
<PolarGrid stroke="currentColor" className="text-gray-200 dark:text-gray-700" />
<PolarAngleAxis dataKey="subject" className="text-[10px] fill-gray-500 dark:fill-gray-400" />
<PolarRadiusAxis angle={30} domain={[0, 100]} className="text-[8px]" />
<Radar name={t('student_portal.progress_bar')} dataKey="progress" stroke="#10b981" fill="#10b981" fillOpacity={0.2} strokeWidth={2} />
<Radar name={t('student_portal.mastery_level')} dataKey="level" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.1} strokeWidth={2} />
<RechartsTooltip />
</RadarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Grade Trend Chart */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-violet-400 to-purple-500 text-white shadow-sm">
<TrendingUp className="h-3.5 w-3.5" />
</div>
{t('student_portal.grade_trend')}
</CardTitle>
</CardHeader>
<CardContent className="p-2">
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={[
{ month: 'Sep', math: 65, german: 72, english: 58 },
{ month: 'Oct', math: 70, german: 75, english: 62 },
{ month: 'Nov', math: 68, german: 78, english: 65 },
{ month: 'Dec', math: 75, german: 80, english: 70 },
{ month: 'Jan', math: 78, german: 82, english: 73 },
{ month: 'Feb', math: 82, german: 85, english: 76 },
]}>
<CartesianGrid strokeDasharray="3 3" className="text-gray-200 dark:text-gray-700" />
<XAxis dataKey="month" className="text-[10px] fill-gray-500 dark:fill-gray-400" />
<YAxis domain={[0, 100]} className="text-[10px] fill-gray-500 dark:fill-gray-400" />
<RechartsTooltip />
<Line type="monotone" dataKey="math" stroke="#10b981" strokeWidth={2} dot={{ r: 3 }} name={t('student.notebook_math')} />
<Line type="monotone" dataKey="german" stroke="#f59e0b" strokeWidth={2} dot={{ r: 3 }} name={t('student.notebook_german')} />
<Line type="monotone" dataKey="english" stroke="#8b5cf6" strokeWidth={2} dot={{ r: 3 }} name={t('student.notebook_english')} />
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
</motion.div>
{/* ── Weekly Streak Heatmap ───────────────────────────────────────── */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-orange-400 to-red-500 text-white shadow-sm">
<Flame className="h-3.5 w-3.5" />
</div>
{t('student_portal.weekly_heatmap')}
</CardTitle>
</CardHeader>
<CardContent className="p-4">
<div className="flex items-center gap-2 overflow-x-auto pb-2">
{Array.from({ length: 28 }).map((_, i) => {
const daysAgo = 27 - i;
const date = new Date();
date.setDate(date.getDate() - daysAgo);
const dayName = date.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'narrow' });
const intensity = Math.random();
const isToday = daysAgo === 0;
const bgColor = intensity > 0.7
? 'bg-emerald-500 dark:bg-emerald-600'
: intensity > 0.4
? 'bg-emerald-300 dark:bg-emerald-700'
: intensity > 0.15
? 'bg-emerald-100 dark:bg-emerald-900'
: 'bg-gray-100 dark:bg-gray-800';
return (
<div key={i} className="flex flex-col items-center gap-1 shrink-0">
<span className="text-[8px] text-gray-400 dark:text-gray-500">{i % 7 === 0 ? dayName : ''}</span>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: i * 0.02, type: 'spring', stiffness: 200 }}
className={`w-6 h-6 rounded-md ${bgColor} ${isToday ? 'ring-2 ring-emerald-400 dark:ring-emerald-500' : ''} transition-colors`}
title={date.toLocaleDateString()}
/>
</div>
);
})}
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-gray-400 dark:text-gray-500">{t('student_portal.less')}</span>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded-sm bg-gray-100 dark:bg-gray-800" />
<div className="w-3 h-3 rounded-sm bg-emerald-100 dark:bg-emerald-900" />
<div className="w-3 h-3 rounded-sm bg-emerald-300 dark:bg-emerald-700" />
<div className="w-3 h-3 rounded-sm bg-emerald-500 dark:bg-emerald-600" />
</div>
<span className="text-[10px] text-gray-400 dark:text-gray-500">{t('student_portal.more')}</span>
</div>
</CardContent>
</Card>
</motion.div>
{/* ── Daily Challenge Widget ──────────────────────────────────────── */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden border-l-3 border-l-amber-500">
<CardHeader className="pb-2 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<CardTitle className="text-sm font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm">
<Sparkles className="h-3.5 w-3.5" />
</div>
{t('student_portal.daily_challenge')}
<Badge className="text-[10px] bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
+50 XP
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<CircularProgress value={60} size={56} strokeWidth={4} colorClass="stroke-amber-400">
<span className="text-xs font-bold text-gray-900 dark:text-gray-100">60%</span>
</CircularProgress>
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{t('student_portal.challenge_title')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('student_portal.challenge_description')}</p>
<div className="flex items-center gap-2 mt-2">
<Button size="sm" className="h-7 text-xs bg-gradient-to-r from-amber-500 to-orange-500 text-white" onClick={() => {
const store = useAppStore.getState();
store.setCurrentView('subjects');
}}>
{t('student_portal.start_challenge')}
<ChevronRight className="h-3 w-3 ml-1" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* ── Learning Time Tracker ───────────────────────────────────────── */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-sky-400 to-cyan-500 text-white shadow-sm">
<Clock className="h-3.5 w-3.5" />
</div>
{t('student_portal.learning_time')}
</CardTitle>
</CardHeader>
<CardContent className="p-4">
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="text-center p-2 rounded-xl bg-gray-50 dark:bg-gray-800/50">
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">3.5h</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('student_portal.today')}</p>
</div>
<div className="text-center p-2 rounded-xl bg-gray-50 dark:bg-gray-800/50">
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">12.5h</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('student_portal.this_week')}</p>
</div>
<div className="text-center p-2 rounded-xl bg-gray-50 dark:bg-gray-800/50">
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">45h</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('student_portal.this_month')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">{t('student_portal.weekly_goal')}</span>
<div className="flex-1">
<Progress value={62} className="h-2" />
</div>
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300">12.5/20h</span>
</div>
</CardContent>
</Card>
</motion.div>
{/* ── Main Tabs ──────────────────────────────────────────────────── */}
<motion.div variants={itemVariants}>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff