Files
schulos/src/components/communication-view.tsx
T
Mustafa Cetin f1776932d7 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
2026-07-29 20:09:03 +00:00

900 lines
32 KiB
TypeScript

'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>
);
}