feat: Round 21 - Major feature additions

- Schlaukopf.de clone: Subject Content Management System (7 categories, 416 topics)
- AI Integration: Pollination AI, OpenAI, Anthropic (chat, image/video generation)
- Virtual Character System: 5 animated characters (Owl, Dragon, Robot, Cat, Wizard)
- Illness Reporting with Parent Approval: privacy-first, student reports need parent approval
- Student-Teacher Communication Rooms: real-time chat, voice, file sharing
- Counseling/Guidance System: 4 types, calendar integration
- Disciplinary Committee System: case management, severity levels
- AI Test Generation: auto-generate practice tests before exams
- Teacher Grading Review: AI-powered grading audit
- VICE_PRINCIPAL role with same privileges as SCHOOL_ADMIN
- Class responsible teacher (Klassenlehrer) field
- 15 new Prisma models (80+ total)
- 30+ new API route files
- 8 new frontend components
- 400+ i18n keys
- Professional styling with gradient headers, animated counters, skeleton loading
This commit is contained in:
Mustafa Cetin committed 2026-07-29 20:09:03 +00:00
1 parent 04ae7aa80c
commit f1776932d7
48 files changed
+13559 -4

No files matched your search

+435
View File
@@ -0,0 +1,435 @@
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
MessageCircle,
X,
Send,
Sparkles,
HelpCircle,
Lightbulb,
Brain,
Loader2,
Trash2,
Plus,
AlertTriangle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { t } from '@/lib/i18n';
import { apiPost, apiGet } from '@/lib/api';
interface ChatMessage {
id: string;
content: string;
senderType: 'user' | 'ai' | 'system';
createdAt: string;
}
interface ChatHistoryMessage {
role: 'user' | 'assistant';
content: string;
}
export default function AIChatWidget() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [requestsToday, setRequestsToday] = useState(0);
const [maxRequests, setMaxRequests] = useState(50);
const [chatEnabled, setChatEnabled] = useState(true);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Load chat history on mount
useEffect(() => {
if (isOpen) {
loadChatHistory();
}
}, [isOpen]);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, isLoading]);
// Focus input when chat opens
useEffect(() => {
if (isOpen && inputRef.current) {
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [isOpen]);
const loadChatHistory = useCallback(async () => {
try {
const data = await apiGet<{
messages: ChatMessage[];
requestsToday: number;
maxRequests: number;
chatEnabled: boolean;
}>('/api/ai/chat');
setMessages(data.messages || []);
setRequestsToday(data.requestsToday);
setMaxRequests(data.maxRequests);
setChatEnabled(data.chatEnabled);
} catch {
// Chat might not be available yet
setChatEnabled(false);
}
}, []);
const handleSendMessage = useCallback(
async (messageText?: string) => {
const text = (messageText || inputValue).trim();
if (!text || isLoading || !chatEnabled) return;
// Check rate limit
if (requestsToday >= maxRequests) {
setError(t('ai.error_rate_limit'));
return;
}
setInputValue('');
setError(null);
// Add user message to UI immediately
const userMessage: ChatMessage = {
id: `temp-${Date.now()}`,
content: text,
senderType: 'user',
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
setIsLoading(true);
try {
// Build conversation history for context
const history: ChatHistoryMessage[] = messages
.filter((m) => m.senderType === 'user' || m.senderType === 'ai')
.slice(-10)
.map((m) => ({
role: m.senderType === 'user' ? ('user' as const) : ('assistant' as const),
content: m.content,
}));
const data = await apiPost<{
response: string;
requestsToday: number;
maxRequests: number;
}>('/api/ai/chat', {
message: text,
conversationHistory: history,
});
// Add AI response
const aiMessage: ChatMessage = {
id: `ai-${Date.now()}`,
content: data.response,
senderType: 'ai',
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, aiMessage]);
setRequestsToday(data.requestsToday);
} catch (err: unknown) {
const error = err as { isRateLimit?: boolean; message?: string };
if (error?.isRateLimit) {
setError(t('ai.error_rate_limit'));
} else {
setError(t('ai.error_generic'));
}
} finally {
setIsLoading(false);
}
},
[inputValue, isLoading, chatEnabled, requestsToday, maxRequests, messages]
);
const handleQuickAction = useCallback(
(action: 'explain' | 'hint' | 'quiz') => {
const prefixes: Record<string, string> = {
explain: t('ai.topic_explain') + ': ',
hint: t('ai.topic_hint') + ': ',
quiz: t('ai.topic_quiz') + ': ',
};
setInputValue(prefixes[action]);
setTimeout(() => inputRef.current?.focus(), 50);
},
[]
);
const handleClearChat = useCallback(async () => {
setMessages([]);
setError(null);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
},
[handleSendMessage]
);
const remainingRequests = maxRequests - requestsToday;
return (
<TooltipProvider>
{/* Floating Chat Button */}
<AnimatePresence>
{!isOpen && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
className="fixed bottom-20 right-4 z-50 sm:bottom-6 sm:right-6"
>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => setIsOpen(true)}
className="h-14 w-14 rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 shadow-lg shadow-emerald-500/30 hover:shadow-emerald-500/50 transition-all duration-300 group"
size="icon"
>
<MessageCircle className="h-6 w-6 text-white group-hover:scale-110 transition-transform" />
{remainingRequests < 10 && remainingRequests > 0 && (
<Badge className="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center bg-amber-500 text-white text-[10px] border-0">
{remainingRequests}
</Badge>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('ai.chat_title')}</p>
</TooltipContent>
</Tooltip>
</motion.div>
)}
</AnimatePresence>
{/* Chat Dialog */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="fixed bottom-4 right-4 z-50 w-[360px] max-w-[calc(100vw-2rem)] sm:w-[400px] sm:bottom-6 sm:right-6"
>
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl shadow-emerald-500/10 border border-emerald-200/60 dark:border-emerald-800/40 overflow-hidden flex flex-col max-h-[70vh] sm:max-h-[80vh]">
{/* Header */}
<div className="bg-gradient-to-r from-emerald-500 to-teal-600 p-4 flex items-center justify-between shrink-0 relative overflow-hidden">
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMSIgY3k9IjEiIHI9IjEiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC4xKSIvPjwvc3ZnPg==')] opacity-50" />
<div className="relative z-10 flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center">
<Sparkles className="h-4 w-4 text-white" />
</div>
<div>
<h3 className="text-white font-semibold text-sm">
{t('ai.chat_title')}
</h3>
<p className="text-emerald-100 text-[10px]">
{t('ai.daily_limit', { used: String(requestsToday), max: String(maxRequests) })}
</p>
</div>
</div>
<div className="relative z-10 flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-white/80 hover:text-white hover:bg-white/10"
onClick={handleClearChat}
title={t('ai.clear_chat')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-white/80 hover:text-white hover:bg-white/10"
onClick={() => setIsOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Messages Area */}
<ScrollArea className="flex-1 p-4 min-h-[300px] max-h-[400px]">
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="h-16 w-16 rounded-full bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center mb-4">
<Sparkles className="h-8 w-8 text-emerald-500" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{t('ai.welcome_message')}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
{t('ai.no_homework')}
</p>
</div>
)}
{messages.map((msg, idx) => (
<motion.div
key={msg.id || idx}
initial={{ opacity: 0, y: 8, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.25, type: 'spring', stiffness: 400, damping: 30 }}
className={`flex mb-3 ${
msg.senderType === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed shadow-sm ${
msg.senderType === 'user'
? 'bg-gradient-to-br from-emerald-500 to-teal-600 text-white rounded-br-md'
: 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-bl-md border border-gray-100 dark:border-gray-700'
}`}
>
{msg.content}
</div>
</motion.div>
))}
{/* Typing Indicator */}
{isLoading && (
<motion.div
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
className="flex justify-start mb-3"
>
<div className="bg-white dark:bg-gray-800 rounded-2xl rounded-bl-md px-4 py-3 border border-gray-100 dark:border-gray-700 shadow-sm">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<motion.div className="h-2 w-2 rounded-full bg-emerald-500" animate={{ y: [0, -4, 0] }} transition={{ repeat: Infinity, duration: 0.6, delay: 0 }} />
<motion.div className="h-2 w-2 rounded-full bg-emerald-500" animate={{ y: [0, -4, 0] }} transition={{ repeat: Infinity, duration: 0.6, delay: 0.15 }} />
<motion.div className="h-2 w-2 rounded-full bg-emerald-500" animate={{ y: [0, -4, 0] }} transition={{ repeat: Infinity, duration: 0.6, delay: 0.3 }} />
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{t('ai.thinking')}
</span>
</div>
</div>
</motion.div>
)}
{/* Error Message */}
{error && (
<motion.div
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
className="flex justify-center mb-3"
>
<div className="bg-red-50 dark:bg-red-900/20 rounded-xl px-4 py-2.5 text-xs text-red-600 dark:text-red-400 flex items-center gap-2 border border-red-100 dark:border-red-800/30">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{error}
</div>
</motion.div>
)}
<div ref={scrollRef} />
</ScrollArea>
{/* Quick Actions */}
<div className="px-4 py-2 border-t border-emerald-100 dark:border-emerald-800/30 shrink-0">
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] text-gray-400 dark:text-gray-500 font-medium uppercase tracking-wider">
{t('ai.quick_actions')}
</span>
</div>
<div className="flex gap-1.5">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<Button
variant="outline"
size="sm"
className="h-7 text-[11px] border-emerald-200/50 dark:border-emerald-800/30 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 px-2"
onClick={() => handleQuickAction('explain')}
disabled={isLoading || !chatEnabled}
>
<HelpCircle className="h-3 w-3 mr-1" />
{t('ai.explain_topic')}
</Button>
</motion.div>
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<Button
variant="outline"
size="sm"
className="h-7 text-[11px] border-emerald-200/50 dark:border-emerald-800/30 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 px-2"
onClick={() => handleQuickAction('hint')}
disabled={isLoading || !chatEnabled}
>
<Lightbulb className="h-3 w-3 mr-1" />
{t('ai.give_hint')}
</Button>
</motion.div>
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<Button
variant="outline"
size="sm"
className="h-7 text-[11px] border-emerald-200/50 dark:border-emerald-800/30 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 px-2"
onClick={() => handleQuickAction('quiz')}
disabled={isLoading || !chatEnabled}
>
<Brain className="h-3 w-3 mr-1" />
{t('ai.quiz_me')}
</Button>
</motion.div>
</div>
</div>
{/* Input Area */}
<div className="p-3 border-t border-emerald-100 dark:border-emerald-800/30 shrink-0">
<div className="flex items-center gap-2">
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('ai.chat_placeholder')}
disabled={isLoading || !chatEnabled || requestsToday >= maxRequests}
className="flex-1 h-9 text-sm border-emerald-200/50 dark:border-emerald-800/30 focus:ring-emerald-500/30 bg-white dark:bg-gray-800"
/>
<Button
onClick={() => handleSendMessage()}
disabled={
!inputValue.trim() ||
isLoading ||
!chatEnabled ||
requestsToday >= maxRequests
}
className="h-9 w-9 bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white shrink-0"
size="icon"
>
<Send className="h-4 w-4" />
</Button>
</div>
{requestsToday >= maxRequests && (
<p className="text-[10px] text-amber-600 dark:text-amber-400 mt-1.5 text-center">
{t('ai.rate_limit')}
</p>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</TooltipProvider>
);
}
+736
View File
@@ -0,0 +1,736 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Brain,
FileQuestion,
Play,
CheckCircle2,
XCircle,
ArrowRight,
ArrowLeft,
BarChart3,
Clock,
Zap,
Sparkles,
Trophy,
Send,
Settings,
RefreshCw,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Progress } from '@/components/ui/progress';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut } from '@/lib/api';
import { toast } from 'sonner';
interface AITest {
id: string;
schoolId: string;
assessmentId: string | null;
studentId: string | null;
classGroupId: string | null;
subjectId: string | null;
testType: string;
difficulty: string;
questionCount: number;
questions: string | null;
aiProvider: string;
generatedAt: string | null;
isCompleted: boolean;
completedAt: string | null;
score: number | null;
createdAt: string;
student: { id: string; firstName: string; lastName: string } | null;
classGroup: { id: string; name: string } | null;
subject: { id: string; name: string } | null;
}
interface Question {
question: string;
options: string[];
correctAnswer: string;
explanation: string;
}
export default function AITestsView() {
const currentUser = useAppStore((s) => s.currentUser);
const [tests, setTests] = useState<AITest[]>([]);
const [loading, setLoading] = useState(true);
const [generateDialogOpen, setGenerateDialogOpen] = useState(false);
const [testTakingOpen, setTestTakingOpen] = useState(false);
const [selectedTest, setSelectedTest] = useState<AITest | null>(null);
const [currentQuestion, setCurrentQuestion] = useState(0);
const [answers, setAnswers] = useState<Record<number, string>>({});
const [showResults, setShowResults] = useState(false);
// Generate form
const [topic, setTopic] = useState('');
const [testType, setTestType] = useState('practice');
const [difficulty, setDifficulty] = useState('medium');
const [questionCount, setQuestionCount] = useState(10);
const schoolId = currentUser?.schoolId;
const role = currentUser?.role;
const isStudent = role === 'STUDENT';
const isTeacher = role === 'TEACHER';
const isAdmin = role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL';
const fetchTests = useCallback(async () => {
if (!schoolId) return;
setLoading(true);
try {
const data = await apiGet<AITest[]>(`/api/ai-tests?schoolId=${schoolId}`);
setTests(data);
} catch (err) {
console.error('Failed to fetch tests:', err);
} finally {
setLoading(false);
}
}, [schoolId]);
useEffect(() => {
fetchTests();
}, [fetchTests]);
const handleGenerateTest = async () => {
if (!topic) return;
try {
await apiPost('/api/ai-tests', {
schoolId,
topic,
testType,
difficulty,
questionCount,
});
toast.success(t('ai_tests.generate'));
setGenerateDialogOpen(false);
setTopic('');
fetchTests();
} catch (err) {
toast.error(String(err) || 'Failed to generate test');
}
};
const handleAutoGenerate = async () => {
try {
const result = await apiPost('/api/ai-tests/auto-generate', { schoolId });
toast.success(`${t('ai_tests.auto_generate')}: ${result.generated}`);
fetchTests();
} catch (err) {
toast.error(String(err) || 'Failed to auto-generate');
}
};
const startTest = (test: AITest) => {
setSelectedTest(test);
setCurrentQuestion(0);
setAnswers({});
setShowResults(false);
setTestTakingOpen(true);
};
const getQuestions = (test: AITest): Question[] => {
if (!test.questions) return [];
try {
return JSON.parse(test.questions);
} catch {
return [];
}
};
const handleSubmitTest = async () => {
if (!selectedTest) return;
const questions = getQuestions(selectedTest);
let correct = 0;
questions.forEach((q, i) => {
if (answers[i] === q.correctAnswer) correct++;
});
const score = questions.length > 0 ? (correct / questions.length) * 100 : 0;
try {
await apiPut(`/api/ai-tests/${selectedTest.id}`, {
isCompleted: true,
score,
});
setShowResults(true);
toast.success(t('ai_tests.submit_answers'));
fetchTests();
} catch (err) {
toast.error(String(err) || 'Failed to submit');
}
};
const getTestTypeColor = (type: string) => {
switch (type) {
case 'practice': return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400';
case 'mock_exam': return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400';
case 'review': return 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-400';
default: return 'bg-gray-100 text-gray-800';
}
};
const getDifficultyColor = (diff: string) => {
switch (diff) {
case 'easy': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
case 'medium': return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400';
case 'hard': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
default: return 'bg-gray-100 text-gray-800';
}
};
const pendingTests = tests.filter((t) => !t.isCompleted);
const completedTests = tests.filter((t) => t.isCompleted);
const avgScore = completedTests.length > 0 ? completedTests.reduce((a, t) => a + (t.score || 0), 0) / completedTests.length : 0;
// Questions for current test
const questions = selectedTest ? getQuestions(selectedTest) : [];
const currentQ = questions[currentQuestion];
return (
<div className="space-y-6 p-4 md:p-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center shadow-lg shadow-emerald-200 dark:shadow-emerald-900/30">
<Brain className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{t('ai_tests.title')}</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('ai_tests.practice')}</p>
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Button onClick={() => setGenerateDialogOpen(true)} className="bg-emerald-600 hover:bg-emerald-700 text-white">
<Sparkles className="h-4 w-4 mr-2" />
{t('ai_tests.generate')}
</Button>
{isAdmin && (
<Button variant="outline" onClick={handleAutoGenerate} className="border-emerald-300 text-emerald-700 dark:border-emerald-700 dark:text-emerald-400">
<RefreshCw className="h-4 w-4 mr-2" />
{t('ai_tests.auto_generate')}
</Button>
)}
<Button variant="outline" onClick={fetchTests}>
{t('action.refresh')}
</Button>
</div>
</motion.div>
{/* Stats */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid grid-cols-2 md:grid-cols-4 gap-4"
>
<Card className="border-l-4 border-l-emerald-500">
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{t('ai_tests.total_tests')}</p>
<p className="text-2xl font-bold">{tests.length}</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-amber-500">
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{t('ai_tests.pending')}</p>
<p className="text-2xl font-bold">{pendingTests.length}</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-teal-500">
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{t('ai_tests.completion_rate')}</p>
<p className="text-2xl font-bold">{tests.length > 0 ? Math.round((completedTests.length / tests.length) * 100) : 0}%</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500">
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{t('ai_tests.average_score')}</p>
<p className="text-2xl font-bold">{Math.round(avgScore)}%</p>
</CardContent>
</Card>
</motion.div>
{/* Tabs */}
<Tabs defaultValue="available" className="space-y-4">
<TabsList>
<TabsTrigger value="available">{t('ai_tests.take_test')}</TabsTrigger>
<TabsTrigger value="completed">{t('ai_tests.completed')}</TabsTrigger>
{isAdmin && <TabsTrigger value="statistics">{t('ai_tests.test_statistics')}</TabsTrigger>}
</TabsList>
{/* Available Tests Tab */}
<TabsContent value="available">
{loading ? (
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="p-4">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-2" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3" />
</CardContent>
</Card>
))}
</div>
) : pendingTests.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-12 text-center">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
>
<div className="h-20 w-20 rounded-full bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center mx-auto mb-4">
<FileQuestion className="h-10 w-10 text-emerald-300 dark:text-emerald-600" />
</div>
</motion.div>
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">{t('ai_tests.no_tests')}</p>
<p className="text-gray-400 text-sm mt-1">{t('ai_tests.generate')}</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
<AnimatePresence>
{pendingTests.map((test, idx) => (
<motion.div
key={test.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
>
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-start gap-3">
<div className={`h-10 w-10 rounded-lg flex items-center justify-center ${getTestTypeColor(test.testType)}`}>
<Brain className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<Badge className={getTestTypeColor(test.testType)} variant="secondary">
{t(`ai_tests.${test.testType}`)}
</Badge>
<Badge className={getDifficultyColor(test.difficulty)} variant="secondary">
{t(`ai_tests.${test.difficulty}`)}
</Badge>
<span className="text-sm text-gray-500">
{test.questionCount} {t('ai_tests.questions')}
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{test.subject?.name && `${t('ai_tests.subject')}: ${test.subject.name} | `}
{test.generatedAt && `${t('ai_tests.generated_on')}: ${new Date(test.generatedAt).toLocaleDateString()}`}
</p>
</div>
</div>
<Button
onClick={() => startTest(test)}
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
<Play className="h-4 w-4 mr-2" />
{t('ai_tests.take_test')}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</TabsContent>
{/* Completed Tests Tab */}
<TabsContent value="completed">
{completedTests.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-12 text-center">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
>
<div className="h-20 w-20 rounded-full bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center mx-auto mb-4">
<Trophy className="h-10 w-10 text-emerald-300 dark:text-emerald-600" />
</div>
</motion.div>
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">{t('ai_tests.no_tests')}</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{completedTests.map((test, idx) => (
<motion.div
key={test.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.05 }}
>
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-start gap-3">
<div className={`h-10 w-10 rounded-lg flex items-center justify-center ${test.score && test.score >= 70 ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400'}`}>
<Trophy className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<Badge className={getTestTypeColor(test.testType)} variant="secondary">
{t(`ai_tests.${test.testType}`)}
</Badge>
<span className="text-lg font-bold">{Math.round(test.score || 0)}%</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{test.completedAt && `${t('ai_tests.completed')}: ${new Date(test.completedAt).toLocaleDateString()}`}
</p>
</div>
</div>
<Button
variant="outline"
onClick={() => {
setSelectedTest(test);
setShowResults(true);
setTestTakingOpen(true);
}}
>
<BarChart3 className="h-4 w-4 mr-2" />
{t('ai_tests.results')}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
)}
</TabsContent>
{/* Statistics Tab */}
{isAdmin && (
<TabsContent value="statistics">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('ai_tests.test_statistics')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<p className="text-sm text-muted-foreground">{t('ai_tests.average_score')}</p>
<div className="flex items-center gap-2 mt-1">
<Progress value={avgScore} className="flex-1" />
<span className="text-sm font-medium">{Math.round(avgScore)}%</span>
</div>
</div>
<div>
<p className="text-sm text-muted-foreground">{t('ai_tests.completion_rate')}</p>
<div className="flex items-center gap-2 mt-1">
<Progress value={tests.length > 0 ? (completedTests.length / tests.length) * 100 : 0} className="flex-1" />
<span className="text-sm font-medium">{tests.length > 0 ? Math.round((completedTests.length / tests.length) * 100) : 0}%</span>
</div>
</div>
<div>
<p className="text-sm text-muted-foreground">{t('ai_tests.difficulty')}</p>
<div className="flex gap-2 mt-1">
{['easy', 'medium', 'hard'].map((d) => (
<Badge key={d} className={getDifficultyColor(d)} variant="secondary">
{t(`ai_tests.${d}`)}: {tests.filter((t) => t.difficulty === d).length}
</Badge>
))}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('ai_tests.auto_settings')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t('ai_tests.two_weeks_before')}</p>
<Button onClick={handleAutoGenerate} className="bg-emerald-600 hover:bg-emerald-700 text-white w-full">
<RefreshCw className="h-4 w-4 mr-2" />
{t('ai_tests.manual_trigger')}
</Button>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
)}
</Tabs>
{/* Generate Test Dialog */}
<Dialog open={generateDialogOpen} onOpenChange={setGenerateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('ai_tests.generate')}</DialogTitle>
<DialogDescription>{t('ai_tests.configure')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>{t('ai_tests.topic')}</Label>
<Input
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder={t('ai_tests.topic')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>{t('ai_tests.test_type')}</Label>
<Select value={testType} onValueChange={setTestType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="practice">{t('ai_tests.practice')}</SelectItem>
<SelectItem value="mock_exam">{t('ai_tests.mock_exam')}</SelectItem>
<SelectItem value="review">{t('ai_tests.review')}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>{t('ai_tests.difficulty')}</Label>
<Select value={difficulty} onValueChange={setDifficulty}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="easy">{t('ai_tests.easy')}</SelectItem>
<SelectItem value="medium">{t('ai_tests.medium')}</SelectItem>
<SelectItem value="hard">{t('ai_tests.hard')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>{t('ai_tests.question_count')}</Label>
<Select value={String(questionCount)} onValueChange={(v) => setQuestionCount(Number(v))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="15">15</SelectItem>
<SelectItem value="20">20</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setGenerateDialogOpen(false)}>
{t('action.cancel')}
</Button>
<Button onClick={handleGenerateTest} className="bg-emerald-600 hover:bg-emerald-700 text-white">
<Sparkles className="h-4 w-4 mr-2" />
{t('ai_tests.generate')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Test Taking Dialog */}
<Dialog open={testTakingOpen} onOpenChange={setTestTakingOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{showResults ? t('ai_tests.results') : t('ai_tests.take_test')}
</DialogTitle>
<DialogDescription>
{selectedTest && !showResults && t('ai_tests.question_of', { current: currentQuestion + 1, total: questions.length })}
</DialogDescription>
</DialogHeader>
{showResults ? (
<div className="space-y-4">
{selectedTest && (
<div className="text-center mb-4">
{/* Circular score visualization */}
<div className="relative w-28 h-28 mx-auto mb-3">
<svg className="w-full h-full -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="none" stroke="currentColor" className="text-gray-200 dark:text-gray-700" strokeWidth="6" />
<motion.circle
cx="50" cy="50" r="40" fill="none"
stroke={selectedTest.score && selectedTest.score >= 70 ? '#10b981' : selectedTest.score && selectedTest.score >= 40 ? '#f59e0b' : '#ef4444'}
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 40}`}
initial={{ strokeDashoffset: 2 * Math.PI * 40 }}
animate={{ strokeDashoffset: 2 * Math.PI * 40 * (1 - (selectedTest.score || 0) / 100) }}
transition={{ duration: 1.2, ease: 'easeOut' }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<motion.span
className={`text-2xl font-bold ${selectedTest.score && selectedTest.score >= 70 ? 'text-emerald-600' : 'text-amber-600'}`}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', delay: 0.3 }}
>
{Math.round(selectedTest.score || 0)}%
</motion.span>
</div>
</div>
{/* Celebration animation for high scores */}
{selectedTest.score && selectedTest.score >= 70 && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', delay: 0.5 }}
>
<Trophy className="h-8 w-8 text-amber-500 mx-auto mb-2" />
</motion.div>
)}
<p className="text-sm text-muted-foreground">{t('ai_tests.score')}</p>
</div>
)}
<ScrollArea className="max-h-96">
<div className="space-y-4">
{questions.map((q, i) => {
const isCorrect = answers[i] === q.correctAnswer;
return (
<Card key={i} className={`${isCorrect ? 'border-emerald-300 dark:border-emerald-700' : 'border-red-300 dark:border-red-700'}`}>
<CardContent className="p-4">
<div className="flex items-start gap-2">
{isCorrect ? (
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0 mt-0.5" />
) : (
<XCircle className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
)}
<div>
<p className="font-medium text-sm">{q.question}</p>
<p className="text-sm text-muted-foreground mt-1">
{t('ai_tests.your_answer')}: {answers[i] || '---'}
</p>
{!isCorrect && (
<p className="text-sm text-emerald-600 dark:text-emerald-400">
{t('ai_tests.correct_answer')}: {q.correctAnswer}
</p>
)}
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('ai_tests.explanation')}: {q.explanation}
</p>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</ScrollArea>
</div>
) : currentQ ? (
<div className="space-y-4">
{/* Quiz progress bar with animated fill */}
<div className="relative h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<motion.div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-emerald-500 to-teal-500 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${((currentQuestion + 1) / questions.length) * 100}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
</div>
{/* Question navigation dots */}
<div className="flex items-center gap-1 justify-center">
{questions.map((_, i) => (
<motion.div
key={i}
className={`h-2 rounded-full transition-all duration-300 ${
i === currentQuestion
? 'w-6 bg-emerald-500'
: answers[i]
? 'w-2 bg-emerald-300 dark:bg-emerald-700'
: 'w-2 bg-gray-300 dark:bg-gray-600'
}`}
layout
/>
))}
</div>
<div className="text-center">
<p className="text-lg font-semibold">{currentQ.question}</p>
</div>
<div className="space-y-2">
{currentQ.options.map((option, i) => (
<Button
key={i}
variant={answers[currentQuestion] === option ? 'default' : 'outline'}
className={`w-full text-left justify-start h-auto py-3 px-4 ${
answers[currentQuestion] === option
? 'bg-emerald-600 hover:bg-emerald-700 text-white'
: 'hover:bg-emerald-50 dark:hover:bg-emerald-950/30'
}`}
onClick={() => setAnswers({ ...answers, [currentQuestion]: option })}
>
<span className="mr-2 font-semibold">{String.fromCharCode(65 + i)}.</span>
{option}
</Button>
))}
</div>
<div className="flex justify-between">
<Button
variant="outline"
onClick={() => setCurrentQuestion(Math.max(0, currentQuestion - 1))}
disabled={currentQuestion === 0}
>
<ArrowLeft className="h-4 w-4 mr-2" />
{t('ai_tests.previous_question')}
</Button>
{currentQuestion < questions.length - 1 ? (
<Button
onClick={() => setCurrentQuestion(currentQuestion + 1)}
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
{t('ai_tests.next_question')}
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
) : (
<Button
onClick={handleSubmitTest}
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
<Send className="h-4 w-4 mr-2" />
{t('ai_tests.submit_answers')}
</Button>
)}
</div>
</div>
) : (
<div className="text-center py-8">
<p className="text-gray-500">{t('ai_tests.no_tests')}</p>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
+36 -1
View File
@@ -60,6 +60,8 @@ import {
Trophy,
FolderOpen,
Building2,
MessageSquare,
Brain,
} from 'lucide-react';
import {
Sidebar,
@@ -156,6 +158,14 @@ import TimetableView from './timetable-view';
import ResourceLibraryView from './resource-library-view';
import CompetitionsView from './competitions-view';
import DistrictManagementView from './district-management-view';
import IllnessReportingView from './illness-reporting-view';
import CommunicationView from './communication-view';
import SubjectsView from './subjects-view';
import CounselingView from './counseling-view';
import DisciplinaryView from './disciplinary-view';
import AITestsView from './ai-tests-view';
import AIChatWidget from './ai-chat-widget';
import VirtualCharacter from './virtual-character';
// Dynamic imports for heavy components with loading skeletons
const AnalyticsView = dynamic(() => import('./analytics-view'), {
@@ -214,6 +224,12 @@ const navSections: NavSection[] = [
{ key: 'timetable', icon: ClockIcon, labelKey: 'nav.timetable' },
{ key: 'resources', icon: LibraryIcon, labelKey: 'nav.resources' },
{ key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' },
{ key: 'subjects', icon: BookOpen, labelKey: 'nav.subjects' },
{ key: 'illness', icon: Heart, labelKey: 'nav.illness' },
{ key: 'communication', icon: MessageSquare, labelKey: 'nav.communication' },
{ key: 'counseling', icon: Heart, labelKey: 'counseling.title' },
{ key: 'disciplinary', icon: Shield, labelKey: 'disciplinary.title' },
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
],
},
{
@@ -243,6 +259,11 @@ const studentNavSections: NavSection[] = [
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
{ key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' },
{ key: 'subjects', icon: BookOpen, labelKey: 'nav.subjects' },
{ key: 'illness', icon: Heart, labelKey: 'nav.illness' },
{ key: 'communication', icon: MessageSquare, labelKey: 'nav.communication' },
{ key: 'counseling', icon: Heart, labelKey: 'counseling.title' },
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
{ key: 'resources', icon: FolderOpen, labelKey: 'nav.resources' },
{ key: 'settings', icon: Settings, labelKey: 'nav.settings' },
],
@@ -262,6 +283,8 @@ const parentNavSections: NavSection[] = [
{ key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
{ key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' },
{ key: 'illness', icon: Heart, labelKey: 'nav.illness' },
{ key: 'communication', icon: MessageSquare, labelKey: 'nav.communication' },
],
},
];
@@ -394,6 +417,12 @@ function renderView(view: ViewName) {
case 'resources': return <ResourceLibraryView />;
case 'competitions': return <CompetitionsView />;
case 'districts': return <DistrictManagementView />;
case 'subjects': return <SubjectsView />;
case 'illness': return <IllnessReportingView />;
case 'communication': return <CommunicationView />;
case 'counseling': return <CounselingView />;
case 'disciplinary': return <DisciplinaryView />;
case 'ai-tests': return <AITestsView />;
default: return <DashboardView />;
}
}
@@ -445,7 +474,7 @@ export default function AppLayout() {
? `${currentUser.firstName} ${currentUser.lastName}`
: '';
const roleKey = currentUser?.role === 'TEACHER' ? 'role.teacher' : currentUser?.role === 'SCHOOL_ADMIN' ? 'role.school_admin' : currentUser?.role === 'STUDENT' ? 'role.student' : currentUser?.role === 'PARENT' ? 'role.parent' : 'role.super_admin';
const roleKey = currentUser?.role === 'TEACHER' ? 'role.teacher' : currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'VICE_PRINCIPAL' ? 'role.school_admin' : currentUser?.role === 'STUDENT' ? 'role.student' : currentUser?.role === 'PARENT' ? 'role.parent' : 'role.super_admin';
const isStudent = currentUser?.role === 'STUDENT';
const isParent = currentUser?.role === 'PARENT';
@@ -1482,6 +1511,12 @@ export default function AppLayout() {
{/* ─── Onboarding Tour ─────────────────────────────────────── */}
<OnboardingTour open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
{/* ─── AI Chat Widget ─────────────────────────────────────────── */}
<AIChatWidget />
{/* ─── Virtual Character ──────────────────────────────────────── */}
<VirtualCharacter userRole={currentUser?.role ?? 'TEACHER'} />
</SidebarProvider>
);
}
+1
View File
@@ -18,6 +18,7 @@ type LoginRole = 'teacher' | 'student' | 'parent';
const DEMO_ACCOUNTS = [
{ email: 'demo@competencetrack.org', password: 'Demo2025!', role: 'SCHOOL_ADMIN', labelKey: 'auth.demo_admin', icon: Shield, colorClass: 'from-emerald-400 to-emerald-600 hover:from-emerald-500 hover:to-emerald-700 shadow-emerald-300/40 dark:shadow-emerald-900/40' },
{ email: 'demo.vice@competencetrack.org', password: 'Demo2025!', role: 'VICE_PRINCIPAL', labelKey: 'auth.demo_vice_principal', icon: Shield, colorClass: 'from-emerald-400 to-emerald-600 hover:from-emerald-500 hover:to-emerald-700 shadow-emerald-300/40 dark:shadow-emerald-900/40' },
{ email: 'demo.teacher@competencetrack.org', password: 'Demo2025!', role: 'TEACHER', labelKey: 'auth.demo_teacher', icon: GraduationCap, colorClass: 'from-teal-400 to-teal-600 hover:from-teal-500 hover:to-teal-700 shadow-teal-300/40 dark:shadow-teal-900/40' },
{ email: 'demo.student@competencetrack.org', password: 'Demo2025!', role: 'STUDENT', labelKey: 'auth.demo_student', icon: User, colorClass: 'from-amber-400 to-amber-600 hover:from-amber-500 hover:to-amber-700 shadow-amber-300/40 dark:shadow-amber-900/40' },
{ email: 'demo.parent@competencetrack.org', password: 'Demo2025!', role: 'PARENT', labelKey: 'auth.demo_parent', icon: Heart, colorClass: 'from-violet-400 to-violet-600 hover:from-violet-500 hover:to-violet-700 shadow-violet-300/40 dark:shadow-violet-900/40' },
+899
View File
@@ -0,0 +1,899 @@
'use client';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
MessageSquare,
Plus,
Send,
Clock,
CheckCircle2,
XCircle,
Phone,
Paperclip,
Mic,
MicOff,
StickyNote,
FileText,
User,
Shield,
Eye,
X,
MessageCircle,
ChevronRight,
AlertTriangle,
Volume2,
Notebook,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
import { toast } from 'sonner';
interface CommunicationRoom {
id: string;
schoolId: string;
studentId: string;
teacherId: string;
roomType: string;
status: string;
requestedBy: string;
acceptedAt: string | null;
closedAt: string | null;
closeReason: string | null;
createdAt: string;
updatedAt: string;
student?: { id: string; firstName: string; lastName: string };
teacher?: { id: string; firstName: string; lastName: string };
messages?: Array<{
id: string;
content: string;
messageType: string;
createdAt: string;
senderId: string;
}>;
}
interface Message {
id: string;
roomId: string;
senderId: string;
content: string;
messageType: string;
fileUrl: string | null;
metadata: string | null;
isRead: boolean;
createdAt: string;
sender?: { id: string; firstName: string; lastName: string; role: string };
}
export default function CommunicationView() {
const currentUser = useAppStore((s) => s.currentUser);
const [rooms, setRooms] = useState<CommunicationRoom[]>([]);
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showRequestDialog, setShowRequestDialog] = useState(false);
const fetchRooms = useCallback(async () => {
try {
const data = await apiGet<CommunicationRoom[]>('/api/communication-rooms');
setRooms(data);
} catch (error) {
console.error('Failed to fetch rooms:', error);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchRooms();
// Polling every 5 seconds for new messages
const interval = setInterval(fetchRooms, 5000);
return () => clearInterval(interval);
}, [fetchRooms]);
const role = currentUser?.role;
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
);
}
const requestedRooms = rooms.filter((r) => r.status === 'requested');
const activeRooms = rooms.filter((r) => r.status === 'active');
const closedRooms = rooms.filter((r) => r.status === 'closed');
return (
<div className="p-4 md:p-6 h-[calc(100vh-4rem)] flex flex-col max-w-7xl mx-auto">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4"
>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-foreground flex items-center gap-2">
<MessageSquare className="h-7 w-7 text-emerald-500" />
{t('communication.title')}
</h1>
<p className="text-muted-foreground mt-1">
{role === 'STUDENT' && t('communication.active_conversations')}
{role === 'TEACHER' && t('communication.incoming_requests')}
{(role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') && t('communication.all_rooms')}
</p>
</div>
{role === 'STUDENT' && (
<Button
onClick={() => setShowRequestDialog(true)}
className="bg-emerald-500 hover:bg-emerald-600 text-white"
>
<Plus className="h-4 w-4 mr-2" />
{t('communication.request_conversation')}
</Button>
)}
</motion.div>
{/* Main Layout: Room List + Chat */}
<div className="flex-1 flex gap-4 min-h-0">
{/* Room List */}
<div className="w-full md:w-80 shrink-0 flex flex-col gap-4">
{/* Pending Requests (Teacher) */}
{role === 'TEACHER' && requestedRooms.length > 0 && (
<Card className="border-amber-200 dark:border-amber-800">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Clock className="h-4 w-4 text-amber-500" />
{t('communication.pending_requests')} ({requestedRooms.length})
</CardTitle>
</CardHeader>
<CardContent className="p-2 pt-0">
<div className="space-y-1">
{requestedRooms.map((room) => (
<RoomListItem
key={room.id}
room={room}
isSelected={selectedRoomId === room.id}
onClick={() => setSelectedRoomId(room.id)}
currentUserId={currentUser?.id || ''}
role={role}
/>
))}
</div>
</CardContent>
</Card>
)}
{/* Active Rooms */}
<Card className="flex-1 min-h-0">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<MessageCircle className="h-4 w-4 text-emerald-500" />
{t('communication.active_conversations')} ({activeRooms.length})
</CardTitle>
</CardHeader>
<CardContent className="p-2 pt-0 overflow-y-auto max-h-96">
{activeRooms.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">{t('communication.no_conversations')}</p>
) : (
<div className="space-y-1">
{activeRooms.map((room) => (
<RoomListItem
key={room.id}
room={room}
isSelected={selectedRoomId === room.id}
onClick={() => setSelectedRoomId(room.id)}
currentUserId={currentUser?.id || ''}
role={role}
/>
))}
</div>
)}
</CardContent>
</Card>
{/* Closed Rooms */}
{closedRooms.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<XCircle className="h-4 w-4 text-muted-foreground" />
{t('communication.closed')} ({closedRooms.length})
</CardTitle>
</CardHeader>
<CardContent className="p-2 pt-0 overflow-y-auto max-h-32">
<div className="space-y-1">
{closedRooms.map((room) => (
<RoomListItem
key={room.id}
room={room}
isSelected={selectedRoomId === room.id}
onClick={() => setSelectedRoomId(room.id)}
currentUserId={currentUser?.id || ''}
role={role}
/>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* Chat Area */}
<div className="hidden md:flex flex-1 min-h-0">
{selectedRoomId ? (
<ChatArea
roomId={selectedRoomId}
currentUserId={currentUser?.id || ''}
role={role}
onRefresh={fetchRooms}
/>
) : (
<Card className="flex-1 flex items-center justify-center">
<div className="text-center">
<MessageSquare className="h-12 w-12 text-muted-foreground/30 mx-auto mb-4" />
<p className="text-muted-foreground">{t('communication.select_conversation')}</p>
</div>
</Card>
)}
</div>
</div>
{/* Mobile: Show chat as full-screen overlay */}
<AnimatePresence>
{selectedRoomId && (
<div className="md:hidden fixed inset-0 z-50 bg-background">
<ChatArea
roomId={selectedRoomId}
currentUserId={currentUser?.id || ''}
role={role}
onRefresh={fetchRooms}
isMobile
onClose={() => setSelectedRoomId(null)}
/>
</div>
)}
</AnimatePresence>
{/* Request Dialog */}
<RequestConversationDialog
open={showRequestDialog}
onOpenChange={setShowRequestDialog}
onCreated={fetchRooms}
/>
</div>
);
}
function RoomListItem({
room,
isSelected,
onClick,
currentUserId,
role,
}: {
room: CommunicationRoom;
isSelected: boolean;
onClick: () => void;
currentUserId: string;
role: string | undefined;
}) {
const isStudent = room.studentId === currentUserId;
const otherPerson = isStudent ? room.teacher : room.student;
const lastMessage = room.messages?.[0];
const getStatusColor = () => {
switch (room.status) {
case 'requested': return 'bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300';
case 'active': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300';
case 'closed': return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
default: return '';
}
};
const getStatusLabel = () => {
switch (room.status) {
case 'requested': return t('communication.requested');
case 'active': return t('communication.active');
case 'closed': return t('communication.closed');
default: return room.status;
}
};
return (
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
onClick={onClick}
className={`w-full text-left p-3 rounded-lg transition-colors ${
isSelected
? 'bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-800'
: 'hover:bg-muted/50'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<div className="relative shrink-0">
<div className="h-8 w-8 rounded-full bg-emerald-100 dark:bg-emerald-950/50 flex items-center justify-center">
<User className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
{/* Online/offline status indicator */}
<div className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white dark:border-gray-900 ${
room.status === 'active' ? 'bg-emerald-500' : room.status === 'requested' ? 'bg-amber-500' : 'bg-gray-400'
}`} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{otherPerson ? `${otherPerson.firstName} ${otherPerson.lastName}` : 'Unknown'}
</p>
{lastMessage && (
<p className="text-xs text-muted-foreground truncate">{lastMessage.content}</p>
)}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{/* Unread count badge */}
{room.messages && room.messages.length > 0 && room.status === 'active' && (
<span className="flex items-center justify-center h-4 min-w-4 px-1 rounded-full bg-emerald-500 text-white text-[10px] font-bold">
{room.messages.length}
</span>
)}
<Badge variant="secondary" className={`text-xs shrink-0 ${getStatusColor()}`}>
{getStatusLabel()}
</Badge>
</div>
</div>
</motion.button>
);
}
function ChatArea({
roomId,
currentUserId,
role,
onRefresh,
isMobile,
onClose,
}: {
roomId: string;
currentUserId: string;
role: string | undefined;
onRefresh: () => void;
isMobile?: boolean;
onClose?: () => void;
}) {
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState('');
const [isLoadingMessages, setIsLoadingMessages] = useState(true);
const [isSending, setIsSending] = useState(false);
const [room, setRoom] = useState<CommunicationRoom | null>(null);
const [isRecording, setIsRecording] = useState(false);
const [teacherNotes, setTeacherNotes] = useState('');
const [showTeacherNotes, setShowTeacherNotes] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const fetchMessages = useCallback(async () => {
try {
const data = await apiGet<{ messages: Message[]; total: number }>(`/api/communication-rooms/${roomId}/messages`);
setMessages(data.messages);
} catch (error) {
console.error('Failed to fetch messages:', error);
} finally {
setIsLoadingMessages(false);
}
}, [roomId]);
const fetchRoom = useCallback(async () => {
try {
const data = await apiGet<CommunicationRoom>(`/api/communication-rooms/${roomId}`);
setRoom(data);
} catch (error) {
console.error('Failed to fetch room:', error);
}
}, [roomId]);
useEffect(() => {
fetchMessages();
fetchRoom();
const interval = setInterval(fetchMessages, 5000);
return () => clearInterval(interval);
}, [fetchMessages, fetchRoom]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const sendMessage = async () => {
if (!newMessage.trim()) return;
setIsSending(true);
try {
await apiPost(`/api/communication-rooms/${roomId}/messages`, {
content: newMessage,
messageType: 'text',
});
setNewMessage('');
fetchMessages();
onRefresh();
} catch (error) {
toast.error(t('communication.error_send'));
} finally {
setIsSending(false);
}
};
const handleAccept = async () => {
try {
await apiPut(`/api/communication-rooms/${roomId}`, { status: 'active' });
toast.success(t('communication.conversation_accepted'));
fetchRoom();
onRefresh();
} catch (error) {
toast.error('Error accepting conversation');
}
};
const handleReject = async () => {
try {
await apiPut(`/api/communication-rooms/${roomId}`, { status: 'rejected' });
toast.success(t('communication.conversation_rejected'));
onRefresh();
} catch (error) {
toast.error('Error rejecting conversation');
}
};
const handleClose = async () => {
try {
await apiPut(`/api/communication-rooms/${roomId}`, { status: 'closed', closeReason: 'Closed by teacher' });
toast.success(t('communication.conversation_closed'));
fetchRoom();
onRefresh();
} catch (error) {
toast.error('Error closing conversation');
}
};
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
const audioChunks: BlobPart[] = [];
mediaRecorder.ondataavailable = (event) => {
audioChunks.push(event.data);
};
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
// In a real app, you'd upload this to a server
// For now, just send a text message indicating voice note
try {
await apiPost(`/api/communication-rooms/${roomId}/messages`, {
content: t('communication.voice_message'),
messageType: 'voice',
});
fetchMessages();
onRefresh();
} catch {
toast.error(t('communication.error_send'));
}
stream.getTracks().forEach((track) => track.stop());
};
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.start();
setIsRecording(true);
} catch {
toast.error('Microphone access denied');
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
}
};
const isStudent = room?.studentId === currentUserId;
const otherPerson = isStudent ? room?.teacher : room?.student;
const isAdmin = role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL' || role === 'SUPER_ADMIN';
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const time = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
if (isToday) return `${t('communication.today')} ${time}`;
if (isYesterday) return `${t('communication.yesterday')} ${time}`;
return `${date.toLocaleDateString()} ${time}`;
};
return (
<Card className={`flex-1 flex flex-col min-h-0 ${isMobile ? 'rounded-none border-0' : ''}`}>
{/* Chat Header */}
<div className="p-4 border-b flex items-center justify-between bg-gradient-to-r from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
<div className="flex items-center gap-3">
{isMobile && onClose && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
)}
<div className="relative">
<div className="h-10 w-10 rounded-full bg-emerald-100 dark:bg-emerald-950/50 flex items-center justify-center">
<User className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
{/* Online status */}
<div className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white dark:border-gray-900 ${
room?.status === 'active' ? 'bg-emerald-500' : room?.status === 'requested' ? 'bg-amber-500' : 'bg-gray-400'
}`} />
</div>
<div>
<p className="font-medium">
{otherPerson ? `${otherPerson.firstName} ${otherPerson.lastName}` : 'Unknown'}
</p>
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={`text-xs ${
room?.status === 'active' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' :
room?.status === 'requested' ? 'bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300' :
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}`}
>
{room?.status === 'requested' ? t('communication.requested') :
room?.status === 'active' ? t('communication.active') :
t('communication.closed')}
</Badge>
{isAdmin && (
<Badge variant="outline" className="text-xs">
<Eye className="h-3 w-3 mr-1" />
{t('communication.read_only')}
</Badge>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1">
{role === 'TEACHER' && room?.status === 'requested' && (
<>
<Button size="sm" className="bg-emerald-500 hover:bg-emerald-600 text-white h-8" onClick={handleAccept}>
<CheckCircle2 className="h-3.5 w-3.5 mr-1" />
{t('communication.accept')}
</Button>
<Button size="sm" variant="outline" className="h-8 text-rose-600" onClick={handleReject}>
<XCircle className="h-3.5 w-3.5 mr-1" />
{t('communication.reject')}
</Button>
</>
)}
{role === 'TEACHER' && room?.status === 'active' && (
<>
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowTeacherNotes(!showTeacherNotes)}>
<StickyNote className="h-3.5 w-3.5 mr-1" />
{t('communication.teacher_notes')}
</Button>
<Button variant="outline" size="sm" className="h-8 text-rose-600" onClick={handleClose}>
<XCircle className="h-3.5 w-3.5 mr-1" />
{t('communication.close')}
</Button>
</>
)}
</div>
</div>
{/* Teacher Notes Panel */}
<AnimatePresence>
{showTeacherNotes && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="border-b bg-amber-50 dark:bg-amber-950/20 p-4"
>
<Label className="text-sm font-medium">{t('communication.teacher_notes')}</Label>
<Textarea
value={teacherNotes}
onChange={(e) => setTeacherNotes(e.target.value)}
placeholder={t('communication.teacher_notes_placeholder')}
rows={3}
className="mt-1"
/>
</motion.div>
)}
</AnimatePresence>
{/* Typing indicator */}
<AnimatePresence>
{isSending && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="px-4 py-2 border-b"
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<motion.div className="h-2 w-2 rounded-full bg-emerald-500" animate={{ y: [0, -4, 0] }} transition={{ repeat: Infinity, duration: 0.6, delay: 0 }} />
<motion.div className="h-2 w-2 rounded-full bg-emerald-500" animate={{ y: [0, -4, 0] }} transition={{ repeat: Infinity, duration: 0.6, delay: 0.15 }} />
<motion.div className="h-2 w-2 rounded-full bg-emerald-500" animate={{ y: [0, -4, 0] }} transition={{ repeat: Infinity, duration: 0.6, delay: 0.3 }} />
</div>
<span className="text-xs text-muted-foreground">{t('communication.type_message')}</span>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{isLoadingMessages ? (
<div className="flex items-center justify-center h-full">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p>{t('communication.no_messages')}</p>
</div>
) : (
messages.map((msg, idx) => {
const isOwn = msg.senderId === currentUserId;
const isVoice = msg.messageType === 'voice';
const isFile = msg.messageType === 'file';
const isNoteShare = msg.messageType === 'note_share';
return (
<motion.div
key={msg.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.02 }}
className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-[80%] ${isOwn ? 'order-2' : 'order-1'}`}>
{!isOwn && msg.sender && (
<p className="text-xs text-muted-foreground mb-1 ml-1">
{msg.sender.firstName} {msg.sender.lastName}
</p>
)}
<div
className={`rounded-2xl px-4 py-2.5 shadow-sm ${
isOwn
? 'bg-gradient-to-br from-emerald-500 to-teal-600 text-white rounded-br-md'
: 'bg-white dark:bg-gray-800 rounded-bl-md border border-gray-100 dark:border-gray-700'
}`}
>
{isVoice && (
<div className="flex items-center gap-2">
<Volume2 className="h-4 w-4" />
<span>{t('communication.voice_message')}</span>
</div>
)}
{isFile && (
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4" />
<span>{t('communication.file_shared')}</span>
</div>
)}
{isNoteShare && (
<div className="flex items-center gap-2">
<Notebook className="h-4 w-4" />
<span>{t('communication.note_shared')}</span>
</div>
)}
{!isVoice && !isFile && !isNoteShare && <p className="text-sm">{msg.content}</p>}
{msg.fileUrl && (
<a href={msg.fileUrl} target="_blank" rel="noopener noreferrer" className="text-xs underline mt-1 block opacity-80">
{t('communication.file_shared')}
</a>
)}
</div>
<p className={`text-xs text-muted-foreground mt-1 ${isOwn ? 'text-right mr-1' : 'ml-1'}`}>
{formatTime(msg.createdAt)}
</p>
</div>
</motion.div>
);
})
)}
<div ref={messagesEndRef} />
</div>
{/* Message Input */}
{!isAdmin && room?.status === 'active' && (
<div className="p-4 border-t">
<div className="flex items-center gap-2">
{role === 'TEACHER' && (
<>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => {
apiPost(`/api/communication-rooms/${roomId}/messages`, {
content: t('communication.note_shared'),
messageType: 'note_share',
}).then(() => {
fetchMessages();
onRefresh();
});
}}
>
<StickyNote className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
>
<Paperclip className="h-4 w-4" />
</Button>
</>
)}
{role === 'STUDENT' && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => {
apiPost(`/api/communication-rooms/${roomId}/messages`, {
content: t('communication.note_shared'),
messageType: 'note_share',
}).then(() => {
fetchMessages();
onRefresh();
});
}}
>
<StickyNote className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={isRecording ? stopRecording : startRecording}
>
{isRecording ? (
<MicOff className="h-4 w-4 text-rose-500" />
) : (
<Mic className="h-4 w-4" />
)}
</Button>
{isRecording && (
<div className="flex items-center gap-2 text-sm text-rose-500">
<div className="h-2 w-2 rounded-full bg-rose-500 animate-pulse" />
{t('communication.recording')}
</div>
)}
{!isRecording && (
<>
<Input
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder={t('communication.type_message')}
className="flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
/>
<Button
onClick={sendMessage}
disabled={isSending || !newMessage.trim()}
className="bg-emerald-500 hover:bg-emerald-600 text-white h-9 shrink-0"
>
<Send className="h-4 w-4" />
</Button>
</>
)}
</div>
</div>
)}
{/* Admin read-only notice */}
{isAdmin && (
<div className="p-4 border-t bg-muted/30 text-center">
<p className="text-sm text-muted-foreground flex items-center justify-center gap-2">
<Shield className="h-4 w-4" />
{t('communication.read_only')}
</p>
</div>
)}
{/* Room not active notice */}
{room?.status !== 'active' && !isAdmin && (
<div className="p-4 border-t bg-muted/30 text-center">
<p className="text-sm text-muted-foreground">
{room?.status === 'requested' && t('communication.requested')}
{room?.status === 'closed' && t('communication.closed')}
</p>
</div>
)}
</Card>
);
}
function RequestConversationDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => void;
}) {
const [reason, setReason] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
setIsSubmitting(true);
try {
await apiPost('/api/communication-rooms', { reason });
toast.success(t('communication.request_sent'));
onOpenChange(false);
setReason('');
onCreated();
} catch (error) {
toast.error(t('communication.error_create'));
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-emerald-500" />
{t('communication.request_conversation')}
</DialogTitle>
<DialogDescription>
{t('communication.with_teacher')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>{t('communication.request_reason')}</Label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t('communication.optional_reason')}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('action.cancel')}</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting}
className="bg-emerald-500 hover:bg-emerald-600 text-white"
>
{isSubmitting ? <div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" /> : t('action.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+685
View File
@@ -0,0 +1,685 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Heart,
Calendar,
Clock,
FileText,
Plus,
Eye,
CheckCircle2,
XCircle,
AlertCircle,
UserCheck,
BarChart3,
ChevronRight,
Lock,
CalendarPlus,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut } from '@/lib/api';
import { toast } from 'sonner';
interface CounselingAppointment {
id: string;
schoolId: string;
studentId: string;
counselorId: string;
requestType: string;
description: string | null;
status: string;
scheduledAt: string | null;
duration: number;
notes: string | null;
isPrivate: boolean;
addToCalendar: boolean;
createdAt: string;
updatedAt: string;
counselor: { id: string; firstName: string; lastName: string };
student: { id: string; firstName: string; lastName: string };
}
interface Teacher {
id: string;
firstName: string;
lastName: string;
}
export default function CounselingView() {
const currentUser = useAppStore((s) => s.currentUser);
const [appointments, setAppointments] = useState<CounselingAppointment[]>([]);
const [loading, setLoading] = useState(true);
const [requestDialogOpen, setRequestDialogOpen] = useState(false);
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
const [selectedAppt, setSelectedAppt] = useState<CounselingAppointment | null>(null);
const [notesDialogOpen, setNotesDialogOpen] = useState(false);
// Form state
const [requestType, setRequestType] = useState('guidance');
const [description, setDescription] = useState('');
const [scheduledAt, setScheduledAt] = useState('');
const [duration, setDuration] = useState(30);
const [notes, setNotes] = useState('');
const schoolId = currentUser?.schoolId;
const role = currentUser?.role;
const isStudent = role === 'STUDENT';
const isCounselor = role === 'TEACHER';
const isAdmin = role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL';
const fetchAppointments = useCallback(async () => {
if (!schoolId) return;
setLoading(true);
try {
const data = await apiGet<CounselingAppointment[]>(`/api/counseling?schoolId=${schoolId}`);
setAppointments(data);
} catch (err) {
console.error('Failed to fetch appointments:', err);
} finally {
setLoading(false);
}
}, [schoolId]);
useEffect(() => {
fetchAppointments();
}, [fetchAppointments]);
const handleRequestAppointment = async () => {
try {
await apiPost('/api/counseling', {
schoolId,
requestType,
description,
addToCalendar: true,
isPrivate: true,
});
toast.success(t('counseling.request'));
setRequestDialogOpen(false);
setDescription('');
setRequestType('guidance');
fetchAppointments();
} catch (err) {
toast.error(String(err) || 'Failed to request appointment');
}
};
const handleScheduleAppointment = async () => {
if (!selectedAppt || !scheduledAt) return;
try {
await apiPut(`/api/counseling/${selectedAppt.id}`, {
scheduledAt,
duration,
status: 'scheduled',
addToCalendar: true,
});
toast.success(t('counseling.schedule'));
setScheduleDialogOpen(false);
setScheduledAt('');
setDuration(30);
fetchAppointments();
} catch (err) {
toast.error(String(err) || 'Failed to schedule appointment');
}
};
const handleAddNotes = async () => {
if (!selectedAppt || !notes) return;
try {
await apiPut(`/api/counseling/${selectedAppt.id}`, {
notes,
status: 'completed',
});
toast.success(t('counseling.add_notes'));
setNotesDialogOpen(false);
setNotes('');
fetchAppointments();
} catch (err) {
toast.error(String(err) || 'Failed to add notes');
}
};
const handleCancelAppointment = async (id: string) => {
try {
await apiPut(`/api/counseling/${id}`, { status: 'cancelled' });
toast.success(t('counseling.cancel'));
fetchAppointments();
} catch (err) {
toast.error(String(err) || 'Failed to cancel');
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'requested': return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400';
case 'scheduled': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
case 'completed': return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400';
case 'cancelled': return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
default: return 'bg-gray-100 text-gray-800';
}
};
const getTypeColor = (type: string) => {
switch (type) {
case 'guidance': return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400';
case 'psychological': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400';
case 'career': return 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-400';
case 'social': return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400';
default: return 'bg-gray-100 text-gray-800';
}
};
const upcoming = appointments.filter((a) => a.status === 'requested' || a.status === 'scheduled');
const history = appointments.filter((a) => a.status === 'completed' || a.status === 'cancelled');
// Stats for admin
const stats = {
total: appointments.length,
thisMonth: appointments.filter((a) => {
const d = new Date(a.createdAt);
const now = new Date();
return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear();
}).length,
byType: {
guidance: appointments.filter((a) => a.requestType === 'guidance').length,
psychological: appointments.filter((a) => a.requestType === 'psychological').length,
career: appointments.filter((a) => a.requestType === 'career').length,
social: appointments.filter((a) => a.requestType === 'social').length,
},
};
return (
<div className="space-y-6 p-4 md:p-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center shadow-lg shadow-emerald-200 dark:shadow-emerald-900/30">
<Heart className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{t('counseling.title')}</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('counseling.appointments')}</p>
</div>
</div>
<div className="flex gap-2">
{isStudent && (
<Button onClick={() => setRequestDialogOpen(true)} className="bg-emerald-600 hover:bg-emerald-700 text-white">
<Plus className="h-4 w-4 mr-2" />
{t('counseling.request')}
</Button>
)}
<Button variant="outline" onClick={fetchAppointments}>
{t('action.refresh')}
</Button>
</div>
</motion.div>
{/* Admin Stats */}
{isAdmin && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid grid-cols-2 md:grid-cols-4 gap-4"
>
<Card className="border-l-4 border-l-emerald-500">
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{t('counseling.total_appointments')}</p>
<p className="text-2xl font-bold">{stats.total}</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-teal-500">
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{t('counseling.this_month')}</p>
<p className="text-2xl font-bold">{stats.thisMonth}</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-amber-500">
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{t('counseling.upcoming')}</p>
<p className="text-2xl font-bold">{upcoming.length}</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500">
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{t('counseling.by_type')}</p>
<div className="flex gap-2 mt-1">
<Badge variant="secondary" className="text-xs">{stats.byType.guidance} G</Badge>
<Badge variant="secondary" className="text-xs">{stats.byType.psychological} P</Badge>
<Badge variant="secondary" className="text-xs">{stats.byType.career} C</Badge>
<Badge variant="secondary" className="text-xs">{stats.byType.social} S</Badge>
</div>
</CardContent>
</Card>
</motion.div>
)}
{/* Tabs */}
<Tabs defaultValue="upcoming" className="space-y-4">
<TabsList>
<TabsTrigger value="upcoming">{t('counseling.upcoming')}</TabsTrigger>
<TabsTrigger value="history">{t('counseling.history')}</TabsTrigger>
{isAdmin && <TabsTrigger value="statistics">{t('counseling.statistics')}</TabsTrigger>}
</TabsList>
{/* Upcoming Tab */}
<TabsContent value="upcoming">
{loading ? (
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="p-4">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-2" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3" />
</CardContent>
</Card>
))}
</div>
) : upcoming.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-12 text-center">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
>
<div className="h-20 w-20 rounded-full bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center mx-auto mb-4">
<Calendar className="h-10 w-10 text-emerald-300 dark:text-emerald-600" />
</div>
</motion.div>
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">{t('counseling.no_appointments')}</p>
<p className="text-gray-400 text-sm mt-1">{t('counseling.request')}</p>
{isStudent && (
<Button
onClick={() => setRequestDialogOpen(true)}
className="mt-4 bg-emerald-600 hover:bg-emerald-700 text-white"
>
<Plus className="h-4 w-4 mr-2" />
{t('counseling.request')}
</Button>
)}
</CardContent>
</Card>
) : (
<div className="grid gap-4">
<AnimatePresence>
{upcoming.map((appt, idx) => (
<motion.div
key={appt.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
>
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-start gap-3">
<div className="shrink-0">
{/* Counselor avatar with initials */}
<div className={`h-10 w-10 rounded-lg flex items-center justify-center text-sm font-bold ${getTypeColor(appt.requestType)}`}>
{appt.counselor.firstName[0]}{appt.counselor.lastName[0]}
</div>
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold">{t(`counseling.${appt.requestType}`)}</span>
<Badge className={getStatusColor(appt.status)} variant="secondary">
{t(`counseling.${appt.status}`)}
</Badge>
{appt.isPrivate && (
<Lock className="h-3.5 w-3.5 text-gray-400" />
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{isStudent
? `${t('counseling.counselor')}: ${appt.counselor.firstName} ${appt.counselor.lastName}`
: `${t('counseling.student')}: ${appt.student.firstName} ${appt.student.lastName}`}
</p>
{appt.scheduledAt && (
<div className="flex items-center gap-2 mt-1">
<div className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
<Calendar className="h-3.5 w-3.5" />
{new Date(appt.scheduledAt).toLocaleDateString()}
</div>
<div className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
<Clock className="h-3.5 w-3.5" />
{new Date(appt.scheduledAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
<div className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
{appt.duration} min
</div>
{/* Time slot visualization */}
<div className="flex items-center gap-0.5">
{Array.from({ length: Math.ceil(appt.duration / 15) }).map((_, i) => (
<div key={i} className="h-2 w-3 rounded-sm bg-emerald-300 dark:bg-emerald-700" />
))}
</div>
</div>
)}
{appt.description && (
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">{appt.description}</p>
)}
</div>
</div>
<div className="flex gap-2">
{isCounselor && appt.status === 'requested' && (
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedAppt(appt);
setScheduleDialogOpen(true);
}}
className="border-emerald-300 text-emerald-700 hover:bg-emerald-50 dark:border-emerald-700 dark:text-emerald-400 dark:hover:bg-emerald-950/30"
>
<CalendarPlus className="h-4 w-4 mr-1" />
{t('counseling.schedule')}
</Button>
)}
{isCounselor && appt.status === 'scheduled' && (
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedAppt(appt);
setNotesDialogOpen(true);
}}
>
<FileText className="h-4 w-4 mr-1" />
{t('counseling.add_notes')}
</Button>
)}
{(isCounselor || isAdmin) && appt.status !== 'cancelled' && (
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={() => handleCancelAppointment(appt.id)}
>
<XCircle className="h-4 w-4 mr-1" />
{t('counseling.cancel')}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</TabsContent>
{/* History Tab */}
<TabsContent value="history">
{history.length === 0 ? (
<Card>
<CardContent className="p-8 text-center">
<FileText className="h-12 w-12 mx-auto text-gray-400 mb-3" />
<p className="text-gray-500 dark:text-gray-400">{t('counseling.no_appointments')}</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{history.map((appt, idx) => (
<motion.div
key={appt.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.05 }}
>
<Card className="opacity-80 hover:opacity-100 transition-opacity">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`h-8 w-8 rounded-lg flex items-center justify-center ${getTypeColor(appt.requestType)}`}>
<Heart className="h-4 w-4" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{t(`counseling.${appt.requestType}`)}</span>
<Badge className={getStatusColor(appt.status)} variant="secondary">
{t(`counseling.${appt.status}`)}
</Badge>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{appt.scheduledAt ? new Date(appt.scheduledAt).toLocaleDateString() : new Date(appt.createdAt).toLocaleDateString()}
</p>
</div>
</div>
{appt.notes && (
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedAppt(appt);
setNotesDialogOpen(true);
}}
>
<Eye className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
)}
</TabsContent>
{/* Statistics Tab (Admin only) */}
{isAdmin && (
<TabsContent value="statistics">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('counseling.by_type')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{Object.entries(stats.byType).map(([type, count]) => (
<div key={type} className="flex items-center justify-between">
<span className="text-sm">{t(`counseling.${type}`)}</span>
<div className="flex items-center gap-2">
<div className="w-32 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${stats.total > 0 ? (count / stats.total) * 100 : 0}%` }}
className="h-2 rounded-full bg-emerald-500"
/>
</div>
<span className="text-sm font-medium w-8 text-right">{count}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('counseling.status')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{['requested', 'scheduled', 'completed', 'cancelled'].map((status) => {
const count = appointments.filter((a) => a.status === status).length;
return (
<div key={status} className="flex items-center justify-between">
<span className="text-sm">{t(`counseling.${status}`)}</span>
<div className="flex items-center gap-2">
<div className="w-32 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${stats.total > 0 ? (count / stats.total) * 100 : 0}%` }}
className="h-2 rounded-full bg-emerald-500"
/>
</div>
<span className="text-sm font-medium w-8 text-right">{count}</span>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
)}
</Tabs>
{/* Request Dialog */}
<Dialog open={requestDialogOpen} onOpenChange={setRequestDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('counseling.request')}</DialogTitle>
<DialogDescription>{t('counseling.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>{t('counseling.type')}</Label>
<Select value={requestType} onValueChange={setRequestType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="guidance">{t('counseling.guidance')}</SelectItem>
<SelectItem value="psychological">{t('counseling.psychological')}</SelectItem>
<SelectItem value="career">{t('counseling.career')}</SelectItem>
<SelectItem value="social">{t('counseling.social')}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>{t('counseling.description')}</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('counseling.description')}
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setRequestDialogOpen(false)}>
{t('action.cancel')}
</Button>
<Button onClick={handleRequestAppointment} className="bg-emerald-600 hover:bg-emerald-700 text-white">
{t('counseling.request')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Schedule Dialog */}
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('counseling.schedule')}</DialogTitle>
<DialogDescription>
{selectedAppt && `${t('counseling.student')}: ${selectedAppt.student.firstName} ${selectedAppt.student.lastName}`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>{t('counseling.scheduled_at')}</Label>
<Input
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
/>
</div>
<div>
<Label>{t('counseling.duration')}</Label>
<Select value={String(duration)} onValueChange={(v) => setDuration(Number(v))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="15">15 min</SelectItem>
<SelectItem value="30">30 min</SelectItem>
<SelectItem value="45">45 min</SelectItem>
<SelectItem value="60">60 min</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setScheduleDialogOpen(false)}>
{t('action.cancel')}
</Button>
<Button onClick={handleScheduleAppointment} className="bg-emerald-600 hover:bg-emerald-700 text-white">
{t('counseling.schedule')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Notes Dialog */}
<Dialog open={notesDialogOpen} onOpenChange={setNotesDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('counseling.session_notes')}</DialogTitle>
<DialogDescription>
{selectedAppt && `${t('counseling.student')}: ${selectedAppt.student.firstName} ${selectedAppt.student.lastName}`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{selectedAppt?.notes && (
<div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-300">{selectedAppt.notes}</p>
</div>
)}
<div>
<Label>{t('counseling.add_notes')}</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t('counseling.add_notes')}
rows={4}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setNotesDialogOpen(false)}>
{t('action.cancel')}
</Button>
<Button onClick={handleAddNotes} className="bg-emerald-600 hover:bg-emerald-700 text-white">
{t('action.save')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+736
View File
@@ -0,0 +1,736 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Shield,
Users,
Plus,
Eye,
CheckCircle2,
XCircle,
AlertTriangle,
Gavel,
UserPlus,
Trash2,
ChevronRight,
FileWarning,
Scale,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
import { toast } from 'sonner';
interface CommitteeMember {
id: string;
userId: string;
role: string;
isLead: boolean;
user: { id: string; firstName: string; lastName: string; role: string };
}
interface DisciplinaryCommittee {
id: string;
schoolId: string;
name: string;
description: string | null;
isActive: boolean;
createdAt: string;
members: CommitteeMember[];
_count?: { cases: number };
}
interface DisciplinaryCase {
id: string;
schoolId: string;
committeeId: string;
studentId: string;
reportedBy: string;
reviewedBy: string | null;
caseType: string;
description: string;
evidence: string | null;
status: string;
resolution: string | null;
resolutionDate: string | null;
createdAt: string;
updatedAt: string;
student: { id: string; firstName: string; lastName: string };
reporter: { id: string; firstName: string; lastName: string };
reviewer: { id: string; firstName: string; lastName: string } | null;
committee: { id: string; name: string };
}
interface Teacher {
id: string;
firstName: string;
lastName: string;
}
interface Student {
id: string;
firstName: string;
lastName: string;
}
export default function DisciplinaryView() {
const currentUser = useAppStore((s) => s.currentUser);
const [committees, setCommittees] = useState<DisciplinaryCommittee[]>([]);
const [cases, setCases] = useState<DisciplinaryCase[]>([]);
const [teachers, setTeachers] = useState<Teacher[]>([]);
const [students, setStudents] = useState<Student[]>([]);
const [loading, setLoading] = useState(true);
const [createCommitteeOpen, setCreateCommitteeOpen] = useState(false);
const [reportCaseOpen, setReportCaseOpen] = useState(false);
const [reviewCaseOpen, setReviewCaseOpen] = useState(false);
const [selectedCase, setSelectedCase] = useState<DisciplinaryCase | null>(null);
// Form state
const [committeeName, setCommitteeName] = useState('');
const [committeeDescription, setCommitteeDescription] = useState('');
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
const [caseCommitteeId, setCaseCommitteeId] = useState('');
const [caseStudentId, setCaseStudentId] = useState('');
const [caseType, setCaseType] = useState('warning');
const [caseDescription, setCaseDescription] = useState('');
const [caseEvidence, setCaseEvidence] = useState('');
const [resolution, setResolution] = useState('');
const [resolutionStatus, setResolutionStatus] = useState('resolved');
const schoolId = currentUser?.schoolId;
const role = currentUser?.role;
const isAdmin = role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'VICE_PRINCIPAL';
const isTeacher = role === 'TEACHER';
const fetchData = useCallback(async () => {
if (!schoolId) return;
setLoading(true);
try {
if (isAdmin) {
const [committeesData, casesData, teachersData, studentsData] = await Promise.all([
apiGet<DisciplinaryCommittee[]>(`/api/disciplinary-committees?schoolId=${schoolId}`).catch(() => []),
apiGet<DisciplinaryCase[]>(`/api/disciplinary-cases?schoolId=${schoolId}`).catch(() => []),
apiGet<Teacher[]>(`/api/users?schoolId=${schoolId}&role=TEACHER`).catch(() => []),
apiGet<Student[]>(`/api/students?schoolId=${schoolId}`).catch(() => []),
]);
setCommittees(committeesData);
setCases(casesData);
setTeachers(teachersData);
setStudents(studentsData);
} else {
const casesData = await apiGet<DisciplinaryCase[]>(`/api/disciplinary-cases?schoolId=${schoolId}`).catch(() => []);
setCases(casesData);
}
} catch (err) {
console.error('Failed to fetch data:', err);
} finally {
setLoading(false);
}
}, [schoolId, isAdmin]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleCreateCommittee = async () => {
if (!committeeName) return;
try {
await apiPost('/api/disciplinary-committees', {
schoolId,
name: committeeName,
description: committeeDescription,
members: selectedMembers.map((userId) => ({
userId,
role: 'member',
isLead: false,
})),
});
toast.success(t('disciplinary.create_committee'));
setCreateCommitteeOpen(false);
setCommitteeName('');
setCommitteeDescription('');
setSelectedMembers([]);
fetchData();
} catch (err) {
toast.error(String(err) || 'Failed to create committee');
}
};
const handleReportCase = async () => {
if (!caseCommitteeId || !caseStudentId || !caseDescription) return;
try {
await apiPost('/api/disciplinary-cases', {
schoolId,
committeeId: caseCommitteeId,
studentId: caseStudentId,
caseType,
description: caseDescription,
evidence: caseEvidence,
});
toast.success(t('disciplinary.report_case'));
setReportCaseOpen(false);
setCaseDescription('');
setCaseEvidence('');
fetchData();
} catch (err) {
toast.error(String(err) || 'Failed to report case');
}
};
const handleReviewCase = async () => {
if (!selectedCase || !resolution) return;
try {
await apiPut(`/api/disciplinary-cases/${selectedCase.id}`, {
status: resolutionStatus,
resolution,
});
toast.success(t('disciplinary.review_case'));
setReviewCaseOpen(false);
setResolution('');
setSelectedCase(null);
fetchData();
} catch (err) {
toast.error(String(err) || 'Failed to review case');
}
};
const handleToggleCommittee = async (id: string, isActive: boolean) => {
try {
if (isActive) {
await apiPut(`/api/disciplinary-committees/${id}`, { isActive: false });
toast.success(t('disciplinary.deactivate'));
} else {
await apiPut(`/api/disciplinary-committees/${id}`, { isActive: true });
toast.success(t('disciplinary.activate'));
}
fetchData();
} catch (err) {
toast.error(String(err) || 'Failed to update committee');
}
};
const getCaseTypeColor = (type: string) => {
switch (type) {
case 'warning': return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400';
case 'minor_violation': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400';
case 'major_violation': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
case 'serious_offense': return 'bg-rose-100 text-rose-800 dark:bg-rose-900/30 dark:text-rose-400';
default: return 'bg-gray-100 text-gray-800';
}
};
const getCaseStatusColor = (status: string) => {
switch (status) {
case 'open': return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400';
case 'under_review': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
case 'resolved': return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400';
case 'dismissed': return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
default: return 'bg-gray-100 text-gray-800';
}
};
const getCaseTypeIcon = (type: string) => {
switch (type) {
case 'warning': return AlertTriangle;
case 'minor_violation': return FileWarning;
case 'major_violation': return Shield;
case 'serious_offense': return Gavel;
default: return AlertTriangle;
}
};
const openCases = cases.filter((c) => c.status === 'open' || c.status === 'under_review');
const closedCases = cases.filter((c) => c.status === 'resolved' || c.status === 'dismissed');
return (
<div className="space-y-6 p-4 md:p-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center shadow-lg shadow-emerald-200 dark:shadow-emerald-900/30">
<Shield className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{t('disciplinary.title')}</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('disciplinary.cases')}</p>
</div>
</div>
<div className="flex gap-2">
{isAdmin && (
<Button onClick={() => setCreateCommitteeOpen(true)} className="bg-emerald-600 hover:bg-emerald-700 text-white">
<Plus className="h-4 w-4 mr-2" />
{t('disciplinary.create_committee')}
</Button>
)}
{(isTeacher || isAdmin) && (
<Button variant="outline" onClick={() => setReportCaseOpen(true)} className="border-emerald-300 text-emerald-700 dark:border-emerald-700 dark:text-emerald-400">
<FileWarning className="h-4 w-4 mr-2" />
{t('disciplinary.report_case')}
</Button>
)}
<Button variant="outline" onClick={fetchData}>
{t('action.refresh')}
</Button>
</div>
</motion.div>
{/* Admin Stats */}
{isAdmin && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid grid-cols-2 md:grid-cols-4 gap-4"
>
<Card className="border-l-4 border-l-emerald-500">
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{t('disciplinary.committee')}</p>
<p className="text-2xl font-bold">{committees.length}</p>
<p className="text-xs text-muted-foreground">{committees.filter((c) => c.isActive).length} {t('disciplinary.active')}</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-amber-500">
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{t('disciplinary.open')}</p>
<p className="text-2xl font-bold">{openCases.length}</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-blue-500">
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{t('disciplinary.under_review')}</p>
<p className="text-2xl font-bold">{cases.filter((c) => c.status === 'under_review').length}</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-teal-500">
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{t('disciplinary.resolved')}</p>
<p className="text-2xl font-bold">{closedCases.length}</p>
</CardContent>
</Card>
</motion.div>
)}
{/* Tabs */}
<Tabs defaultValue="cases" className="space-y-4">
<TabsList>
<TabsTrigger value="cases">{t('disciplinary.cases')}</TabsTrigger>
{isAdmin && <TabsTrigger value="committees">{t('disciplinary.committee')}</TabsTrigger>}
</TabsList>
{/* Cases Tab */}
<TabsContent value="cases">
{loading ? (
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="p-4">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-2" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3" />
</CardContent>
</Card>
))}
</div>
) : cases.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-12 text-center">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
>
<div className="h-20 w-20 rounded-full bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center mx-auto mb-4">
<Scale className="h-10 w-10 text-emerald-300 dark:text-emerald-600" />
</div>
</motion.div>
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">{t('disciplinary.no_cases')}</p>
<p className="text-gray-400 text-sm mt-1">All clear</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
<AnimatePresence>
{cases.map((c, idx) => {
const CaseIcon = getCaseTypeIcon(c.caseType);
return (
<motion.div
key={c.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
>
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-start gap-3">
<div className={`h-10 w-10 rounded-lg flex items-center justify-center ${getCaseTypeColor(c.caseType)}`}>
<CaseIcon className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold">{t(`disciplinary.${c.caseType}`)}</span>
<Badge className={getCaseStatusColor(c.status)} variant="secondary">
{t(`disciplinary.${c.status}`)}
</Badge>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('disciplinary.student_involved')}: {c.student.firstName} {c.student.lastName}
</p>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">{c.description}</p>
{/* Case status timeline */}
<div className="flex items-center gap-2 mt-2">
{['open', 'under_review', 'resolved'].map((step, si) => (
<div key={step} className="flex items-center gap-1.5">
<div className={`h-2 w-2 rounded-full ${
c.status === step ? 'bg-emerald-500 ring-2 ring-emerald-200 dark:ring-emerald-800' :
['open', 'under_review', 'resolved'].indexOf(c.status) > si ? 'bg-emerald-400' : 'bg-gray-300 dark:bg-gray-600'
}`} />
<span className={`text-[10px] ${
c.status === step ? 'text-emerald-600 dark:text-emerald-400 font-medium' : 'text-gray-400'
}`}>{t(`disciplinary.${step}`)}</span>
{si < 2 && <div className="h-px w-4 bg-gray-300 dark:bg-gray-600" />}
</div>
))}
</div>
{/* Evidence file attachment cards */}
{c.evidence && (
<div className="mt-2 p-2 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FileWarning className="h-4 w-4 text-amber-500 shrink-0" />
<span className="text-xs text-gray-600 dark:text-gray-400 truncate">{c.evidence}</span>
</div>
)}
<p className="text-xs text-gray-400 mt-1">
{t('disciplinary.reported_by')}: {c.reporter.firstName} {c.reporter.lastName} | {t('disciplinary.committee')}: {c.committee.name}
</p>
{c.resolution && (
<p className="text-sm text-emerald-600 dark:text-emerald-400 mt-1">
{t('disciplinary.resolution')}: {c.resolution}
</p>
)}
</div>
</div>
<div className="flex gap-2">
{(c.status === 'open' || c.status === 'under_review') && (
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedCase(c);
setReviewCaseOpen(true);
}}
className="border-emerald-300 text-emerald-700 hover:bg-emerald-50 dark:border-emerald-700 dark:text-emerald-400 dark:hover:bg-emerald-950/30"
>
<Eye className="h-4 w-4 mr-1" />
{t('disciplinary.review_case')}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</AnimatePresence>
</div>
)}
</TabsContent>
{/* Committees Tab (Admin only) */}
{isAdmin && (
<TabsContent value="committees">
{committees.length === 0 ? (
<Card>
<CardContent className="p-8 text-center">
<Users className="h-12 w-12 mx-auto text-gray-400 mb-3" />
<p className="text-gray-500 dark:text-gray-400">{t('disciplinary.no_committees')}</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2">
{committees.map((committee, idx) => (
<motion.div
key={committee.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.05 }}
>
<Card className={`hover:shadow-md transition-shadow ${!committee.isActive ? 'opacity-60' : ''}`}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{committee.name}</CardTitle>
<Badge className={committee.isActive ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400'}>
{committee.isActive ? t('disciplinary.active') : t('disciplinary.inactive')}
</Badge>
</div>
{committee.description && (
<CardDescription>{committee.description}</CardDescription>
)}
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{t('disciplinary.members')}</p>
<div className="flex flex-wrap gap-1">
{committee.members.map((m) => (
<Badge key={m.id} variant="secondary" className={`text-xs ${
m.role === 'chair' ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400' :
m.isLead ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400' : ''
}`}>
{m.user.firstName} {m.user.lastName}
{m.role === 'chair' && ` (${t('disciplinary.member_chair')})`}
{m.isLead && ` (${t('disciplinary.lead')})`}
</Badge>
))}
{committee.members.length === 0 && (
<span className="text-xs text-gray-400">{t('disciplinary.no_committees')}</span>
)}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{t('disciplinary.cases')}: {committee._count?.cases ?? 0}</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleToggleCommittee(committee.id, committee.isActive)}
>
{committee.isActive ? t('disciplinary.deactivate') : t('disciplinary.activate')}
</Button>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
)}
</TabsContent>
)}
</Tabs>
{/* Create Committee Dialog */}
<Dialog open={createCommitteeOpen} onOpenChange={setCreateCommitteeOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('disciplinary.create_committee')}</DialogTitle>
<DialogDescription>{t('disciplinary.committee_description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>{t('disciplinary.committee_name')}</Label>
<Input
value={committeeName}
onChange={(e) => setCommitteeName(e.target.value)}
placeholder={t('disciplinary.committee_name')}
/>
</div>
<div>
<Label>{t('disciplinary.committee_description')}</Label>
<Textarea
value={committeeDescription}
onChange={(e) => setCommitteeDescription(e.target.value)}
placeholder={t('disciplinary.committee_description')}
rows={3}
/>
</div>
<div>
<Label>{t('disciplinary.members')}</Label>
<ScrollArea className="h-40 rounded border p-2">
{teachers.map((teacher) => (
<label key={teacher.id} className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900 px-2 rounded">
<input
type="checkbox"
checked={selectedMembers.includes(teacher.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedMembers([...selectedMembers, teacher.id]);
} else {
setSelectedMembers(selectedMembers.filter((id) => id !== teacher.id));
}
}}
className="rounded border-gray-300"
/>
<span className="text-sm">{teacher.firstName} {teacher.lastName}</span>
</label>
))}
</ScrollArea>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCreateCommitteeOpen(false)}>
{t('action.cancel')}
</Button>
<Button onClick={handleCreateCommittee} className="bg-emerald-600 hover:bg-emerald-700 text-white">
{t('action.create')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Report Case Dialog */}
<Dialog open={reportCaseOpen} onOpenChange={setReportCaseOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('disciplinary.report_case')}</DialogTitle>
<DialogDescription>{t('disciplinary.case_description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>{t('disciplinary.committee')}</Label>
<Select value={caseCommitteeId} onValueChange={setCaseCommitteeId}>
<SelectTrigger>
<SelectValue placeholder={t('action.select')} />
</SelectTrigger>
<SelectContent>
{committees.filter((c) => c.isActive).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('disciplinary.student_involved')}</Label>
<Select value={caseStudentId} onValueChange={setCaseStudentId}>
<SelectTrigger>
<SelectValue placeholder={t('action.select')} />
</SelectTrigger>
<SelectContent>
{students.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.firstName} {s.lastName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('disciplinary.case_type')}</Label>
<Select value={caseType} onValueChange={setCaseType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="warning">{t('disciplinary.warning')}</SelectItem>
<SelectItem value="minor_violation">{t('disciplinary.minor_violation')}</SelectItem>
<SelectItem value="major_violation">{t('disciplinary.major_violation')}</SelectItem>
<SelectItem value="serious_offense">{t('disciplinary.serious_offense')}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>{t('disciplinary.case_description')}</Label>
<Textarea
value={caseDescription}
onChange={(e) => setCaseDescription(e.target.value)}
placeholder={t('disciplinary.case_description')}
rows={3}
/>
</div>
<div>
<Label>{t('disciplinary.evidence')}</Label>
<Textarea
value={caseEvidence}
onChange={(e) => setCaseEvidence(e.target.value)}
placeholder={t('disciplinary.evidence')}
rows={2}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setReportCaseOpen(false)}>
{t('action.cancel')}
</Button>
<Button onClick={handleReportCase} className="bg-emerald-600 hover:bg-emerald-700 text-white">
{t('disciplinary.report_case')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Review Case Dialog */}
<Dialog open={reviewCaseOpen} onOpenChange={setReviewCaseOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('disciplinary.review_case')}</DialogTitle>
<DialogDescription>
{selectedCase && `${t('disciplinary.student_involved')}: ${selectedCase.student.firstName} ${selectedCase.student.lastName}`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{selectedCase && (
<div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-2">
<div className="flex items-center gap-2">
<Badge className={getCaseTypeColor(selectedCase.caseType)} variant="secondary">
{t(`disciplinary.${selectedCase.caseType}`)}
</Badge>
<Badge className={getCaseStatusColor(selectedCase.status)} variant="secondary">
{t(`disciplinary.${selectedCase.status}`)}
</Badge>
</div>
<p className="text-sm">{selectedCase.description}</p>
{selectedCase.evidence && (
<p className="text-xs text-gray-500">{t('disciplinary.evidence')}: {selectedCase.evidence}</p>
)}
</div>
)}
<div>
<Label>{t('disciplinary.resolution')}</Label>
<Select value={resolutionStatus} onValueChange={setResolutionStatus}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="under_review">{t('disciplinary.under_review')}</SelectItem>
<SelectItem value="resolved">{t('disciplinary.resolve_case')}</SelectItem>
<SelectItem value="dismissed">{t('disciplinary.dismiss_case')}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>{t('disciplinary.resolution')}</Label>
<Textarea
value={resolution}
onChange={(e) => setResolution(e.target.value)}
placeholder={t('disciplinary.resolution')}
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setReviewCaseOpen(false)}>
{t('action.cancel')}
</Button>
<Button onClick={handleReviewCase} className="bg-emerald-600 hover:bg-emerald-700 text-white">
{t('action.confirm')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+810
View File
@@ -0,0 +1,810 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Heart,
Plus,
AlertTriangle,
CheckCircle2,
XCircle,
Clock,
CalendarDays,
FileText,
Upload,
ChevronDown,
ChevronUp,
BarChart3,
Calendar,
List,
Shield,
User,
Users,
Trash2,
Edit,
Eye,
EyeOff,
BadgeCheck,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
import { toast } from 'sonner';
interface IllnessReport {
id: string;
schoolId: string;
studentId: string;
reportedBy: string;
reporterType: string;
reason: string;
description: string | null;
startDate: string;
endDate: string | null;
documentUrl: string | null;
parentApprovalStatus: string;
parentApprovedBy: string | null;
parentApprovedAt: string | null;
isVisibleToTeacher: boolean;
isVisibleToAdmin: boolean;
status: string;
createdAt: string;
updatedAt: string;
student?: { id: string; firstName: string; lastName: string };
reporter?: { id: string; firstName: string; lastName: string };
approver?: { id: string; firstName: string; lastName: string } | null;
}
export default function IllnessReportingView() {
const currentUser = useAppStore((s) => s.currentUser);
const [reports, setReports] = useState<IllnessReport[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [viewMode, setViewMode] = useState<'list' | 'calendar'>('list');
const fetchReports = useCallback(async () => {
try {
const data = await apiGet<IllnessReport[]>('/api/illness-reports');
setReports(data);
} catch (error) {
console.error('Failed to fetch illness reports:', error);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchReports();
}, [fetchReports]);
const role = currentUser?.role;
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
);
}
return (
<div className="p-4 md:p-6 space-y-6 max-w-7xl mx-auto">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-foreground flex items-center gap-2">
<Heart className="h-7 w-7 text-emerald-500" />
{t('illness.title')}
</h1>
<p className="text-muted-foreground mt-1">
{role === 'STUDENT' && t('illness.my_reports')}
{role === 'PARENT' && t('illness.children_reports')}
{role === 'TEACHER' && t('illness.class_absences')}
{(role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') && t('illness.all_reports')}
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className={viewMode === 'list' ? 'bg-emerald-500 hover:bg-emerald-600 text-white' : ''}
>
<List className="h-4 w-4 mr-1" />
{t('illness.list_view')}
</Button>
<Button
variant={viewMode === 'calendar' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('calendar')}
className={viewMode === 'calendar' ? 'bg-emerald-500 hover:bg-emerald-600 text-white' : ''}
>
<Calendar className="h-4 w-4 mr-1" />
{t('illness.calendar_view')}
</Button>
</div>
{(role === 'STUDENT' || role === 'PARENT') && (
<Button
onClick={() => setShowCreateDialog(true)}
className="bg-emerald-500 hover:bg-emerald-600 text-white"
>
<Plus className="h-4 w-4 mr-2" />
{t('illness.report')}
</Button>
)}
</div>
</motion.div>
{/* Stats Cards */}
<StatsCards reports={reports} role={role} />
{/* Parent Approval Warning for Students */}
{role === 'STUDENT' && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-lg p-4 flex items-start gap-3"
>
<AlertTriangle className="h-5 w-5 text-amber-500 mt-0.5 shrink-0" />
<div>
<p className="font-medium text-amber-800 dark:text-amber-200">{t('illness.parent_approval_needed')}</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">{t('illness.parent_approval_desc')}</p>
</div>
</motion.div>
)}
{/* Main Content */}
{viewMode === 'list' ? (
<ListView reports={reports} role={role} currentUserId={currentUser?.id || ''} onRefresh={fetchReports} />
) : (
<CalendarView reports={reports} role={role} />
)}
{/* Create Dialog */}
<CreateReportDialog
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
role={role}
userId={currentUser?.id || ''}
schoolId={currentUser?.schoolId || ''}
onCreated={fetchReports}
/>
</div>
);
}
function StatsCards({ reports, role }: { reports: IllnessReport[]; role: string | undefined }) {
const pending = reports.filter((r) => r.parentApprovalStatus === 'pending').length;
const approved = reports.filter((r) => r.parentApprovalStatus === 'approved').length;
const rejected = reports.filter((r) => r.parentApprovalStatus === 'rejected').length;
const totalDays = reports
.filter((r) => r.parentApprovalStatus === 'approved')
.reduce((acc, r) => {
const start = new Date(r.startDate);
const end = r.endDate ? new Date(r.endDate) : start;
return acc + Math.max(1, Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1);
}, 0);
const stats = [
{ label: t('illness.pending_reports'), value: pending, icon: Clock, color: 'text-amber-500', bg: 'bg-amber-50 dark:bg-amber-950/30' },
{ label: t('illness.approved_reports'), value: approved, icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-50 dark:bg-emerald-950/30' },
{ label: t('illness.rejected_reports'), value: rejected, icon: XCircle, color: 'text-rose-500', bg: 'bg-rose-50 dark:bg-rose-950/30' },
{ label: t('illness.total_absences'), value: totalDays, icon: CalendarDays, color: 'text-teal-500', bg: 'bg-teal-50 dark:bg-teal-950/30' },
];
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{stats.map((stat, idx) => (
<motion.div
key={stat.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
>
<Card className={`border-0 ${stat.bg} overflow-hidden relative`}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">{stat.label}</p>
<motion.p
className="text-2xl font-bold mt-1"
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 25, delay: idx * 0.1 + 0.2 }}
>
{stat.value}
</motion.p>
</div>
<div className={`h-10 w-10 rounded-xl ${stat.bg} flex items-center justify-center`}>
<stat.icon className={`h-5 w-5 ${stat.color}`} />
</div>
</div>
</CardContent>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-current to-transparent opacity-10" />
</Card>
</motion.div>
))}
</div>
);
}
function ListView({ reports, role, currentUserId, onRefresh }: { reports: IllnessReport[]; role: string | undefined; currentUserId: string; onRefresh: () => void }) {
const [expandedId, setExpandedId] = useState<string | null>(null);
if (reports.length === 0) {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<Card className="border-dashed">
<CardContent className="p-8 flex flex-col items-center justify-center text-center">
<Heart className="h-12 w-12 text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground">{t('illness.no_reports')}</p>
<p className="text-sm text-muted-foreground/70 mt-1">{t('illness.create_first')}</p>
</CardContent>
</Card>
</motion.div>
);
}
const pendingReports = reports.filter((r) => r.parentApprovalStatus === 'pending');
const processedReports = reports.filter((r) => r.parentApprovalStatus !== 'pending');
return (
<div className="space-y-6">
{/* Pending Approvals Section (for Parent) */}
{role === 'PARENT' && pendingReports.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-500" />
{t('illness.pending_approvals')} ({pendingReports.length})
</h2>
<div className="space-y-3">
{pendingReports.map((report, idx) => (
<ReportCard
key={report.id}
report={report}
role={role}
currentUserId={currentUserId}
expanded={expandedId === report.id}
onToggle={() => setExpandedId(expandedId === report.id ? null : report.id)}
onRefresh={onRefresh}
idx={idx}
/>
))}
</div>
</div>
)}
{/* All Reports */}
<div>
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
<FileText className="h-5 w-5 text-emerald-500" />
{role === 'PARENT' ? t('illness.children_reports') : t('illness.my_reports')}
</h2>
<div className="space-y-3">
{reports.map((report, idx) => (
<ReportCard
key={report.id}
report={report}
role={role}
currentUserId={currentUserId}
expanded={expandedId === report.id}
onToggle={() => setExpandedId(expandedId === report.id ? null : report.id)}
onRefresh={onRefresh}
idx={idx}
/>
))}
</div>
</div>
</div>
);
}
function ReportCard({
report,
role,
currentUserId,
expanded,
onToggle,
onRefresh,
idx,
}: {
report: IllnessReport;
role: string | undefined;
currentUserId: string;
expanded: boolean;
onToggle: () => void;
onRefresh: () => void;
idx: number;
}) {
const [isApproving, setIsApproving] = useState(false);
const getStatusBadge = () => {
switch (report.parentApprovalStatus) {
case 'pending':
return <Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-300 dark:bg-amber-950/30 dark:text-amber-300 dark:border-amber-800"><Clock className="h-3 w-3 mr-1" />{t('illness.pending_approval')}</Badge>;
case 'approved':
return <Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-300 dark:bg-emerald-950/30 dark:text-emerald-300 dark:border-emerald-800"><CheckCircle2 className="h-3 w-3 mr-1" />{t('illness.approved')}</Badge>;
case 'rejected':
return <Badge variant="outline" className="bg-rose-50 text-rose-700 border-rose-300 dark:bg-rose-950/30 dark:text-rose-300 dark:border-rose-800"><XCircle className="h-3 w-3 mr-1" />{t('illness.rejected')}</Badge>;
default:
return null;
}
};
const getReasonLabel = (reason: string) => {
switch (reason) {
case 'illness': return t('illness.illness');
case 'doctor_visit': return t('illness.doctor_visit');
case 'other': return t('illness.other');
default: return reason;
}
};
const handleApprove = async () => {
setIsApproving(true);
try {
await apiPost('/api/illness-reports/approve', { reportId: report.id, action: 'approve' });
toast.success(t('illness.approve_success'));
onRefresh();
} catch (error) {
toast.error(t('illness.error_approve'));
} finally {
setIsApproving(false);
}
};
const handleReject = async () => {
setIsApproving(true);
try {
await apiPost('/api/illness-reports/approve', { reportId: report.id, action: 'reject' });
toast.success(t('illness.reject_success'));
onRefresh();
} catch (error) {
toast.error(t('illness.error_reject'));
} finally {
setIsApproving(false);
}
};
const handleDelete = async () => {
try {
await apiDelete(`/api/illness-reports/${report.id}`);
toast.success(t('illness.delete_success'));
onRefresh();
} catch (error) {
toast.error('Error deleting report');
}
};
const startDate = new Date(report.startDate);
const endDate = report.endDate ? new Date(report.endDate) : null;
const days = endDate
? Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1)
: 1;
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.05 }}
>
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{getReasonLabel(report.reason)}</span>
{getStatusBadge()}
{report.reporterType === 'student' && (
<Badge variant="secondary" className="text-xs"><User className="h-3 w-3 mr-1" />{t('illness.student_report')}</Badge>
)}
{report.reporterType === 'parent' && (
<Badge variant="secondary" className="text-xs"><BadgeCheck className="h-3 w-3 mr-1" />{t('illness.parent_report')}</Badge>
)}
{!report.isVisibleToTeacher && role === 'STUDENT' && (
<Badge variant="secondary" className="text-xs"><EyeOff className="h-3 w-3 mr-1" />{t('illness.not_visible_teacher')}</Badge>
)}
{report.isVisibleToTeacher && (
<Badge variant="secondary" className="text-xs bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300"><Eye className="h-3 w-3 mr-1" />{t('illness.visible_teacher')}</Badge>
)}
</div>
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<CalendarDays className="h-3.5 w-3.5" />
{startDate.toLocaleDateString()}
{endDate && ` - ${endDate.toLocaleDateString()}`}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{days} {t('illness.days_absent')}
</span>
</div>
{/* Date range visualization */}
<div className="mt-2 flex items-center gap-1">
{Array.from({ length: Math.min(days, 14) }).map((_, i) => (
<motion.div
key={i}
className={`h-1.5 flex-1 rounded-full ${
report.parentApprovalStatus === 'approved' ? 'bg-emerald-400' :
report.parentApprovalStatus === 'pending' ? 'bg-amber-400' : 'bg-rose-400'
}`}
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ delay: i * 0.05, duration: 0.3 }}
style={{ transformOrigin: 'left' }}
/>
))}
{days > 14 && <span className="text-xs text-muted-foreground ml-1">+{days - 14}</span>}
</div>
{report.student && role !== 'STUDENT' && (
<p className="text-sm text-muted-foreground mt-1">
{report.student.firstName} {report.student.lastName}
</p>
)}
</div>
<div className="flex items-center gap-1">
{(role === 'PARENT' && report.parentApprovalStatus === 'pending') && (
<>
<Button
size="sm"
className="bg-emerald-500 hover:bg-emerald-600 text-white h-8"
onClick={handleApprove}
disabled={isApproving}
>
<CheckCircle2 className="h-3.5 w-3.5 mr-1" />
{t('illness.approve')}
</Button>
<Button
size="sm"
variant="outline"
className="h-8 text-rose-600 border-rose-300 hover:bg-rose-50 dark:hover:bg-rose-950/30"
onClick={handleReject}
disabled={isApproving}
>
<XCircle className="h-3.5 w-3.5 mr-1" />
{t('illness.reject')}
</Button>
</>
)}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggle}>
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</div>
</div>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<Separator className="my-3" />
<div className="space-y-2 text-sm">
{report.description && (
<div>
<span className="text-muted-foreground">{t('illness.description')}:</span>
<p className="mt-1">{report.description}</p>
</div>
)}
{report.documentUrl && (
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-emerald-500" />
<a href={report.documentUrl} target="_blank" rel="noopener noreferrer" className="text-emerald-600 hover:underline">
{t('illness.medical_certificate')}
</a>
</div>
)}
<div className="flex items-center gap-4 text-muted-foreground">
<span>{t('illness.reported_on')}: {new Date(report.createdAt).toLocaleDateString()}</span>
{report.reporter && (
<span>{t('illness.reported_by')}: {report.reporter.firstName} {report.reporter.lastName}</span>
)}
</div>
{report.approver && (
<div className="text-muted-foreground">
<span>{t('illness.approved_by')}: {report.approver.firstName} {report.approver.lastName}</span>
{report.parentApprovedAt && (
<span className="ml-2">{t('illness.approved_on')}: {new Date(report.parentApprovedAt).toLocaleDateString()}</span>
)}
</div>
)}
{(report.reportedBy === currentUserId || role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') && (
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" className="h-7 text-rose-600" onClick={handleDelete}>
<Trash2 className="h-3.5 w-3.5 mr-1" />
{t('action.delete')}
</Button>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</CardContent>
</Card>
</motion.div>
);
}
function CalendarView({ reports, role }: { reports: IllnessReport[]; role: string | undefined }) {
const today = new Date();
const [currentMonth, setCurrentMonth] = useState(today.getMonth());
const [currentYear, setCurrentYear] = useState(today.getFullYear());
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();
const adjustedFirstDay = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1;
const getReportsForDay = (day: number) => {
const date = new Date(currentYear, currentMonth, day);
return reports.filter((r) => {
const start = new Date(r.startDate);
const end = r.endDate ? new Date(r.endDate) : start;
return date >= start && date <= end;
});
};
const prevMonth = () => {
if (currentMonth === 0) {
setCurrentMonth(11);
setCurrentYear(currentYear - 1);
} else {
setCurrentMonth(currentMonth - 1);
}
};
const nextMonth = () => {
if (currentMonth === 11) {
setCurrentMonth(0);
setCurrentYear(currentYear + 1);
} else {
setCurrentMonth(currentMonth + 1);
}
};
const monthName = new Date(currentYear, currentMonth).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{monthName}</CardTitle>
<div className="flex gap-1">
<Button variant="outline" size="icon" className="h-8 w-8" onClick={prevMonth}>
<ChevronDown className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-8 w-8" onClick={nextMonth}>
<ChevronUp className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-7 gap-1">
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d) => (
<div key={d} className="text-center text-xs font-medium text-muted-foreground py-1">{d}</div>
))}
{Array.from({ length: adjustedFirstDay }).map((_, i) => (
<div key={`empty-${i}`} className="h-16" />
))}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1;
const dayReports = getReportsForDay(day);
const isToday = day === today.getDate() && currentMonth === today.getMonth() && currentYear === today.getFullYear();
return (
<div
key={day}
className={`h-16 border rounded-md p-1 text-xs ${
isToday ? 'bg-emerald-50 dark:bg-emerald-950/30 border-emerald-300 dark:border-emerald-800' : 'border-muted'
}`}
>
<span className={`font-medium ${isToday ? 'text-emerald-600 dark:text-emerald-400' : ''}`}>{day}</span>
<div className="mt-0.5 space-y-0.5">
{dayReports.slice(0, 2).map((r) => (
<div
key={r.id}
className={`h-1.5 rounded-full ${
r.parentApprovalStatus === 'approved' ? 'bg-emerald-400' :
r.parentApprovalStatus === 'pending' ? 'bg-amber-400' : 'bg-rose-400'
}`}
/>
))}
{dayReports.length > 2 && (
<div className="text-muted-foreground">+{dayReports.length - 2}</div>
)}
</div>
</div>
);
})}
</div>
<div className="flex items-center gap-4 mt-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1"><div className="h-2 w-2 rounded-full bg-emerald-400" />{t('illness.approved')}</div>
<div className="flex items-center gap-1"><div className="h-2 w-2 rounded-full bg-amber-400" />{t('illness.pending_approval')}</div>
<div className="flex items-center gap-1"><div className="h-2 w-2 rounded-full bg-rose-400" />{t('illness.rejected')}</div>
</div>
</CardContent>
</Card>
);
}
function CreateReportDialog({
open,
onOpenChange,
role,
userId,
schoolId,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
role: string | undefined;
userId: string;
schoolId: string;
onCreated: () => void;
}) {
const [reason, setReason] = useState('illness');
const [description, setDescription] = useState('');
const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedStudentId, setSelectedStudentId] = useState('');
const [children, setChildren] = useState<Array<{ id: string; firstName: string; lastName: string }>>([]);
useEffect(() => {
if (open && role === 'PARENT') {
apiGet<Array<{ studentId: string; student: { id: string; firstName: string; lastName: string } }>>(
`/api/parent-links?parentId=${userId}`
).then((data) => {
const kids = data.map((d) => ({ id: d.student.id, firstName: d.student.firstName, lastName: d.student.lastName }));
setChildren(kids);
if (kids.length > 0) setSelectedStudentId(kids[0].id);
}).catch(() => {});
}
if (open && role === 'STUDENT') {
// Find the student record
apiGet<Array<{ id: string }>>('/api/students?userId=' + userId).then((data) => {
if (data.length > 0) setSelectedStudentId(data[0].id);
}).catch(() => {});
}
}, [open, role, userId]);
const handleSubmit = async () => {
if (!selectedStudentId || !reason || !startDate) return;
setIsSubmitting(true);
try {
await apiPost('/api/illness-reports', {
studentId: selectedStudentId,
reason,
description: description || null,
startDate,
endDate: endDate || null,
});
toast.success(t('illness.report_success'));
onOpenChange(false);
setReason('illness');
setDescription('');
setStartDate(new Date().toISOString().split('T')[0]);
setEndDate('');
onCreated();
} catch (error) {
toast.error(t('illness.error_create'));
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Heart className="h-5 w-5 text-emerald-500" />
{t('illness.report')}
</DialogTitle>
<DialogDescription>
{role === 'STUDENT' ? t('illness.parent_approval_desc') : t('illness.auto_approved')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Student Selection (Parent only) */}
{role === 'PARENT' && children.length > 0 && (
<div>
<Label>{t('illness.children_reports')}</Label>
<Select value={selectedStudentId} onValueChange={setSelectedStudentId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{children.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.firstName} {c.lastName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Reason */}
<div>
<Label>{t('illness.reason')}</Label>
<Select value={reason} onValueChange={setReason}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="illness">{t('illness.illness')}</SelectItem>
<SelectItem value="doctor_visit">{t('illness.doctor_visit')}</SelectItem>
<SelectItem value="other">{t('illness.other')}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Date Range */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('illness.start_date')}</Label>
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
</div>
<div>
<Label>{t('illness.end_date')}</Label>
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
</div>
{/* Description */}
<div>
<Label>{t('illness.description')}</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('illness.description')}
rows={3}
/>
</div>
{/* Medical Certificate Upload placeholder */}
<div>
<Label>{t('illness.medical_certificate')}</Label>
<div className="mt-1 border-2 border-dashed border-emerald-300 dark:border-emerald-700 rounded-xl p-6 text-center text-muted-foreground hover:border-emerald-400 hover:bg-emerald-50/50 dark:hover:bg-emerald-950/20 transition-all duration-300 cursor-pointer group">
<div className="h-12 w-12 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform">
<Upload className="h-6 w-6 text-emerald-500" />
</div>
<p className="text-sm font-medium">{t('illness.upload_certificate')}</p>
<p className="text-xs text-muted-foreground mt-1">PDF, JPG, PNG</p>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('action.cancel')}</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !selectedStudentId || !startDate}
className="bg-emerald-500 hover:bg-emerald-600 text-white"
>
{isSubmitting ? <div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" /> : t('action.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large. Load diff
+881
View File
@@ -0,0 +1,881 @@
'use client';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Sparkles,
X,
Settings,
ChevronUp,
Star,
Heart,
Zap,
Moon,
Sun,
BookOpen,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { t } from '@/lib/i18n';
import { apiGet, apiPut } from '@/lib/api';
interface VirtualCharacterData {
id: string;
userId: string;
characterId: string;
name: string;
color: string;
level: number;
xp: number;
mood: string;
accessories: string | null;
}
const CHARACTER_TYPES = [
{ id: 'owl', nameKey: 'character.owl', icon: 'owl' },
{ id: 'dragon', nameKey: 'character.dragon', icon: 'dragon' },
{ id: 'robot', nameKey: 'character.robot', icon: 'robot' },
{ id: 'cat', nameKey: 'character.cat', icon: 'cat' },
{ id: 'wizard', nameKey: 'character.wizard', icon: 'wizard' },
];
const XP_PER_LEVEL = 100;
const MOODS = ['happy', 'excited', 'thinking', 'sleepy', 'celebrating'];
// ─── SVG Character Renderers ──────────────────────────────────────────
function OwlCharacter({ color, mood }: { color: string; mood: string }) {
const isBlinking = mood === 'thinking';
const isSleepy = mood === 'sleepy';
const isExcited = mood === 'excited' || mood === 'celebrating';
return (
<svg viewBox="0 0 100 100" className="w-full h-full">
{/* Body */}
<ellipse cx="50" cy="65" rx="28" ry="25" fill={color} opacity="0.9" />
<ellipse cx="50" cy="65" rx="22" ry="20" fill={`${color}33`} />
{/* Head */}
<circle cx="50" cy="38" r="20" fill={color} opacity="0.95" />
{/* Ear tufts */}
<polygon points="35,25 30,10 40,22" fill={color} />
<polygon points="65,25 70,10 60,22" fill={color} />
{/* Eyes */}
<circle cx="42" cy="36" r="8" fill="white" />
<circle cx="58" cy="36" r="8" fill="white" />
{!isBlinking && !isSleepy ? (
<>
<circle cx="43" cy="36" r="4" fill="#1a1a2e" />
<circle cx="59" cy="36" r="4" fill="#1a1a2e" />
<circle cx="44" cy="34" r="1.5" fill="white" />
<circle cx="60" cy="34" r="1.5" fill="white" />
</>
) : isSleepy ? (
<>
<line x1="38" y1="36" x2="46" y2="36" stroke="#1a1a2e" strokeWidth="2" strokeLinecap="round" />
<line x1="54" y1="36" x2="62" y2="36" stroke="#1a1a2e" strokeWidth="2" strokeLinecap="round" />
</>
) : (
<>
<ellipse cx="42" cy="36" rx="6" ry="2" fill="#1a1a2e" />
<ellipse cx="58" cy="36" rx="6" ry="2" fill="#1a1a2e" />
</>
)}
{/* Beak */}
<polygon points="47,42 50,47 53,42" fill="#f59e0b" />
{/* Wings */}
<motion.ellipse
cx="25"
cy="62"
rx="10"
ry="15"
fill={color}
opacity="0.8"
animate={isExcited ? { rotate: [0, -10, 0], y: [0, -3, 0] } : {}}
style={{ transformOrigin: '35px 62px' }}
/>
<motion.ellipse
cx="75"
cy="62"
rx="10"
ry="15"
fill={color}
opacity="0.8"
animate={isExcited ? { rotate: [0, 10, 0], y: [0, -3, 0] } : {}}
style={{ transformOrigin: '65px 62px' }}
/>
{/* Feet */}
<ellipse cx="40" cy="88" rx="8" ry="3" fill="#f59e0b" />
<ellipse cx="60" cy="88" rx="8" ry="3" fill="#f59e0b" />
{/* Celebration sparkles */}
{mood === 'celebrating' && (
<>
<motion.circle cx="15" cy="20" r="2" fill="#fbbf24" animate={{ opacity: [1, 0, 1], scale: [1, 0.5, 1] }} transition={{ repeat: Infinity, duration: 1 }} />
<motion.circle cx="85" cy="15" r="2" fill="#fbbf24" animate={{ opacity: [0, 1, 0], scale: [0.5, 1, 0.5] }} transition={{ repeat: Infinity, duration: 1.2 }} />
<motion.circle cx="80" cy="45" r="1.5" fill="#fbbf24" animate={{ opacity: [1, 0, 1], scale: [1, 0.5, 1] }} transition={{ repeat: Infinity, duration: 0.8 }} />
</>
)}
</svg>
);
}
function DragonCharacter({ color, mood }: { color: string; mood: string }) {
const isExcited = mood === 'excited' || mood === 'celebrating';
const isSleepy = mood === 'sleepy';
return (
<svg viewBox="0 0 100 100" className="w-full h-full">
{/* Body */}
<ellipse cx="50" cy="62" rx="25" ry="22" fill={color} opacity="0.9" />
<ellipse cx="50" cy="62" rx="18" ry="16" fill={`${color}33`} />
{/* Head */}
<circle cx="50" cy="35" r="18" fill={color} opacity="0.95" />
{/* Horns */}
<polygon points="38,22 32,5 42,18" fill="#92400e" />
<polygon points="62,22 68,5 58,18" fill="#92400e" />
{/* Eyes */}
<circle cx="42" cy="33" r="6" fill="white" />
<circle cx="58" cy="33" r="6" fill="white" />
{isSleepy ? (
<>
<line x1="38" y1="33" x2="46" y2="33" stroke="#1a1a2e" strokeWidth="2" strokeLinecap="round" />
<line x1="54" y1="33" x2="62" y2="33" stroke="#1a1a2e" strokeWidth="2" strokeLinecap="round" />
</>
) : (
<>
<circle cx="43" cy="33" r="3.5" fill="#1a1a2e" />
<circle cx="59" cy="33" r="3.5" fill="#1a1a2e" />
<circle cx="44" cy="31" r="1.2" fill="white" />
<circle cx="60" cy="31" r="1.2" fill="white" />
</>
)}
{/* Nostrils */}
<circle cx="46" cy="40" r="1.5" fill={`${color}99`} />
<circle cx="54" cy="40" r="1.5" fill={`${color}99`} />
{/* Mouth */}
{mood === 'happy' || isExcited ? (
<path d="M 44 44 Q 50 50 56 44" fill="none" stroke="#1a1a2e" strokeWidth="1.5" strokeLinecap="round" />
) : (
<line x1="44" y1="44" x2="56" y2="44" stroke="#1a1a2e" strokeWidth="1.5" strokeLinecap="round" />
)}
{/* Wings */}
<motion.path
d="M 25 50 Q 10 35 5 50 Q 15 45 20 55"
fill={`${color}88`}
animate={isExcited ? { y: [0, -4, 0] } : {}}
transition={{ repeat: Infinity, duration: 0.5 }}
/>
<motion.path
d="M 75 50 Q 90 35 95 50 Q 85 45 80 55"
fill={`${color}88`}
animate={isExcited ? { y: [0, -4, 0] } : {}}
transition={{ repeat: Infinity, duration: 0.5 }}
/>
{/* Tail */}
<motion.path
d="M 50 82 Q 60 88 70 85 Q 80 82 75 78"
fill="none"
stroke={color}
strokeWidth="4"
strokeLinecap="round"
animate={isExcited ? { d: ['M 50 82 Q 60 88 70 85 Q 80 82 75 78', 'M 50 82 Q 60 92 70 89 Q 80 86 75 82', 'M 50 82 Q 60 88 70 85 Q 80 82 75 78'] } : {}}
transition={{ repeat: Infinity, duration: 0.8 }}
/>
{/* Belly */}
<ellipse cx="50" cy="65" rx="14" ry="14" fill={`${color}44`} />
{/* Feet */}
<ellipse cx="38" cy="82" rx="7" ry="3" fill={color} />
<ellipse cx="62" cy="82" rx="7" ry="3" fill={color} />
{/* Fire breath when excited */}
{mood === 'celebrating' && (
<motion.g animate={{ opacity: [0.5, 1, 0.5] }} transition={{ repeat: Infinity, duration: 0.6 }}>
<circle cx="50" cy="48" r="3" fill="#f59e0b" />
<circle cx="50" cy="52" r="2" fill="#ef4444" />
</motion.g>
)}
</svg>
);
}
function RobotCharacter({ color, mood }: { color: string; mood: string }) {
const isThinking = mood === 'thinking';
const isSleepy = mood === 'sleepy';
const isExcited = mood === 'excited' || mood === 'celebrating';
return (
<svg viewBox="0 0 100 100" className="w-full h-full">
{/* Antenna */}
<line x1="50" y1="15" x2="50" y2="8" stroke="#6b7280" strokeWidth="2" />
<motion.circle
cx="50"
cy="6"
r="4"
fill={isExcited ? '#fbbf24' : color}
animate={isExcited ? { scale: [1, 1.3, 1] } : isThinking ? { opacity: [1, 0.3, 1] } : {}}
transition={{ repeat: Infinity, duration: 0.8 }}
/>
{/* Head */}
<rect x="28" y="15" width="44" height="30" rx="8" fill="#6b7280" />
<rect x="30" y="17" width="40" height="26" rx="6" fill="#9ca3af" />
{/* Eyes */}
<rect x="36" y="24" width="10" height="10" rx="2" fill="#1a1a2e" />
<rect x="54" y="24" width="10" height="10" rx="2" fill="#1a1a2e" />
{isSleepy ? (
<>
<line x1="38" y1="29" x2="44" y2="29" stroke="#4ade80" strokeWidth="2" />
<line x1="56" y1="29" x2="62" y2="29" stroke="#4ade80" strokeWidth="2" />
</>
) : (
<>
<rect x="38" y="26" width="6" height="6" rx="1" fill={color} />
<rect x="56" y="26" width="6" height="6" rx="1" fill={color} />
</>
)}
{/* Mouth */}
{mood === 'happy' || isExcited ? (
<rect x="42" y="38" width="16" height="4" rx="2" fill={color} />
) : (
<rect x="44" y="38" width="12" height="2" rx="1" fill="#4b5563" />
)}
{/* Body */}
<rect x="25" y="50" width="50" height="30" rx="8" fill="#6b7280" />
<rect x="27" y="52" width="46" height="26" rx="6" fill="#9ca3af" />
{/* Chest display */}
<motion.circle
cx="50"
cy="65"
r="8"
fill={color}
animate={isThinking ? { scale: [1, 1.2, 1], opacity: [0.5, 1, 0.5] } : {}}
transition={{ repeat: Infinity, duration: 1.5 }}
/>
<circle cx="50" cy="65" r="4" fill={`${color}66`} />
{/* Arms */}
<rect x="12" y="52" width="13" height="8" rx="4" fill="#6b7280" />
<rect x="75" y="52" width="13" height="8" rx="4" fill="#6b7280" />
<motion.rect
x="8"
y="58"
width="8"
height="8"
rx="4"
fill="#9ca3af"
animate={isExcited ? { y: [58, 55, 58] } : {}}
transition={{ repeat: Infinity, duration: 0.5 }}
/>
<motion.rect
x="84"
y="58"
width="8"
height="8"
rx="4"
fill="#9ca3af"
animate={isExcited ? { y: [58, 55, 58] } : {}}
transition={{ repeat: Infinity, duration: 0.5 }}
/>
{/* Legs */}
<rect x="33" y="80" width="10" height="10" rx="3" fill="#6b7280" />
<rect x="57" y="80" width="10" height="10" rx="3" fill="#6b7280" />
<rect x="31" y="88" width="14" height="6" rx="3" fill="#4b5563" />
<rect x="55" y="88" width="14" height="6" rx="3" fill="#4b5563" />
</svg>
);
}
function CatCharacter({ color, mood }: { color: string; mood: string }) {
const isExcited = mood === 'excited' || mood === 'celebrating';
const isSleepy = mood === 'sleepy';
const isThinking = mood === 'thinking';
return (
<svg viewBox="0 0 100 100" className="w-full h-full">
{/* Body */}
<ellipse cx="50" cy="65" rx="24" ry="22" fill={color} opacity="0.9" />
<ellipse cx="50" cy="65" rx="17" ry="17" fill={`${color}33`} />
{/* Head */}
<circle cx="50" cy="38" r="18" fill={color} opacity="0.95" />
{/* Ears */}
<polygon points="35,25 28,8 42,22" fill={color} />
<polygon points="65,25 72,8 58,22" fill={color} />
<polygon points="37,24 31,12 41,22" fill="#fca5a5" />
<polygon points="63,24 69,12 59,22" fill="#fca5a5" />
{/* Eyes */}
<circle cx="42" cy="36" r="6" fill="white" />
<circle cx="58" cy="36" r="6" fill="white" />
{isSleepy ? (
<>
<line x1="38" y1="36" x2="46" y2="36" stroke="#1a1a2e" strokeWidth="2" strokeLinecap="round" />
<line x1="54" y1="36" x2="62" y2="36" stroke="#1a1a2e" strokeWidth="2" strokeLinecap="round" />
</>
) : isThinking ? (
<>
<ellipse cx="42" cy="36" rx="4" ry="2" fill="#1a1a2e" />
<ellipse cx="58" cy="36" rx="4" ry="2" fill="#1a1a2e" />
</>
) : (
<>
<ellipse cx="42" cy="36" rx="3" ry="5" fill="#1a1a2e" />
<ellipse cx="58" cy="36" rx="3" ry="5" fill="#1a1a2e" />
<circle cx="43" cy="34" r="1.2" fill="white" />
<circle cx="59" cy="34" r="1.2" fill="white" />
</>
)}
{/* Nose */}
<polygon points="48,42 50,44 52,42" fill="#fca5a5" />
{/* Mouth */}
{mood === 'happy' || isExcited ? (
<path d="M 44 46 Q 50 52 56 46" fill="none" stroke="#1a1a2e" strokeWidth="1" strokeLinecap="round" />
) : (
<path d="M 46 46 Q 50 48 54 46" fill="none" stroke="#1a1a2e" strokeWidth="1" strokeLinecap="round" />
)}
{/* Whiskers */}
<line x1="18" y1="38" x2="38" y2="40" stroke="#6b7280" strokeWidth="0.8" />
<line x1="18" y1="44" x2="38" y2="43" stroke="#6b7280" strokeWidth="0.8" />
<line x1="82" y1="38" x2="62" y2="40" stroke="#6b7280" strokeWidth="0.8" />
<line x1="82" y1="44" x2="62" y2="43" stroke="#6b7280" strokeWidth="0.8" />
{/* Tail */}
<motion.path
d="M 72 70 Q 85 55 80 45"
fill="none"
stroke={color}
strokeWidth="5"
strokeLinecap="round"
animate={isExcited ? { d: ['M 72 70 Q 85 55 80 45', 'M 72 70 Q 88 50 82 40', 'M 72 70 Q 85 55 80 45'] } : {}}
transition={{ repeat: Infinity, duration: 0.6 }}
/>
{/* Paws */}
<ellipse cx="38" cy="85" rx="8" ry="4" fill={color} />
<ellipse cx="62" cy="85" rx="8" ry="4" fill={color} />
{/* Celebration stars */}
{mood === 'celebrating' && (
<>
<motion.circle cx="15" cy="25" r="2" fill="#fbbf24" animate={{ opacity: [1, 0, 1] }} transition={{ repeat: Infinity, duration: 1 }} />
<motion.circle cx="85" cy="20" r="2" fill="#fbbf24" animate={{ opacity: [0, 1, 0] }} transition={{ repeat: Infinity, duration: 1.2 }} />
</>
)}
</svg>
);
}
function WizardCharacter({ color, mood }: { color: string; mood: string }) {
const isExcited = mood === 'excited' || mood === 'celebrating';
const isSleepy = mood === 'sleepy';
return (
<svg viewBox="0 0 100 100" className="w-full h-full">
{/* Hat */}
<polygon points="50,5 30,35 70,35" fill="#4b5563" />
<polygon points="50,5 35,35 65,35" fill="#6b7280" />
<rect x="25" y="33" width="50" height="6" rx="3" fill={color} />
<motion.circle
cx="50"
cy="12"
r="3"
fill="#fbbf24"
animate={isExcited ? { scale: [1, 1.5, 1], opacity: [0.5, 1, 0.5] } : {}}
transition={{ repeat: Infinity, duration: 1 }}
/>
{/* Head */}
<circle cx="50" cy="45" r="14" fill="#fbbf24" opacity="0.3" />
<circle cx="50" cy="45" r="14" fill="#fde68a" />
{/* Eyes */}
<circle cx="44" cy="43" r="4" fill="white" />
<circle cx="56" cy="43" r="4" fill="white" />
{isSleepy ? (
<>
<line x1="41" y1="43" x2="47" y2="43" stroke="#1a1a2e" strokeWidth="1.5" strokeLinecap="round" />
<line x1="53" y1="43" x2="59" y2="43" stroke="#1a1a2e" strokeWidth="1.5" strokeLinecap="round" />
</>
) : (
<>
<circle cx="44" cy="43" r="2.5" fill="#1a1a2e" />
<circle cx="56" cy="43" r="2.5" fill="#1a1a2e" />
<circle cx="45" cy="42" r="1" fill="white" />
<circle cx="57" cy="42" r="1" fill="white" />
</>
)}
{/* Beard */}
<path d="M 42 50 Q 50 65 58 50" fill="#9ca3af" />
{/* Nose */}
<circle cx="50" cy="47" r="2" fill="#f59e0b" />
{/* Robe */}
<path d="M 30 60 L 25 90 L 75 90 L 70 60 Q 50 55 30 60" fill={color} opacity="0.9" />
<path d="M 35 60 L 32 90 L 68 90 L 65 60 Q 50 57 35 60" fill={`${color}44`} />
{/* Belt */}
<rect x="30" y="62" width="40" height="5" rx="2" fill="#92400e" />
<rect x="46" y="60" width="8" height="8" rx="2" fill="#fbbf24" />
{/* Wand */}
<motion.line
x1="75"
y1="60"
x2="85"
y2="45"
stroke="#92400e"
strokeWidth="2"
strokeLinecap="round"
animate={isExcited ? { rotate: [0, 5, -5, 0] } : {}}
style={{ transformOrigin: '75px 60px' }}
/>
<motion.circle
cx="86"
cy="44"
r="3"
fill={color}
animate={isExcited ? { scale: [1, 1.5, 1], opacity: [0.5, 1, 0.5] } : {}}
transition={{ repeat: Infinity, duration: 0.8 }}
/>
{/* Magic sparkles */}
{mood === 'celebrating' && (
<>
<motion.circle cx="20" cy="30" r="1.5" fill="#fbbf24" animate={{ opacity: [1, 0, 1], y: [30, 25, 30] }} transition={{ repeat: Infinity, duration: 1 }} />
<motion.circle cx="80" cy="25" r="1.5" fill="#fbbf24" animate={{ opacity: [0, 1, 0], y: [25, 20, 25] }} transition={{ repeat: Infinity, duration: 1.2 }} />
<motion.circle cx="90" cy="50" r="1" fill={color} animate={{ opacity: [1, 0, 1] }} transition={{ repeat: Infinity, duration: 0.8 }} />
<motion.circle cx="15" cy="55" r="1" fill={color} animate={{ opacity: [0, 1, 0] }} transition={{ repeat: Infinity, duration: 1.1 }} />
</>
)}
{/* Hands */}
<circle cx="30" cy="68" r="4" fill="#fde68a" />
<circle cx="70" cy="68" r="4" fill="#fde68a" />
</svg>
);
}
function CharacterRenderer({ characterId, color, mood }: { characterId: string; color: string; mood: string }) {
switch (characterId) {
case 'owl':
return <OwlCharacter color={color} mood={mood} />;
case 'dragon':
return <DragonCharacter color={color} mood={mood} />;
case 'robot':
return <RobotCharacter color={color} mood={mood} />;
case 'cat':
return <CatCharacter color={color} mood={mood} />;
case 'wizard':
return <WizardCharacter color={color} mood={mood} />;
default:
return <OwlCharacter color={color} mood={mood} />;
}
}
// ─── Main Component ──────────────────────────────────────────────────
export default function VirtualCharacter({ userRole }: { userRole: string }) {
// Don't show for admin/teacher roles
if (userRole === 'SCHOOL_ADMIN' || userRole === 'SUPER_ADMIN' || userRole === 'TEACHER') {
return null;
}
return <VirtualCharacterInner />;
}
function VirtualCharacterInner() {
const [character, setCharacter] = useState<VirtualCharacterData | null>(null);
const [enabled, setEnabled] = useState(true);
const [isExpanded, setIsExpanded] = useState(false);
const [showCustomize, setShowCustomize] = useState(false);
const [speechBubble, setSpeechBubble] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editColor, setEditColor] = useState('#10b981');
const [editCharacterId, setEditCharacterId] = useState('owl');
// Load character data
useEffect(() => {
async function loadCharacter() {
try {
const data = await apiGet<{ character: VirtualCharacterData; enabled: boolean }>(
'/api/ai/character'
);
setCharacter(data.character);
setEnabled(data.enabled);
setEditName(data.character.name);
setEditColor(data.character.color);
setEditCharacterId(data.character.characterId);
} catch {
setEnabled(false);
}
}
loadCharacter();
}, []);
// Show idle speech bubbles periodically
useEffect(() => {
if (!character || !enabled) return;
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return t('character.greeting_morning');
if (hour < 18) return t('character.greeting_afternoon');
return t('character.greeting_evening');
};
// Show initial greeting after 3 seconds
const initialTimer = setTimeout(() => {
setSpeechBubble(getGreeting());
setTimeout(() => setSpeechBubble(null), 5000);
}, 3000);
// Show idle messages every 60 seconds
const interval = setInterval(() => {
const messages = [
t('character.idle_message_1'),
t('character.idle_message_2'),
t('character.idle_message_3'),
];
const randomMsg = messages[Math.floor(Math.random() * messages.length)];
setSpeechBubble(randomMsg);
setTimeout(() => setSpeechBubble(null), 4000);
}, 60000);
return () => {
clearTimeout(initialTimer);
clearInterval(interval);
};
}, [character, enabled]);
// Update character mood periodically
useEffect(() => {
if (!character || !enabled) return;
const moodInterval = setInterval(() => {
const hour = new Date().getHours();
let newMood = character.mood;
if (hour >= 22 || hour < 6) {
newMood = 'sleepy';
} else if (Math.random() > 0.7) {
const randomMoods = ['happy', 'thinking', 'excited'];
newMood = randomMoods[Math.floor(Math.random() * randomMoods.length)];
}
if (newMood !== character.mood) {
setCharacter((prev) => (prev ? { ...prev, mood: newMood } : prev));
}
}, 30000);
return () => clearInterval(moodInterval);
}, [character, enabled]);
const handleSaveCharacter = useCallback(async () => {
try {
const data = await apiPut<VirtualCharacterData>('/api/ai/character', {
characterId: editCharacterId,
name: editName,
color: editColor,
});
setCharacter(data);
setShowCustomize(false);
} catch {
// Ignore save errors
}
}, [editCharacterId, editName, editColor]);
const xpProgress = character ? (character.xp % XP_PER_LEVEL) / XP_PER_LEVEL * 100 : 0;
const xpToNext = character ? XP_PER_LEVEL - (character.xp % XP_PER_LEVEL) : XP_PER_LEVEL;
// Get mood icon
const moodIcon = useMemo(() => {
switch (character?.mood) {
case 'excited':
case 'celebrating':
return <Zap className="h-3 w-3" />;
case 'thinking':
return <Sparkles className="h-3 w-3" />;
case 'sleepy':
return <Moon className="h-3 w-3" />;
default:
return <Heart className="h-3 w-3" />;
}
}, [character?.mood]);
if (!enabled || !character) return null;
return (
<TooltipProvider>
<div className="fixed bottom-4 left-4 z-40 sm:bottom-6 sm:left-6">
{/* Speech Bubble */}
<AnimatePresence>
{speechBubble && !isExpanded && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.9 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="absolute bottom-20 left-0 max-w-[200px] bg-white dark:bg-gray-800 rounded-xl px-3 py-2 shadow-lg border border-emerald-200/60 dark:border-emerald-800/30 text-xs text-gray-700 dark:text-gray-300"
>
<p className="animate-pulse">{speechBubble}</p>
<div className="absolute -bottom-1.5 left-4 w-3 h-3 bg-white dark:bg-gray-800 border-r border-b border-emerald-200/60 dark:border-emerald-800/30 transform rotate-45" />
</motion.div>
)}
</AnimatePresence>
{/* Expanded Panel */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="absolute bottom-16 left-0 w-[280px] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl shadow-emerald-500/10 border border-emerald-200/60 dark:border-emerald-800/40 overflow-hidden"
>
{/* Character Display */}
<div className="p-4 bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-950/30 dark:to-teal-950/30">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-200">
{character.name}
</h3>
<Badge className="h-5 text-[10px] bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border-0 px-1.5">
{t('character.level')} {character.level}
</Badge>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400 hover:text-emerald-600"
onClick={() => setShowCustomize(!showCustomize)}
>
<Settings className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('character.customize')}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400 hover:text-gray-600"
onClick={() => setIsExpanded(false)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Character SVG */}
<motion.div
className="w-24 h-24 mx-auto mb-3"
animate={
character.mood === 'happy'
? { y: [0, -2, 0] }
: character.mood === 'sleepy'
? { rotate: [0, 2, -2, 0] }
: {}
}
transition={{
repeat: Infinity,
duration: 2,
ease: 'easeInOut',
}}
>
<CharacterRenderer
characterId={character.characterId}
color={character.color}
mood={character.mood}
/>
</motion.div>
{/* Mood Badge */}
<div className="flex items-center justify-center gap-1.5 mb-2">
<Badge
className="text-[10px] border-0 px-2"
style={{ backgroundColor: `${character.color}22`, color: character.color }}
>
{moodIcon}
<span className="ml-1">
{t(`character.mood_${character.mood}` as keyof typeof t)}
</span>
</Badge>
</div>
{/* XP Progress */}
<div className="space-y-1">
<div className="flex items-center justify-between text-[10px] text-gray-500 dark:text-gray-400">
<span>{t('character.xp')}: {character.xp}</span>
<span>{t('character.xp_to_next', { xp: String(xpToNext) })}</span>
</div>
<div className="relative h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<motion.div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-emerald-400 to-teal-400 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${xpProgress}%` }}
transition={{ duration: 0.8, ease: 'easeOut' }}
/>
{/* Shimmer effect */}
<motion.div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-transparent via-white/30 to-transparent rounded-full"
initial={{ x: '-100%' }}
animate={{ x: '200%' }}
transition={{ repeat: Infinity, duration: 2, ease: 'linear' }}
style={{ width: '50%' }}
/>
</div>
</div>
</div>
{/* Customize Panel */}
<AnimatePresence>
{showCustomize && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden border-t border-emerald-200/60 dark:border-emerald-800/30"
>
<div className="p-4 space-y-3">
{/* Character Selection */}
<div>
<Label className="text-xs text-gray-600 dark:text-gray-400 mb-1.5 block">
{t('character.select')}
</Label>
<div className="flex gap-1.5">
{CHARACTER_TYPES.map((ct) => (
<Button
key={ct.id}
variant={editCharacterId === ct.id ? 'default' : 'outline'}
size="sm"
className={`h-8 text-[10px] px-2 ${
editCharacterId === ct.id
? 'bg-emerald-500 hover:bg-emerald-600 text-white'
: 'border-emerald-200/50 dark:border-emerald-800/30 text-gray-600 dark:text-gray-400'
}`}
onClick={() => setEditCharacterId(ct.id)}
>
{t(ct.nameKey)}
</Button>
))}
</div>
</div>
{/* Name */}
<div>
<Label className="text-xs text-gray-600 dark:text-gray-400 mb-1 block">
{t('character.name')}
</Label>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-8 text-xs border-emerald-200/50 dark:border-emerald-800/30"
/>
</div>
{/* Color */}
<div>
<Label className="text-xs text-gray-600 dark:text-gray-400 mb-1 block">
{t('character.color')}
</Label>
<div className="flex items-center gap-2">
<input
type="color"
value={editColor}
onChange={(e) => setEditColor(e.target.value)}
className="w-8 h-8 rounded-lg border border-emerald-200/50 dark:border-emerald-800/30 cursor-pointer"
/>
<Input
value={editColor}
onChange={(e) => setEditColor(e.target.value)}
className="h-8 text-xs border-emerald-200/50 dark:border-emerald-800/30 flex-1"
/>
</div>
</div>
{/* Save Button */}
<Button
onClick={handleSaveCharacter}
className="w-full h-8 text-xs bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white"
>
{t('character.save')}
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
{/* Floating Character Button */}
<motion.div
className="relative"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Button
onClick={() => setIsExpanded(!isExpanded)}
className="h-14 w-14 rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 shadow-lg shadow-emerald-500/30 hover:shadow-emerald-500/50 transition-all duration-300 p-0 overflow-hidden"
>
<div className="w-10 h-10">
<CharacterRenderer
characterId={character.characterId}
color={character.color}
mood={character.mood}
/>
</div>
</Button>
{/* Level badge */}
<Badge className="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center bg-amber-500 text-white text-[10px] border-2 border-white dark:border-gray-900 font-bold">
{character.level}
</Badge>
</motion.div>
</div>
</TooltipProvider>
);
}