All checks were successful
Build & Push Docker Image / build (push) Successful in 5m11s
- Added `analytics_visibility` column to `rooms` table to control who can view analytics data. - Updated analytics routes to check visibility settings before allowing access and export of analytics data. - Implemented export functionality for analytics in CSV, XLSX, and PDF formats. - Enhanced `AnalyticsList` component to include export options for analytics entries. - Updated room detail page to allow setting analytics visibility when creating or editing rooms. - Added translations for new analytics visibility options and export messages.
262 lines
11 KiB
JavaScript
262 lines
11 KiB
JavaScript
import { BarChart3, Trash2, Clock, Users, MessageSquare, Video, Mic, ChevronDown, ChevronUp, Hand, BarChart2, Download } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import api from '../services/api';
|
|
import { useLanguage } from '../contexts/LanguageContext';
|
|
import toast from 'react-hot-toast';
|
|
|
|
export default function AnalyticsList({ analytics, onRefresh, isOwner = true }) {
|
|
const [loading, setLoading] = useState({});
|
|
const [expanded, setExpanded] = useState({});
|
|
const [exportMenu, setExportMenu] = useState({});
|
|
const { t, language } = useLanguage();
|
|
|
|
const formatDate = (dateStr) => {
|
|
if (!dateStr) return '—';
|
|
return new Date(dateStr).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
};
|
|
|
|
const formatDurationSec = (sec) => {
|
|
if (!sec || sec <= 0) return '0m';
|
|
const minutes = Math.floor(sec / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
const mins = minutes % 60;
|
|
const secs = sec % 60;
|
|
if (hours > 0) return `${hours}h ${mins}m`;
|
|
if (minutes > 0) return `${mins}m ${secs}s`;
|
|
return `${secs}s`;
|
|
};
|
|
|
|
const handleDelete = async (id) => {
|
|
if (!confirm(t('analytics.deleteConfirm'))) return;
|
|
setLoading(prev => ({ ...prev, [id]: 'deleting' }));
|
|
try {
|
|
await api.delete(`/analytics/${id}`);
|
|
toast.success(t('analytics.deleted'));
|
|
onRefresh?.();
|
|
} catch {
|
|
toast.error(t('analytics.deleteFailed'));
|
|
} finally {
|
|
setLoading(prev => ({ ...prev, [id]: null }));
|
|
}
|
|
};
|
|
|
|
const toggleExpand = (id) => {
|
|
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
|
};
|
|
|
|
const toggleExportMenu = (id) => {
|
|
setExportMenu(prev => ({ ...prev, [id]: !prev[id] }));
|
|
};
|
|
|
|
const handleExport = async (id, format) => {
|
|
setExportMenu(prev => ({ ...prev, [id]: false }));
|
|
setLoading(prev => ({ ...prev, [id]: 'exporting' }));
|
|
try {
|
|
const response = await api.get(`/analytics/${id}/export/${format}`, { responseType: 'blob' });
|
|
const disposition = response.headers['content-disposition'];
|
|
const match = disposition?.match(/filename="?([^"]+)"?/);
|
|
const filename = match?.[1] || `analytics.${format}`;
|
|
const url = window.URL.createObjectURL(response.data);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
window.URL.revokeObjectURL(url);
|
|
toast.success(t('analytics.exportSuccess'));
|
|
} catch {
|
|
toast.error(t('analytics.exportFailed'));
|
|
} finally {
|
|
setLoading(prev => ({ ...prev, [id]: null }));
|
|
}
|
|
};
|
|
|
|
// Extract user summary from BBB learning analytics callback data
|
|
// Payload: { meeting_id, data: { duration, start, finish, attendees: [{ name, moderator, duration, engagement: { chats, talks, raisehand, emojis, poll_votes, talk_time } }] } }
|
|
const getUserSummary = (data) => {
|
|
const attendees = data?.data?.attendees;
|
|
if (!Array.isArray(attendees)) return [];
|
|
return attendees.map(a => ({
|
|
name: a.name || '—',
|
|
isModerator: !!a.moderator,
|
|
duration: a.duration || 0,
|
|
talkTime: a.engagement?.talk_time || 0,
|
|
chats: a.engagement?.chats || 0,
|
|
talks: a.engagement?.talks || 0,
|
|
raiseHand: a.engagement?.raisehand || 0,
|
|
emojis: a.engagement?.emojis || 0,
|
|
pollVotes: a.engagement?.poll_votes || 0,
|
|
}));
|
|
};
|
|
|
|
const getMeetingSummary = (data) => ({
|
|
duration: data?.data?.duration || 0,
|
|
start: data?.data?.start || null,
|
|
finish: data?.data?.finish || null,
|
|
files: data?.data?.files || [],
|
|
polls: data?.data?.polls || [],
|
|
});
|
|
|
|
if (!analytics || analytics.length === 0) {
|
|
return (
|
|
<div className="text-center py-12">
|
|
<BarChart3 size={48} className="mx-auto text-th-text-s/40 mb-3" />
|
|
<p className="text-th-text-s text-sm">{t('analytics.noData')}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{analytics.map(entry => {
|
|
const users = getUserSummary(entry.data);
|
|
const meeting = getMeetingSummary(entry.data);
|
|
const isExpanded = expanded[entry.id];
|
|
const totalParticipants = users.length;
|
|
const totalMessages = users.reduce((sum, u) => sum + u.chats, 0);
|
|
|
|
return (
|
|
<div key={entry.id} className="card p-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h4 className="text-sm font-medium text-th-text truncate">
|
|
{entry.meetingName || entry.meetingId}
|
|
</h4>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-th-text-s">
|
|
<span className="flex items-center gap-1">
|
|
<Clock size={12} />
|
|
{meeting.start ? formatDate(meeting.start) : formatDate(entry.createdAt)}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<BarChart2 size={12} />
|
|
{formatDurationSec(meeting.duration)}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Users size={12} />
|
|
{totalParticipants} {t('analytics.participants')}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<MessageSquare size={12} />
|
|
{totalMessages} {t('analytics.messages')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => toggleExportMenu(entry.id)}
|
|
disabled={loading[entry.id] === 'exporting'}
|
|
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
|
|
title={t('analytics.export')}
|
|
>
|
|
<Download size={16} />
|
|
</button>
|
|
{exportMenu[entry.id] && (
|
|
<div className="absolute right-0 top-full mt-1 bg-th-bg border border-th-border rounded-lg shadow-lg z-10 min-w-[120px] py-1">
|
|
<button
|
|
onClick={() => handleExport(entry.id, 'csv')}
|
|
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
|
>
|
|
CSV
|
|
</button>
|
|
<button
|
|
onClick={() => handleExport(entry.id, 'xlsx')}
|
|
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
|
>
|
|
Excel (.xlsx)
|
|
</button>
|
|
<button
|
|
onClick={() => handleExport(entry.id, 'pdf')}
|
|
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
|
>
|
|
PDF
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => toggleExpand(entry.id)}
|
|
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
|
|
title={isExpanded ? t('analytics.collapse') : t('analytics.expand')}
|
|
>
|
|
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
</button>
|
|
{isOwner && (
|
|
<button
|
|
onClick={() => handleDelete(entry.id)}
|
|
disabled={loading[entry.id] === 'deleting'}
|
|
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors"
|
|
title={t('common.delete')}
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isExpanded && users.length > 0 && (
|
|
<div className="mt-4 border-t border-th-border pt-4">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-xs text-th-text-s border-b border-th-border">
|
|
<th className="pb-2 pr-4 font-medium">{t('analytics.userName')}</th>
|
|
<th className="pb-2 pr-4 font-medium">{t('analytics.role')}</th>
|
|
<th className="pb-2 pr-4 font-medium">
|
|
<span className="flex items-center gap-1"><Clock size={11} />{t('analytics.duration')}</span>
|
|
</th>
|
|
<th className="pb-2 pr-4 font-medium">
|
|
<span className="flex items-center gap-1"><Mic size={11} />{t('analytics.talkTime')}</span>
|
|
</th>
|
|
<th className="pb-2 pr-4 font-medium">
|
|
<span className="flex items-center gap-1"><MessageSquare size={11} />{t('analytics.messages')}</span>
|
|
</th>
|
|
<th className="pb-2 pr-4 font-medium">
|
|
<span className="flex items-center gap-1"><Hand size={11} />{t('analytics.raiseHand')}</span>
|
|
</th>
|
|
<th className="pb-2 font-medium">{t('analytics.reactions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((u, i) => (
|
|
<tr key={i} className="border-b border-th-border/50 last:border-0">
|
|
<td className="py-2 pr-4 text-th-text font-medium">{u.name}</td>
|
|
<td className="py-2 pr-4">
|
|
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${
|
|
u.isModerator
|
|
? 'bg-th-accent/15 text-th-accent'
|
|
: 'bg-th-bg-s text-th-text-s'
|
|
}`}>
|
|
{u.isModerator ? t('analytics.moderator') : t('analytics.viewer')}
|
|
</span>
|
|
</td>
|
|
<td className="py-2 pr-4 text-th-text-s">{formatDurationSec(u.duration)}</td>
|
|
<td className="py-2 pr-4 text-th-text-s">{formatDurationSec(u.talkTime)}</td>
|
|
<td className="py-2 pr-4 text-th-text-s">{u.chats}</td>
|
|
<td className="py-2 pr-4 text-th-text-s">{u.raiseHand}</td>
|
|
<td className="py-2 text-th-text-s">{u.emojis}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|