feat: add analytics visibility settings and export functionality
All checks were successful
Build & Push Docker Image / build (push) Successful in 5m11s
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.
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { BarChart3, Trash2, Clock, Users, MessageSquare, Video, Mic, ChevronDown, ChevronUp, Hand, BarChart2 } from 'lucide-react';
|
||||
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 }) {
|
||||
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) => {
|
||||
@@ -49,6 +50,34 @@ export default function AnalyticsList({ analytics, onRefresh }) {
|
||||
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) => {
|
||||
@@ -124,6 +153,38 @@ export default function AnalyticsList({ analytics, onRefresh }) {
|
||||
</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"
|
||||
@@ -131,6 +192,7 @@ export default function AnalyticsList({ analytics, onRefresh }) {
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => handleDelete(entry.id)}
|
||||
disabled={loading[entry.id] === 'deleting'}
|
||||
@@ -139,6 +201,7 @@ export default function AnalyticsList({ analytics, onRefresh }) {
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -258,7 +258,11 @@
|
||||
"defaultWelcome": "Willkommen zum Meeting!",
|
||||
"analytics": "Lernanalyse",
|
||||
"enableAnalytics": "Lernanalyse aktivieren",
|
||||
"enableAnalyticsHint": "Sammelt Engagement-Daten der Teilnehmer nach jedem Meeting."
|
||||
"enableAnalyticsHint": "Sammelt Engagement-Daten der Teilnehmer nach jedem Meeting.",
|
||||
"analyticsVisibility": "Wer kann die Analyse sehen?",
|
||||
"analyticsOwnerOnly": "Nur Raumbesitzer",
|
||||
"analyticsSharedUsers": "Alle geteilten Benutzer",
|
||||
"analyticsVisibilityHint": "Legt fest, wer die Analysedaten dieses Raums einsehen und exportieren kann."
|
||||
},
|
||||
"recordings": {
|
||||
"title": "Aufnahmen",
|
||||
@@ -295,7 +299,10 @@
|
||||
"duration": "Dauer",
|
||||
"meetingDuration": "Meeting-Dauer",
|
||||
"raiseHand": "Handheben",
|
||||
"reactions": "Reaktionen"
|
||||
"reactions": "Reaktionen",
|
||||
"export": "Herunterladen",
|
||||
"exportSuccess": "Download gestartet",
|
||||
"exportFailed": "Fehler beim Herunterladen"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
|
||||
@@ -258,7 +258,11 @@
|
||||
"defaultWelcome": "Welcome to the meeting!",
|
||||
"analytics": "Learning Analytics",
|
||||
"enableAnalytics": "Enable learning analytics",
|
||||
"enableAnalyticsHint": "Collects participant engagement data after each meeting."
|
||||
"enableAnalyticsHint": "Collects participant engagement data after each meeting.",
|
||||
"analyticsVisibility": "Who can see analytics?",
|
||||
"analyticsOwnerOnly": "Room owner only",
|
||||
"analyticsSharedUsers": "All shared users",
|
||||
"analyticsVisibilityHint": "Controls who can view and export analytics data for this room."
|
||||
},
|
||||
"recordings": {
|
||||
"title": "Recordings",
|
||||
@@ -295,7 +299,10 @@
|
||||
"duration": "Duration",
|
||||
"meetingDuration": "Meeting duration",
|
||||
"raiseHand": "Raise hand",
|
||||
"reactions": "Reactions"
|
||||
"reactions": "Reactions",
|
||||
"export": "Download",
|
||||
"exportSuccess": "Download started",
|
||||
"exportFailed": "Error downloading data"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
|
||||
@@ -196,6 +196,7 @@ export default function RoomDetail() {
|
||||
guest_access: !!editRoom.guest_access,
|
||||
moderator_code: editRoom.moderator_code,
|
||||
learning_analytics: !!editRoom.learning_analytics,
|
||||
analytics_visibility: editRoom.analytics_visibility || 'owner',
|
||||
});
|
||||
setRoom(res.data.room);
|
||||
setEditRoom(res.data.room);
|
||||
@@ -344,7 +345,7 @@ export default function RoomDetail() {
|
||||
const tabs = [
|
||||
{ id: 'overview', label: t('room.overview'), icon: Play },
|
||||
{ id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.length },
|
||||
{ id: 'analytics', label: t('room.analytics'), icon: BarChart3, count: analytics.length },
|
||||
{ id: 'analytics', label: t('room.analytics'), icon: BarChart3, count: analytics.length, hidden: !room.learning_analytics || (isShared && room.analytics_visibility !== 'shared') },
|
||||
...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []),
|
||||
];
|
||||
|
||||
@@ -451,7 +452,7 @@ export default function RoomDetail() {
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 mb-6 border-b border-th-border">
|
||||
{tabs.map(tab => (
|
||||
{tabs.filter(tab => !tab.hidden).map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
@@ -543,7 +544,7 @@ export default function RoomDetail() {
|
||||
)}
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} />
|
||||
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} isOwner={isOwner} />
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && isOwner && editRoom && (
|
||||
@@ -648,6 +649,20 @@ export default function RoomDetail() {
|
||||
/>
|
||||
<span className="text-sm text-th-text">{t('room.enableAnalytics')}</span>
|
||||
</label>
|
||||
{!!editRoom.learning_analytics && (
|
||||
<div className="ml-7">
|
||||
<label className="block text-xs font-medium text-th-text-s mb-1">{t('room.analyticsVisibility')}</label>
|
||||
<select
|
||||
value={editRoom.analytics_visibility || 'owner'}
|
||||
onChange={e => setEditRoom({ ...editRoom, analytics_visibility: e.target.value })}
|
||||
className="input-field text-sm py-1.5 max-w-xs"
|
||||
>
|
||||
<option value="owner">{t('room.analyticsOwnerOnly')}</option>
|
||||
<option value="shared">{t('room.analyticsSharedUsers')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-th-text-s mt-1">{t('room.analyticsVisibilityHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Guest access section */}
|
||||
|
||||
Reference in New Issue
Block a user