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:
1 parent
04ae7aa80c
commit
f1776932d7
48 files changed
+13559
-4
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user