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:
1 parent
9e22214470
commit
490bd0650f
30 files changed
+10844
-31
No files matched your search
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user