Files
dictia-public/templates/admin.html

3690 lines
232 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<title>{% if is_group_admin_only %}Group Management{% else %}Admin Dashboard{% endif %} - DictIA</title>
<!-- Loading overlay to prevent FOUC - MUST be first -->
{% include 'includes/loading_overlay.html' %}
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
<!-- All dependencies bundled locally for offline support -->
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
<!-- All dependencies bundled locally for offline support -->
<script src="{{ url_for('static', filename='vendor/js/vue.global.js') }}"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<!-- All dependencies bundled locally for offline support -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<script src="{{ url_for('static', filename='js/i18n.js') }}"></script>
<style>
/* Hide Vue content until compiled */
[v-cloak] {
display: none !important;
}
/* Hide scrollbar for tabs */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Custom select dropdown styling */
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
padding-right: 2.5rem !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 12px 12px;
}
.dark select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23aaa' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
}
</style>
<script>
// Initialize i18n
let t = (key) => key; // Default fallback
async function initializeI18n() {
const userLanguage = "{{ user_language }}";
// Initialize the global i18n instance
if (window.i18n) {
await window.i18n.init(userLanguage);
// Create a shorthand translation function
t = (key, params) => window.i18n.t(key, params);
// Update all elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
// Update all elements with data-i18n-placeholder attribute
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = t(key);
});
}
}
// Function to apply the theme based on localStorage
function applyTheme() {
// Guard against early execution
if (!document.documentElement) return;
// Apply dark mode
const savedMode = localStorage.getItem('darkMode');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Apply color scheme
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
const isDark = document.documentElement.classList.contains('dark');
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
// Remove all other theme classes
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
themeClasses.forEach(theme => {
document.documentElement.classList.remove(`theme-light-${theme}`);
document.documentElement.classList.remove(`theme-dark-${theme}`);
});
// Add the correct theme class
if (savedScheme !== 'blue') {
document.documentElement.classList.add(themePrefix + savedScheme);
}
}
applyTheme();
</script>
</head>
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
<div id="app" v-cloak class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
DictIA
</a>
</h1>
<div class="flex items-center space-x-2">
<button @click="toggleDarkMode" class="p-2 rounded-full text-[var(--text-muted)] hover:bg-[var(--bg-tertiary)] dark:text-gray-400 dark:hover:bg-gray-700 transition-colors duration-200" :title="isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'">
<i :class="isDarkMode ? 'fas fa-sun' : 'fas fa-moon'"></i>
</button>
<div class="relative">
<button @click="isUserMenuOpen = !isUserMenuOpen"
data-user-menu-toggle
class="flex items-center px-3 py-2 border border-[var(--border-secondary)] rounded-lg text-[var(--text-secondary)] hover:text-[var(--text-accent)] focus:outline-none">
<i :class="isTeamAdminOnly ? 'fas fa-users-cog mr-2' : 'fas fa-user-shield mr-2'"></i>
<span>{{ current_user.username }}</span>
<i class="fas fa-chevron-down ml-2"></i>
</button>
<div v-if="isUserMenuOpen"
data-user-menu-dropdown
class="absolute right-0 mt-2 w-48 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-lg z-10">
<a href="{{ url_for('recordings.index') }}" class="block px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-accent)]">
<i class="fas fa-home mr-2"></i> ${t('nav.home')}
</a>
<a href="{{ url_for('auth.account') }}" class="block px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-accent)]">
<i class="fas fa-user mr-2"></i> ${t('nav.account')}
</a>
<a href="{{ url_for('admin.admin') }}" class="block px-4 py-2 text-[var(--text-accent)] bg-[var(--bg-accent)]">
<i :class="isTeamAdminOnly ? 'fas fa-users-cog mr-2' : 'fas fa-user-shield mr-2'"></i>
<span v-text="isTeamAdminOnly ? t('nav.groupManagement') : t('nav.admin')"></span>
</a>
<a href="{{ url_for('auth.logout') }}" class="block px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-danger)]">
<i class="fas fa-sign-out-alt mr-2"></i> ${t('nav.signOut')}
</a>
</div>
</div>
</div>
</header>
<main class="flex-grow">
<div class="bg-[var(--bg-secondary)] p-4 sm:p-6 lg:p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6" v-text="isTeamAdminOnly ? t('nav.groupManagement') : t('nav.adminDashboard')"></h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Tabs -->
<div class="border-b border-[var(--border-primary)] mb-6">
<div class="relative">
<!-- Scroll gradient indicators -->
<div class="absolute left-0 top-0 bottom-0 w-12 bg-gradient-to-r from-[var(--bg-secondary)] to-transparent z-[1] pointer-events-none opacity-0 transition-opacity duration-200 flex items-center justify-start" id="admin-left-scroll-indicator">
<i class="fas fa-chevron-left text-[var(--text-muted)] ml-1"></i>
</div>
<div class="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-[var(--bg-secondary)] to-transparent z-[1] pointer-events-none opacity-100 transition-opacity duration-200 flex items-center justify-end" id="admin-right-scroll-indicator">
<i class="fas fa-chevron-right text-[var(--text-muted)] mr-1"></i>
</div>
<nav class="tab-nav px-1 sm:px-4 lg:px-6 pt-3 sm:pt-4 lg:pt-6 pb-4 sm:pb-6 -mb-px overflow-x-auto scrollbar-hide" aria-label="Tabs" id="admin-tabs-container">
<div class="inline-flex gap-1 sm:gap-2">
<button v-if="!isTeamAdminOnly" @click="activeTab = 'users'"
:class="activeTab === 'users' ? 'border-[var(--border-accent)] text-[var(--text-accent)]' : 'border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'"
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-users mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span>${t('adminDashboard.userManagement')}</span>
</button>
<button v-if="!isTeamAdminOnly" @click="activeTab = 'stats'"
:class="activeTab === 'stats' ? 'border-[var(--border-accent)] text-[var(--text-accent)]' : 'border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'"
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-chart-bar mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span>${t('adminDashboard.systemStatistics')}</span>
</button>
<button v-if="!isTeamAdminOnly" @click="activeTab = 'settings'"
:class="activeTab === 'settings' ? 'border-[var(--border-accent)] text-[var(--text-accent)]' : 'border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'"
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-cogs mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span>${t('adminDashboard.systemSettings')}</span>
</button>
<button v-if="!isTeamAdminOnly" @click="activeTab = 'prompts'"
:class="activeTab === 'prompts' ? 'border-[var(--border-accent)] text-[var(--text-accent)]' : 'border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'"
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-file-alt mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span>${t('adminDashboard.defaultPrompts')}</span>
</button>
<button @click="activeTab = 'groups'"
:class="activeTab === 'groups' ? 'border-[var(--border-accent)] text-[var(--text-accent)]' : 'border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'"
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-users-cog mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span>${t('adminDashboard.groupsTab')}</span>
</button>
<button v-if="!isTeamAdminOnly && inquireModeEnabled" @click="activeTab = 'vectorstore'"
:class="activeTab === 'vectorstore' ? 'border-[var(--border-accent)] text-[var(--text-accent)]' : 'border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'"
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-database mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span>${t('adminDashboard.vectorStore')}</span>
</button>
<button v-if="!isTeamAdminOnly" @click="activeTab = 'audit'"
:class="activeTab === 'audit' ? 'border-[var(--border-accent)] text-[var(--text-accent)]' : 'border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'"
class="inline-flex items-center whitespace-nowrap py-1 px-1.5 sm:py-3 sm:px-3 border-b-2 font-medium text-xs sm:text-sm rounded-t-md sm:rounded-t-lg hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-clipboard-list mr-0.5 sm:mr-2 text-xs sm:text-sm"></i><span>Journal d'audit</span>
</button>
</div>
</nav>
</div>
</div>
<!-- User Management Tab -->
<div v-show="activeTab === 'users'">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-[var(--text-secondary)]">${t('adminDashboard.userManagement')}</h3>
<button @click="showAddUserModal = true" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg shadow hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] focus:ring-offset-2 transition duration-150 ease-in-out">
<i class="fas fa-user-plus mr-2"></i> ${t('adminDashboard.addUser')}
</button>
</div>
<!-- Search and Filter -->
<div class="mb-4 flex">
<div class="relative flex-grow">
<input type="text" v-model="userSearchQuery" :placeholder="t('adminDashboard.searchUsers')" class="w-full px-4 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<button v-if="userSearchQuery" @click="userSearchQuery = ''" class="absolute right-3 top-2.5 text-[var(--text-muted)] hover:text-[var(--text-secondary)]">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- Users Table -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--bg-tertiary)]">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">${t('adminDashboard.id')}</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">${t('adminDashboard.username')}</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">${t('adminDashboard.email')}</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">${t('adminDashboard.admin')}</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">${t('adminDashboard.publicShare')}</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">${t('adminDashboard.recordings')}</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">${t('adminDashboard.storageUsed')}</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">${t('adminDashboard.actions')}</th>
</tr>
</thead>
<tbody class="bg-[var(--bg-secondary)] divide-y divide-[var(--border-primary)]">
<tr v-for="user in filteredUsers" :key="user.id" class="hover:bg-[var(--bg-tertiary)]">
<td class="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-secondary)]">${ user.id }</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-[var(--text-primary)]">${ user.username }</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-secondary)]">${ user.email }</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-secondary)]">
<span v-if="user.is_admin" class="px-2 inline-flex items-center text-xs leading-5 font-semibold rounded-full bg-[var(--bg-success-light)] text-[var(--text-success-strong)]">Yes</span>
<span v-else class="px-2 inline-flex items-center text-xs leading-5 font-semibold rounded-full bg-[var(--bg-pending-light)] text-[var(--text-pending-strong)]">No</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-secondary)]">
<button @click="togglePublicSharingPermission(user)"
class="inline-flex items-center"
:title="user.can_share_publicly ? 'Revoke public sharing permission' : 'Grant public sharing permission'">
<span v-if="user.can_share_publicly" class="px-2 inline-flex items-center text-xs leading-5 font-semibold rounded-full bg-[var(--bg-success-light)] text-[var(--text-success-strong)]">
<i class="fas fa-check mr-1"></i> ${t('adminDashboard.allowed')}
</span>
<span v-else class="px-2 inline-flex items-center text-xs leading-5 font-semibold rounded-full bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]">
<i class="fas fa-times mr-1"></i> ${t('adminDashboard.blocked')}
</span>
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-secondary)]">${ user.recordings_count }</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-secondary)]">${ formatFileSize(user.storage_used) }</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-secondary)]">
<div class="flex space-x-2">
<button @click="editUser(user)" class="text-[var(--text-accent)] hover:text-[var(--text-accent-hover)]" :title="t('buttons.editUser')">
<i class="fas fa-edit"></i>
</button>
<button @click="toggleAdminStatus(user)" :class="user.is_admin ? 'text-[var(--text-warn-strong)]' : 'text-[var(--text-info-strong)]'" :title="user.is_admin ? 'Remove Admin' : 'Make Admin'">
<i class="fas" :class="user.is_admin ? 'fa-user-minus' : 'fa-user-shield'"></i>
</button>
<button @click="confirmDeleteUser(user)" class="text-[var(--text-danger)] hover:text-[var(--text-danger-hover)]" :title="t('buttons.deleteUser')">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
<tr v-if="filteredUsers.length === 0">
<td colspan="8" class="px-6 py-4 text-center text-sm text-[var(--text-muted)]">
<div v-if="isLoadingUsers">
<i class="fas fa-spinner fa-spin mr-2"></i> Loading users...
</div>
<div v-else>
No users found.
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- System Statistics Tab -->
<div v-show="activeTab === 'stats'">
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-4">${t('adminDashboard.systemStatistics')}</h3>
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] shadow-sm">
<div class="flex items-center">
<div class="p-3 rounded-full bg-[var(--bg-info-light)] text-[var(--text-info-strong)]">
<i class="fas fa-users text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-[var(--text-muted)]">${t('adminDashboard.totalUsers')}</p>
<p class="text-2xl font-semibold text-[var(--text-primary)]">${ stats.total_users }</p>
</div>
</div>
</div>
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] shadow-sm">
<div class="flex items-center">
<div class="p-3 rounded-full bg-[var(--bg-success-light)] text-[var(--text-success-strong)]">
<i class="fas fa-file-audio text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-[var(--text-muted)]">${t('adminDashboard.totalRecordings')}</p>
<p class="text-2xl font-semibold text-[var(--text-primary)]">${ stats.total_recordings }</p>
</div>
</div>
</div>
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] shadow-sm">
<div class="flex items-center">
<div class="p-3 rounded-full bg-[var(--bg-warn-light)] text-[var(--text-warn-strong)]">
<i class="fas fa-database text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-[var(--text-muted)]">${t('adminDashboard.totalStorage')}</p>
<p class="text-2xl font-semibold text-[var(--text-primary)]">${ formatFileSize(stats.total_storage) }</p>
</div>
</div>
</div>
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] shadow-sm">
<div class="flex items-center">
<div class="p-3 rounded-full bg-[var(--bg-accent)] text-[var(--text-accent)]">
<i class="fas fa-comments text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-[var(--text-muted)]">${t('adminDashboard.totalQueries')}</p>
<p class="text-2xl font-semibold text-[var(--text-primary)]">${ stats.total_queries }</p>
</div>
</div>
</div>
</div>
<!-- Status Distribution -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] shadow-sm">
<h4 class="text-md font-medium text-[var(--text-secondary)] mb-3">${t('adminDashboard.recordingStatusDistribution')}</h4>
<div class="grid grid-cols-2 gap-4">
<div class="text-center p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
<span class="block text-2xl font-bold text-[var(--text-success-strong)]">${ stats.completed_recordings }</span>
<span class="block text-sm text-[var(--text-muted)]">${t('adminDashboard.completedRecordings')}</span>
</div>
<div class="text-center p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
<span class="block text-2xl font-bold text-[var(--text-warn-strong)]">${ stats.processing_recordings }</span>
<span class="block text-sm text-[var(--text-muted)]">${t('adminDashboard.processingRecordings')}</span>
</div>
<div class="text-center p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
<span class="block text-2xl font-bold text-[var(--text-pending-strong)]">${ stats.pending_recordings }</span>
<span class="block text-sm text-[var(--text-muted)]">${t('adminDashboard.pendingRecordings')}</span>
</div>
<div class="text-center p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] shadow-sm">
<span class="block text-2xl font-bold text-[var(--text-danger-strong)]">${ stats.failed_recordings }</span>
<span class="block text-sm text-[var(--text-muted)]">${t('adminDashboard.failedRecordings')}</span>
</div>
</div>
</div>
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] shadow-sm">
<h4 class="text-md font-medium text-[var(--text-secondary)] mb-3">${t('adminDashboard.topUsersByStorage')}</h4>
<div class="space-y-3">
<div v-for="user in stats.top_users" :key="user.id" class="flex justify-between items-center p-2 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
<div class="flex items-center">
<i class="fas fa-user text-[var(--text-accent)] mr-2"></i>
<span class="text-sm font-medium text-[var(--text-primary)]">${ user.username }</span>
</div>
<div class="text-sm text-[var(--text-secondary)]">
<span class="font-medium">${ formatFileSize(user.storage_used) }</span>
<span class="text-[var(--text-muted)] ml-2">(${ user.recordings_count} recordings)</span>
</div>
</div>
<div v-if="stats.top_users.length === 0" class="text-center text-sm text-[var(--text-muted)] p-2">
No data available
</div>
</div>
</div>
</div>
<!-- Token Usage Section -->
<div class="mt-6">
<h4 class="text-lg font-medium text-[var(--text-secondary)] mb-4">
<i class="fas fa-coins mr-2"></i> Token Usage Statistics
</h4>
<!-- Token Stats and Per-User Usage Side by Side -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Token Usage Summary Cards (2x2 grid) -->
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] shadow-sm">
<h5 class="text-md font-medium text-[var(--text-secondary)] mb-3">Usage Summary</h5>
<div class="grid grid-cols-2 gap-3">
<div class="p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
<div class="flex items-center">
<div class="p-2 rounded-full bg-[var(--bg-info-light)] text-[var(--text-info-strong)]">
<i class="fas fa-calendar-day"></i>
</div>
<div class="ml-3">
<p class="text-xs text-[var(--text-muted)]">Today</p>
<p class="text-lg font-semibold text-[var(--text-primary)]">${ tokenStats.today?.tokens?.toLocaleString() || 0 }</p>
</div>
</div>
</div>
<div class="p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
<div class="flex items-center">
<div class="p-2 rounded-full bg-[var(--bg-success-light)] text-[var(--text-success-strong)]">
<i class="fas fa-calendar-alt"></i>
</div>
<div class="ml-3">
<p class="text-xs text-[var(--text-muted)]">This Month</p>
<p class="text-lg font-semibold text-[var(--text-primary)]">${ tokenStats.current_month?.tokens?.toLocaleString() || 0 }</p>
</div>
</div>
</div>
<div class="p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
<div class="flex items-center">
<div class="p-2 rounded-full bg-[var(--bg-warn-light)] text-[var(--text-warn-strong)]">
<i class="fas fa-dollar-sign"></i>
</div>
<div class="ml-3">
<p class="text-xs text-[var(--text-muted)]">Monthly Cost</p>
<p class="text-lg font-semibold text-[var(--text-primary)]">$${ (tokenStats.current_month?.cost || 0).toFixed(4) }</p>
</div>
</div>
</div>
<div class="p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
<div class="flex items-center">
<div class="p-2 rounded-full" :class="tokenStats.users_at_100_percent > 0 ? 'bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]' : 'bg-[var(--bg-accent)] text-[var(--text-accent)]'">
<i class="fas fa-exclamation-triangle"></i>
</div>
<div class="ml-3">
<p class="text-xs text-[var(--text-muted)]">Warnings</p>
<p class="text-lg font-semibold">
<span v-if="tokenStats.users_at_100_percent > 0" class="text-[var(--text-danger-strong)]">${ tokenStats.users_at_100_percent } blocked</span>
<span v-else-if="tokenStats.users_over_80_percent > 0" class="text-[var(--text-warn-strong)]">${ tokenStats.users_over_80_percent } warning</span>
<span v-else class="text-[var(--text-success-strong)]">None</span>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Per-User Token Usage -->
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] shadow-sm">
<h5 class="text-md font-medium text-[var(--text-secondary)] mb-3">User Token Usage (This Month)</h5>
<div class="space-y-2 max-h-48 overflow-y-auto">
<div v-for="user in tokenUserStats" :key="user.user_id" class="p-2 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
<div class="flex justify-between items-center">
<div class="flex items-center">
<i class="fas fa-user text-[var(--text-accent)] mr-2 text-xs"></i>
<span class="text-sm font-medium text-[var(--text-primary)]">${ user.username }</span>
</div>
<div class="text-sm text-right">
<span class="font-semibold" :class="{
'text-[var(--text-danger-strong)]': user.percentage >= 100,
'text-[var(--text-warn-strong)]': user.percentage >= 80 && user.percentage < 100,
'text-[var(--text-primary)]': !user.monthly_budget || user.percentage < 80
}">${ user.current_usage?.toLocaleString() || 0 }</span>
<span v-if="user.monthly_budget" class="text-[var(--text-muted)] text-xs"> / ${ user.monthly_budget?.toLocaleString() }</span>
<span v-else class="text-[var(--text-muted)] text-xs ml-1">(unlimited)</span>
</div>
</div>
<div v-if="user.monthly_budget" class="mt-1.5">
<div class="w-full bg-[var(--bg-tertiary)] rounded-full h-1">
<div class="h-1 rounded-full transition-all"
:class="{
'bg-[var(--text-danger-strong)]': user.percentage >= 100,
'bg-[var(--text-warn-strong)]': user.percentage >= 80 && user.percentage < 100,
'bg-[var(--text-success-strong)]': user.percentage < 80
}"
:style="{width: Math.min(user.percentage || 0, 100) + '%'}">
</div>
</div>
</div>
</div>
<div v-if="tokenUserStats.length === 0" class="text-center text-sm text-[var(--text-muted)] p-4">
No token usage data available
</div>
</div>
</div>
</div>
<!-- Token Usage Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Daily Usage Chart -->
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] shadow-sm">
<h5 class="text-md font-medium text-[var(--text-secondary)] mb-3">Daily Token Usage (Last 30 Days)</h5>
<div class="h-64">
<canvas id="dailyTokenChart"></canvas>
</div>
</div>
<!-- Monthly Usage Chart -->
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] shadow-sm">
<h5 class="text-md font-medium text-[var(--text-secondary)] mb-3">Monthly Token Usage (Last 12 Months)</h5>
<div class="h-64">
<canvas id="monthlyTokenChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Transcription Usage Section -->
<div class="mt-8">
<h4 class="text-lg font-medium text-[var(--text-secondary)] mb-4">
<i class="fas fa-microphone mr-2"></i>${t('adminDashboard.transcriptionUsage')}
</h4>
<!-- Transcription Stats and Per-User Usage Side by Side -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Transcription Usage Summary Cards (2x2 grid) -->
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] shadow-sm">
<h5 class="text-md font-medium text-[var(--text-secondary)] mb-3">Usage Summary</h5>
<div class="grid grid-cols-2 gap-3">
<div class="p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
<div class="flex items-center">
<div class="p-2 rounded-full bg-[var(--bg-info-light)] text-[var(--text-info-strong)]">
<i class="fas fa-calendar-day"></i>
</div>
<div class="ml-3">
<p class="text-xs text-[var(--text-muted)]">${t('adminDashboard.todaysMinutes')}</p>
<p class="text-lg font-semibold text-[var(--text-primary)]">${ transcriptionStats.today?.minutes || 0 }</p>
</div>
</div>
</div>
<div class="p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
<div class="flex items-center">
<div class="p-2 rounded-full bg-[var(--bg-success-light)] text-[var(--text-success-strong)]">
<i class="fas fa-calendar-alt"></i>
</div>
<div class="ml-3">
<p class="text-xs text-[var(--text-muted)]">${t('adminDashboard.thisMonth')}</p>
<p class="text-lg font-semibold text-[var(--text-primary)]">${ transcriptionStats.current_month?.minutes || 0 } min</p>
</div>
</div>
</div>
<div class="p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
<div class="flex items-center">
<div class="p-2 rounded-full bg-[var(--bg-warn-light)] text-[var(--text-warn-strong)]">
<i class="fas fa-dollar-sign"></i>
</div>
<div class="ml-3">
<p class="text-xs text-[var(--text-muted)]">${t('adminDashboard.monthlyCost')}</p>
<p class="text-lg font-semibold text-[var(--text-primary)]">$${ (transcriptionStats.current_month?.cost || 0).toFixed(4) }</p>
</div>
</div>
</div>
<div class="p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
<div class="flex items-center">
<div class="p-2 rounded-full" :class="transcriptionStats.users_at_100_percent > 0 ? 'bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]' : 'bg-[var(--bg-accent)] text-[var(--text-accent)]'">
<i class="fas fa-exclamation-triangle"></i>
</div>
<div class="ml-3">
<p class="text-xs text-[var(--text-muted)]">${t('adminDashboard.budgetWarnings')}</p>
<p class="text-lg font-semibold">
<span v-if="transcriptionStats.users_at_100_percent > 0" class="text-[var(--text-danger-strong)]">${ transcriptionStats.users_at_100_percent } ${t('adminDashboard.blocked')}</span>
<span v-else-if="transcriptionStats.users_over_80_percent > 0" class="text-[var(--text-warn-strong)]">${ transcriptionStats.users_over_80_percent } ${t('adminDashboard.warning')}</span>
<span v-else class="text-[var(--text-success-strong)]">${t('adminDashboard.none')}</span>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Per-User Transcription Usage -->
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] shadow-sm">
<h5 class="text-md font-medium text-[var(--text-secondary)] mb-3">${t('adminDashboard.userTranscriptionUsage')}</h5>
<div class="space-y-2 max-h-48 overflow-y-auto">
<div v-for="user in transcriptionUserStats" :key="user.user_id" class="p-2 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
<div class="flex justify-between items-center">
<div class="flex items-center">
<i class="fas fa-user text-[var(--text-accent)] mr-2 text-xs"></i>
<span class="text-sm font-medium text-[var(--text-primary)]">${ user.username }</span>
</div>
<div class="text-sm text-right">
<span class="font-semibold" :class="{
'text-[var(--text-danger-strong)]': user.percentage >= 100,
'text-[var(--text-warn-strong)]': user.percentage >= 80 && user.percentage < 100,
'text-[var(--text-primary)]': !user.monthly_budget_minutes || user.percentage < 80
}">${ user.current_usage_minutes || 0 } min</span>
<span v-if="user.monthly_budget_minutes" class="text-[var(--text-muted)] text-xs"> / ${ user.monthly_budget_minutes } min</span>
<span v-else class="text-[var(--text-muted)] text-xs ml-1">(unlimited)</span>
</div>
</div>
<div v-if="user.monthly_budget_minutes" class="mt-1.5">
<div class="w-full bg-[var(--bg-tertiary)] rounded-full h-1">
<div class="h-1 rounded-full transition-all"
:class="{
'bg-[var(--text-danger-strong)]': user.percentage >= 100,
'bg-[var(--text-warn-strong)]': user.percentage >= 80 && user.percentage < 100,
'bg-[var(--text-success-strong)]': user.percentage < 80
}"
:style="{width: Math.min(user.percentage || 0, 100) + '%'}">
</div>
</div>
</div>
</div>
<div v-if="transcriptionUserStats.length === 0" class="text-center text-sm text-[var(--text-muted)] p-4">
${t('adminDashboard.noTranscriptionData')}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- System Settings Tab -->
<div v-show="activeTab === 'settings'">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-[var(--text-secondary)]">${t('adminDashboard.systemSettings')}</h3>
<button @click="loadSettings" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg shadow hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] focus:ring-offset-2 transition duration-150 ease-in-out">
<i class="fas fa-sync-alt mr-2"></i> ${t('buttons.refresh')}
</button>
</div>
<!-- Settings List -->
<div class="space-y-4">
<div v-for="setting in settings" :key="setting.id" class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)] shadow-sm">
<div class="flex justify-between items-start">
<div class="flex-grow">
<h4 class="text-md font-medium text-[var(--text-primary)] mb-1">${ setting.key }</h4>
<p class="text-sm text-[var(--text-muted)] mb-2">${ getSettingDescription(setting) }</p>
<div class="flex items-center space-x-2">
<span class="text-xs px-2 py-1 bg-[var(--bg-accent)] text-[var(--text-accent)] rounded">${ setting.setting_type }</span>
<span class="text-xs text-[var(--text-muted)]">${t('adminDashboard.lastUpdated')}: ${ formatDate(setting.updated_at) }</span>
</div>
</div>
<div class="ml-4 flex items-center space-x-2">
<div class="text-right">
<div class="text-sm font-medium text-[var(--text-primary)]">
<span v-if="setting.setting_type === 'boolean'">
<span v-if="setting.value === 'true'" class="text-[var(--text-success-strong)]">
<i class="fas fa-check-circle mr-1"></i> Enabled
</span>
<span v-else class="text-[var(--text-danger-strong)]">
<i class="fas fa-times-circle mr-1"></i> Disabled
</span>
</span>
<span v-else-if="setting.key === 'transcript_length_limit' && setting.value === '-1'" class="text-[var(--text-info-strong)]">
<i class="fas fa-infinity mr-1"></i> ${t('adminDashboard.noLimit')}
</span>
<span v-else-if="setting.key === 'transcript_length_limit'" class="text-[var(--text-primary)]">
${ Number(setting.value).toLocaleString() } ${t('adminDashboard.characters')}
</span>
<span v-else-if="setting.key === 'max_file_size_mb'" class="text-[var(--text-primary)]">
${ Number(setting.value).toLocaleString() } ${t('adminDashboard.megabytes')}
</span>
<span v-else-if="setting.key === 'asr_timeout_seconds'" class="text-[var(--text-primary)]">
${ Number(setting.value).toLocaleString() } ${t('adminDashboard.seconds')}
</span>
<span v-else-if="setting.value && setting.value.length > 80" class="text-[var(--text-muted)] italic">${ setting.value.substring(0, 80) }...</span>
<span v-else>${ setting.value || t('adminDashboard.notSet') }</span>
</div>
</div>
<button @click="editSetting(setting)" class="text-[var(--text-accent)] hover:text-[var(--text-accent-hover)] p-1" :title="t('buttons.editSetting')">
<i class="fas fa-edit"></i>
</button>
</div>
</div>
</div>
<div v-if="settings.length === 0" class="text-center py-8">
<div v-if="isLoadingSettings">
<i class="fas fa-spinner fa-spin mr-2"></i> Loading settings...
</div>
<div v-else class="text-[var(--text-muted)]">
No system settings found.
</div>
</div>
</div>
</div>
<!-- Default Prompts Tab -->
<div v-show="activeTab === 'prompts'">
<div class="mb-6">
<h3 class="text-lg font-medium text-[var(--text-secondary)] mb-4">${t('adminDashboard.defaultPrompts')}</h3>
<div class="bg-[var(--bg-info-light)] text-[var(--text-info-strong)] p-4 rounded-lg mb-4">
<i class="fas fa-info-circle mr-2"></i>
${t('adminDashboard.defaultPromptInfo')}
</div>
</div>
<div class="bg-[var(--bg-tertiary)] p-6 rounded-lg border border-[var(--border-primary)]">
<div class="mb-4">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-2">
${t('adminDashboard.defaultSummarizationPrompt')}
</label>
<p class="text-sm text-[var(--text-muted)] mb-3">
${t('adminDashboard.promptDescription')}
</p>
<textarea
v-model="defaultSummaryPrompt"
rows="8"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
:placeholder="t('form.summaryPromptPlaceholder')">
</textarea>
</div>
<div class="flex justify-between items-center">
<div>
<button @click="resetDefaultPrompt"
class="px-4 py-2 border border-[var(--border-secondary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-tertiary)]">
<i class="fas fa-undo mr-2"></i> ${t('adminDashboard.resetToDefault')}
</button>
</div>
<div>
<button @click="saveDefaultPrompt"
:disabled="isSavingPrompt"
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] disabled:opacity-50 disabled:cursor-not-allowed">
<i v-if="isSavingPrompt" class="fas fa-spinner fa-spin mr-2"></i>
<i v-else class="fas fa-save mr-2"></i>
${ isSavingPrompt ? t('adminDashboard.saving') : t('buttons.saveChanges') }
</button>
</div>
</div>
<div v-if="promptSaveMessage" class="mt-4">
<div :class="promptSaveError ? 'bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]' : 'bg-[var(--bg-success-light)] text-[var(--text-success-strong)]'"
class="p-3 rounded-md text-sm">
${ promptSaveMessage }
</div>
</div>
</div>
<div class="mt-6 bg-[var(--bg-tertiary)] p-6 rounded-lg border border-[var(--border-primary)]">
<h4 class="text-md font-medium text-[var(--text-secondary)] mb-3">${t('adminDashboard.promptHierarchy')}</h4>
<p class="text-sm text-[var(--text-muted)] mb-3">
${t('adminDashboard.promptPriorityDescription')}
</p>
<ol class="list-decimal list-inside space-y-2 text-sm text-[var(--text-secondary)]">
<li><strong>${t('adminDashboard.tagCustomPrompt')}:</strong> ${t('adminDashboard.tagCustomPromptDesc')}</li>
<li><strong>Folder Custom Prompt:</strong> Applied when recording is in a folder with a custom prompt (if Folders feature enabled)</li>
<li><strong>${t('adminDashboard.userCustomPrompt')}:</strong> ${t('adminDashboard.userCustomPromptDesc')}</li>
<li><strong>${t('adminDashboard.adminDefaultPrompt')}:</strong> ${t('adminDashboard.adminDefaultPromptDesc')}</li>
<li><strong>${t('adminDashboard.systemFallback')}:</strong> ${t('adminDashboard.systemFallbackDesc')}</li>
</ol>
</div>
<!-- Full LLM Prompt Structure Expander -->
<div class="mt-6 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)]">
<button @click="showFullPromptStructure = !showFullPromptStructure"
class="w-full p-6 flex justify-between items-center hover:bg-[var(--bg-quaternary)] transition-colors duration-200">
<h4 class="text-md font-medium text-[var(--text-secondary)]">
<i class="fas fa-code mr-2"></i>
${t('adminDashboard.viewFullPromptStructure')}
</h4>
<i :class="showFullPromptStructure ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"
class="text-[var(--text-muted)]"></i>
</button>
<div v-show="showFullPromptStructure" class="border-t border-[var(--border-primary)]">
<div class="p-6 space-y-6">
<div>
<h5 class="text-sm font-semibold text-[var(--text-accent)] mb-2">${t('adminDashboard.systemPrompt')}:</h5>
<div class="bg-[var(--bg-secondary)] p-4 rounded-md border border-[var(--border-secondary)]">
<pre class="text-xs text-[var(--text-secondary)] whitespace-pre-wrap font-mono">You are an AI assistant that generates comprehensive summaries for meeting transcripts. Respond only with the summary in Markdown format. Do NOT use markdown code blocks (```markdown). Provide raw markdown content directly.
Context:
- Current date: {current_date}
- Tags applied to this transcript by the user: {tag_names} <span class="text-[var(--text-muted)]">(if tags exist)</span>
- Information about the user: Name: {name}, Job title: {job_title}, Company: {company} <span class="text-[var(--text-muted)]">(if provided)</span>
<span class="text-[var(--text-muted)]">Language Requirement: You MUST generate the entire summary in {user_output_language}. This is mandatory. (if language preference is set)</span></pre>
</div>
</div>
<div>
<h5 class="text-sm font-semibold text-[var(--text-accent)] mb-2">${t('adminDashboard.userMessageTemplate')}:</h5>
<div class="bg-[var(--bg-secondary)] p-4 rounded-md border border-[var(--border-secondary)]">
<pre class="text-xs text-[var(--text-secondary)] whitespace-pre-wrap font-mono">Transcription:
"""
{transcript_text}
"""
Summarization Instructions:
<span class="text-[var(--text-muted)]">/* This section is dynamically replaced with one of the following (in order of priority):
1. Combined tag prompts (if tags with custom prompts are selected)
2. User's personal summarization prompt (if set in account settings)
3. Admin default prompt (shown below)
4. System fallback prompt */</span>
<span v-if="defaultSummaryPrompt || originalDefaultPrompt">${ defaultSummaryPrompt || originalDefaultPrompt }</span>
<span class="text-[var(--text-muted)]">{language_directive} (e.g., "Ensure your response is in {language}." if language is set)</span></pre>
</div>
<p class="text-xs text-[var(--text-muted)] mt-2">
<i class="fas fa-info-circle mr-1"></i>
${t('adminDashboard.placeholdersNote')}
</p>
</div>
<div>
<h5 class="text-sm font-semibold text-[var(--text-accent)] mb-2">${t('adminDashboard.additionalContext')}:</h5>
<div class="bg-[var(--bg-info-light)] p-4 rounded-md">
<ul class="text-xs text-[var(--text-info-strong)] space-y-2">
<li><i class="fas fa-check-circle mr-1"></i> ${t('adminDashboard.contextNotes.transcriptLimit')}</li>
<li><i class="fas fa-check-circle mr-1"></i> ${t('adminDashboard.contextNotes.jsonConversion')}</li>
<li><i class="fas fa-check-circle mr-1"></i> ${t('adminDashboard.contextNotes.tagPrompts')}</li>
<li><i class="fas fa-check-circle mr-1"></i> ${t('adminDashboard.contextNotes.modelConfig')}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Vector Store Tab -->
<div v-show="activeTab === 'vectorstore'" v-if="inquireModeEnabled">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-medium text-[var(--text-secondary)]">${t('adminDashboard.vectorStoreManagement')}</h3>
<button @click="loadInquireStatus" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg shadow hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] focus:ring-offset-2 transition duration-150 ease-in-out">
<i class="fas fa-sync-alt mr-2"></i> ${t('adminDashboard.refreshStatus')}
</button>
</div>
<!-- Vector Store Info -->
<div class="bg-[var(--bg-tertiary)] p-6 rounded-lg border border-[var(--border-primary)] mb-6">
<h4 class="text-md font-semibold text-[var(--text-primary)] mb-4">
<i class="fas fa-info-circle mr-2"></i>${t('adminDashboard.aboutInquireMode')}
</h4>
<p class="text-sm text-[var(--text-secondary)] mb-3">
${t('adminDashboard.inquireModeDescription')}
</p>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-[var(--text-muted)]">${t('adminDashboard.chunkSize')}:</span>
<span class="ml-2 text-[var(--text-primary)]">500 ${t('adminDashboard.characters')}</span>
</div>
<div>
<span class="text-[var(--text-muted)]">${t('adminDashboard.overlap')}:</span>
<span class="ml-2 text-[var(--text-primary)]">50 ${t('adminDashboard.characters')}</span>
</div>
<div>
<span class="text-[var(--text-muted)]">${t('adminDashboard.embeddingModel')}:</span>
<span class="ml-2 text-[var(--text-primary)]">all-MiniLM-L6-v2</span>
</div>
<div>
<span class="text-[var(--text-muted)]">${t('adminDashboard.vectorDimensions')}:</span>
<span class="ml-2 text-[var(--text-primary)]">384</span>
</div>
</div>
</div>
<!-- Statistics Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg border border-[var(--border-primary)] shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-[var(--text-muted)]">${t('adminDashboard.totalRecordings')}</p>
<p class="text-2xl font-semibold text-[var(--text-primary)]">${ inquireStatus.total_completed_recordings || 0 }</p>
</div>
<i class="fas fa-file-audio text-3xl text-[var(--text-accent)] opacity-50"></i>
</div>
</div>
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg border border-[var(--border-primary)] shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-[var(--text-muted)]">${t('adminDashboard.processedForInquire')}</p>
<p class="text-2xl font-semibold text-[var(--text-success-strong)]">${ inquireStatus.processed_for_inquire || 0 }</p>
</div>
<i class="fas fa-check-circle text-3xl text-[var(--text-success-strong)] opacity-50"></i>
</div>
</div>
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg border border-[var(--border-primary)] shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-[var(--text-muted)]">${t('adminDashboard.needProcessing')}</p>
<p class="text-2xl font-semibold" :class="inquireStatus.need_processing > 0 ? 'text-[var(--text-warning-strong)]' : 'text-[var(--text-primary)]'">
${ inquireStatus.need_processing || 0 }
</p>
</div>
<i class="fas fa-clock text-3xl opacity-50" :class="inquireStatus.need_processing > 0 ? 'text-[var(--text-warning-strong)]' : 'text-[var(--text-muted)]'"></i>
</div>
</div>
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg border border-[var(--border-primary)] shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-[var(--text-muted)]">${t('adminDashboard.totalChunks')}</p>
<p class="text-2xl font-semibold text-[var(--text-primary)]">${ inquireStatus.total_chunks || 0 }</p>
</div>
<i class="fas fa-th text-3xl text-[var(--text-accent)] opacity-50"></i>
</div>
</div>
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg border border-[var(--border-primary)] shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-[var(--text-muted)]">${t('adminDashboard.embeddingsStatus')}</p>
<p class="text-lg font-semibold" :class="inquireStatus.embeddings_available ? 'text-[var(--text-success-strong)]' : 'text-[var(--text-warning-strong)]'">
<i :class="inquireStatus.embeddings_available ? 'fas fa-check-circle' : 'fas fa-exclamation-triangle'" class="mr-2"></i>
${ inquireStatus.embeddings_available ? t('adminDashboard.available') : t('adminDashboard.textSearchOnly') }
</p>
</div>
<i class="fas fa-brain text-3xl opacity-50" :class="inquireStatus.embeddings_available ? 'text-[var(--text-success-strong)]' : 'text-[var(--text-warning-strong)]'"></i>
</div>
</div>
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg border border-[var(--border-primary)] shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-[var(--text-muted)]">${t('adminDashboard.processingProgress')}</p>
<div class="mt-2">
<div class="w-full bg-[var(--bg-tertiary)] rounded-full h-2.5">
<div class="bg-[var(--text-accent)] h-2.5 rounded-full" :style="{width: getProcessingProgress() + '%'}"></div>
</div>
<p class="text-xs text-[var(--text-muted)] mt-1">${ getProcessingProgress() }% ${t('adminDashboard.complete')}</p>
</div>
</div>
<i class="fas fa-tasks text-3xl text-[var(--text-accent)] opacity-50"></i>
</div>
</div>
</div>
<!-- Processing Actions -->
<div class="bg-[var(--bg-tertiary)] p-6 rounded-lg border border-[var(--border-primary)]">
<h4 class="text-md font-semibold text-[var(--text-primary)] mb-4">
<i class="fas fa-cog mr-2"></i>${t('adminDashboard.processingActions')}
</h4>
<div v-if="inquireStatus.need_processing > 0" class="mb-4">
<p class="text-sm text-[var(--text-secondary)] mb-4">
${t('adminDashboard.recordingsNeedProcessing').replace('{{count}}', inquireStatus.need_processing)}
</p>
<div class="flex flex-wrap gap-3">
<button @click="processAllRecordings"
:disabled="isProcessingRecordings"
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg shadow hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] focus:ring-offset-2 transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed">
<i :class="isProcessingRecordings ? 'fas fa-spinner fa-spin' : 'fas fa-play'" class="mr-2"></i>
${ isProcessingRecordings ? t('adminDashboard.processing') : t('adminDashboard.processAllRecordings') }
</button>
<button @click="processBatchRecordings(10)"
:disabled="isProcessingRecordings"
class="px-4 py-2 bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-secondary)] rounded-lg shadow hover:bg-[var(--bg-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] focus:ring-offset-2 transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-forward mr-2"></i>
${t('adminDashboard.processNext10')}
</button>
</div>
<div v-if="processingResult" class="mt-4 p-4 rounded-lg"
:class="processingResult.success ? 'bg-[var(--bg-success-light)] text-[var(--text-success-strong)]' : 'bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]'">
<p class="font-medium">${ processingResult.message }</p>
<div v-if="processingResult.failed && processingResult.failed.length > 0" class="mt-2">
<p class="text-sm font-medium">Failed recordings:</p>
<ul class="text-xs mt-1 space-y-1">
<li v-for="fail in processingResult.failed" :key="fail.id">
ID ${ fail.id }: ${ fail.title } - ${ fail.reason }
</li>
</ul>
</div>
</div>
</div>
<div v-else class="text-center py-4">
<i class="fas fa-check-circle text-4xl text-[var(--text-success-strong)] mb-3"></i>
<p class="text-[var(--text-success-strong)] font-medium">${t('adminDashboard.allRecordingsProcessed')}</p>
<p class="text-sm text-[var(--text-muted)] mt-1">${t('adminDashboard.vectorStoreUpToDate')}</p>
</div>
</div>
</div>
<!-- Groups Tab -->
<div v-show="activeTab === 'groups'">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-[var(--text-secondary)]">Group Management</h3>
<button v-if="!isTeamAdminOnly" @click="openCreateTeamModal" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg shadow hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] focus:ring-offset-2 transition duration-150 ease-in-out">
<i class="fas fa-plus mr-2"></i> Create Group
</button>
</div>
<!-- Groups List -->
<div class="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] overflow-hidden">
<div v-if="groups.length > 0">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--bg-tertiary)]">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">${t('adminDashboard.groupName')}</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">${t('adminDashboard.description')}</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">${t('adminDashboard.members')}</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">${t('adminDashboard.created')}</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">${t('adminDashboard.actions')}</th>
</tr>
</thead>
<tbody class="bg-[var(--bg-secondary)] divide-y divide-[var(--border-primary)]">
<tr v-for="group in groups" :key="group.id" class="hover:bg-[var(--bg-tertiary)]">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-[var(--text-primary)]">${ group.name }</div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-[var(--text-secondary)]">${ group.description || t('adminDashboard.noDescription') }</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-[var(--text-secondary)]">
<i class="fas fa-users mr-1"></i>
${ group.member_count || 0 } ${t('adminDashboard.membersCount')}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-muted)]">
${ new Date(group.created_at).toLocaleDateString() }
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<button @click="openManageTeamModal(group)" class="text-blue-500 hover:text-blue-700" title="Manage Members">
<i class="fas fa-users-cog"></i>
</button>
<button @click="openManageTeamTagsModal(group)" class="text-purple-500 hover:text-purple-700" title="Manage Group Tags">
<i class="fas fa-tags"></i>
</button>
<button @click="openEditTeamModal(group)" class="text-[var(--text-accent)] hover:text-[var(--text-accent-hover)]" title="Edit Group">
<i class="fas fa-edit"></i>
</button>
<button v-if="!isTeamAdminOnly" @click="confirmDeleteTeam(group)" class="text-red-500 hover:text-red-700" title="Delete Group">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="p-8 text-center">
<i class="fas fa-users-slash text-4xl text-[var(--text-muted)] mb-3"></i>
<p class="text-[var(--text-muted)]" v-text="isTeamAdminOnly ? t('adminDashboard.noGroupsAdmin') : t('adminDashboard.noGroupsCreated')"></p>
<button v-if="!isTeamAdminOnly" @click="openCreateTeamModal" class="mt-4 px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)]">
<i class="fas fa-plus mr-2"></i> ${t('adminDashboard.createFirstGroup')}
</button>
</div>
</div>
</div>
<!-- Journal d'audit Tab -->
<div v-show="activeTab === 'audit'">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 gap-3">
<h3 class="text-lg font-medium text-[var(--text-secondary)]">
<i class="fas fa-clipboard-list mr-2 text-[var(--text-accent)]"></i>Journal d'audit
</h3>
<div class="flex gap-2">
<button @click="exportAuditCSV()" class="px-3 py-1.5 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border-secondary)] rounded-md hover:bg-[var(--bg-button)] hover:text-[var(--text-button)] transition-colors text-sm">
<i class="fas fa-download mr-1"></i> Exporter CSV
</button>
</div>
</div>
<!-- Audit disabled warning -->
<div v-if="!auditEnabled" class="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle text-amber-500 mr-3"></i>
<div>
<p class="text-sm font-medium text-amber-800">Journal d'audit désactivé</p>
<p class="text-xs text-amber-600 mt-1">Ajoutez <code>ENABLE_AUDIT_LOG=true</code> dans le fichier .env pour activer la traçabilité Loi 25.</p>
</div>
</div>
</div>
<!-- Sub-tabs -->
<div class="flex gap-2 mb-4">
<button @click="auditSubTab = 'access'; auditPage = 1; loadAuditData()"
:class="auditSubTab === 'access' ? 'bg-[var(--bg-button)] text-[var(--text-button)]' : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border-secondary)]'"
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors">
<i class="fas fa-eye mr-1"></i> Accès aux données
<span v-if="auditAccessTotal > 0" class="ml-1 text-xs opacity-75">(${auditAccessTotal})</span>
</button>
<button @click="auditSubTab = 'auth'; auditPage = 1; loadAuditData()"
:class="auditSubTab === 'auth' ? 'bg-[var(--bg-button)] text-[var(--text-button)]' : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border-secondary)]'"
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors">
<i class="fas fa-shield-alt mr-1"></i> Authentification
<span v-if="auditAuthTotal > 0" class="ml-1 text-xs opacity-75">(${auditAuthTotal})</span>
</button>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-2 mb-4">
<select v-model="auditFilterAction" @change="auditPage = 1; loadAuditData()"
class="px-2 py-1.5 text-sm border border-[var(--border-secondary)] rounded-md bg-[var(--bg-input)] text-[var(--text-primary)]">
<option value="">Toutes les actions</option>
<template v-if="auditSubTab === 'access'">
<option value="view">Consultation</option>
<option value="download">Téléchargement</option>
<option value="edit">Modification</option>
<option value="delete">Suppression</option>
<option value="export">Export</option>
<option value="share">Partage</option>
</template>
<template v-else>
<option value="login">Connexion</option>
<option value="logout">Déconnexion</option>
<option value="failed_login">Tentative échouée</option>
<option value="register">Inscription</option>
<option value="password_change">Changement MDP</option>
<option value="password_reset">Réinitialisation MDP</option>
<option value="sso_login">Connexion SSO</option>
</template>
</select>
<select v-if="auditSubTab === 'access'" v-model="auditFilterResource" @change="auditPage = 1; loadAuditData()"
class="px-2 py-1.5 text-sm border border-[var(--border-secondary)] rounded-md bg-[var(--bg-input)] text-[var(--text-primary)]">
<option value="">Toutes les ressources</option>
<option value="recording">Enregistrement</option>
<option value="audio">Audio</option>
<option value="transcript">Transcription</option>
<option value="user">Utilisateur</option>
<option value="summary">Résumé</option>
</select>
<button v-if="auditFilterAction || auditFilterResource" @click="auditFilterAction = ''; auditFilterResource = ''; auditPage = 1; loadAuditData()"
class="px-2 py-1.5 text-sm text-[var(--text-muted)] hover:text-[var(--text-danger)] transition-colors">
<i class="fas fa-times mr-1"></i> Réinitialiser
</button>
</div>
<!-- Loading -->
<div v-if="isLoadingAudit" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-3xl text-[var(--text-muted)] mb-3"></i>
<p class="text-[var(--text-muted)] text-sm">Chargement des journaux...</p>
</div>
<!-- Access Logs Table -->
<div v-if="!isLoadingAudit && auditSubTab === 'access'" class="overflow-x-auto">
<table v-if="auditAccessLogs.length > 0" class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--bg-tertiary)]">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Date</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Utilisateur</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Action</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Ressource</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Statut</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">IP</th>
</tr>
</thead>
<tbody class="bg-[var(--bg-secondary)] divide-y divide-[var(--border-primary)]">
<tr v-for="log in auditAccessLogs" :key="log.id" class="hover:bg-[var(--bg-tertiary)]">
<td class="px-4 py-3 text-sm text-[var(--text-secondary)] whitespace-nowrap">${ formatDate(log.timestamp) }</td>
<td class="px-4 py-3 text-sm text-[var(--text-primary)]">${ log.username || 'Anonyme' }</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
${ auditActionLabel(log.action) }
</span>
</td>
<td class="px-4 py-3 text-sm text-[var(--text-secondary)]">
${ auditResourceLabel(log.resource_type) }
<span v-if="log.resource_id" class="text-[var(--text-muted)]">#${ log.resource_id }</span>
</td>
<td class="px-4 py-3 text-sm">
<span :class="auditStatusClass(log.status)" class="px-2 py-0.5 rounded-full text-xs font-medium">
${ log.status }
</span>
</td>
<td class="px-4 py-3 text-xs text-[var(--text-muted)] font-mono">${ log.ip_address || '-' }</td>
</tr>
</tbody>
</table>
<div v-else class="text-center py-12 bg-[var(--bg-tertiary)] rounded-lg">
<i class="fas fa-eye-slash text-3xl text-[var(--text-muted)] mb-3"></i>
<p class="text-[var(--text-muted)]">Aucun log d'accès enregistré</p>
</div>
</div>
<!-- Auth Logs Table -->
<div v-if="!isLoadingAudit && auditSubTab === 'auth'" class="overflow-x-auto">
<table v-if="auditAuthLogs.length > 0" class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--bg-tertiary)]">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Date</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Utilisateur</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Action</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">IP</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-muted)] uppercase">Détails</th>
</tr>
</thead>
<tbody class="bg-[var(--bg-secondary)] divide-y divide-[var(--border-primary)]">
<tr v-for="log in auditAuthLogs" :key="log.id" class="hover:bg-[var(--bg-tertiary)]">
<td class="px-4 py-3 text-sm text-[var(--text-secondary)] whitespace-nowrap">${ formatDate(log.timestamp) }</td>
<td class="px-4 py-3 text-sm text-[var(--text-primary)]">${ log.username || '-' }</td>
<td class="px-4 py-3 text-sm">
<span :class="auditAuthActionClass(log.action)" class="px-2 py-0.5 rounded-full text-xs font-medium">
${ auditActionLabel(log.action) }
</span>
</td>
<td class="px-4 py-3 text-xs text-[var(--text-muted)] font-mono">${ log.ip_address || '-' }</td>
<td class="px-4 py-3 text-xs text-[var(--text-muted)] max-w-xs truncate">${ log.details ? JSON.stringify(log.details) : '-' }</td>
</tr>
</tbody>
</table>
<div v-else class="text-center py-12 bg-[var(--bg-tertiary)] rounded-lg">
<i class="fas fa-shield-alt text-3xl text-[var(--text-muted)] mb-3"></i>
<p class="text-[var(--text-muted)]">Aucun log d'authentification enregistré</p>
</div>
</div>
<!-- Pagination -->
<div v-if="!isLoadingAudit && auditTotalPages > 1" class="flex items-center justify-between mt-4 pt-4 border-t border-[var(--border-primary)]">
<p class="text-sm text-[var(--text-muted)]">
Page ${ auditPage } de ${ auditTotalPages }
<span class="ml-2">(${ auditSubTab === 'access' ? auditAccessTotal : auditAuthTotal } entrées)</span>
</p>
<div class="flex gap-2">
<button @click="auditPage--; loadAuditData()" :disabled="auditPage <= 1"
:class="auditPage <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-[var(--bg-tertiary)]'"
class="px-3 py-1.5 text-sm border border-[var(--border-secondary)] rounded-md text-[var(--text-secondary)]">
<i class="fas fa-chevron-left"></i> Précédent
</button>
<button @click="auditPage++; loadAuditData()" :disabled="auditPage >= auditTotalPages"
:class="auditPage >= auditTotalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-[var(--bg-tertiary)]'"
class="px-3 py-1.5 text-sm border border-[var(--border-secondary)] rounded-md text-[var(--text-secondary)]">
Suivant <i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
</div>
</div>
</main>
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
<div>&copy; {{ now.year }} InnovA AI &middot; <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialit&eacute;</a> &middot; <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
</footer>
<!-- Add User Modal -->
<div v-if="showAddUserModal" @click.self="showAddUserModal = false" class="fixed inset-0 bg-black bg-opacity-70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-md">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]">${t('adminDashboard.addNewUser')}</h3>
<button @click="showAddUserModal = false" class="text-[var(--text-muted)] hover:text-[var(--text-secondary)]">&times;</button>
</div>
<form @submit.prevent="addUser">
<div class="mb-4">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">${t('adminDashboard.usernameLabel')}</label>
<input v-model="newUser.username" type="text" required class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">${t('adminDashboard.emailLabel')}</label>
<input v-model="newUser.email" type="email" required class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">${t('adminDashboard.passwordLabel')}</label>
<input v-model="newUser.password" type="password" required class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">${t('adminDashboard.confirmPasswordLabel')}</label>
<input v-model="newUser.confirmPassword" type="password" required class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
<div class="mb-4">
<label class="flex items-center cursor-pointer">
<input v-model="newUser.isAdmin" type="checkbox" class="sr-only peer">
<div class="relative w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--text-accent)]"></div>
<span class="ml-3 text-sm text-[var(--text-secondary)]">${t('adminDashboard.adminUser')}</span>
</label>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">${t('adminDashboard.monthlyTokenBudget')}</label>
<input v-model.number="newUser.monthlyTokenBudget" type="number" min="100000" step="10000" :placeholder="t('adminDashboard.tokenBudgetPlaceholder')" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<p class="text-xs text-[var(--text-muted)] mt-1">${t('adminDashboard.tokenBudgetHelp')}</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">${t('adminDashboard.monthlyTranscriptionBudget')}</label>
<input v-model.number="newUser.monthlyTranscriptionBudgetMinutes" type="number" min="10" step="10" :placeholder="t('adminDashboard.transcriptionBudgetPlaceholder')" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<p class="text-xs text-[var(--text-muted)] mt-1">${t('adminDashboard.transcriptionBudgetHelp')}</p>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="showAddUserModal = false" class="px-4 py-2 border border-[var(--border-secondary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-tertiary)]">${t('buttons.cancel')}</button>
<button type="submit" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)]">${t('adminDashboard.addUser')}</button>
</div>
</form>
</div>
</div>
<!-- Edit User Modal -->
<div v-if="showEditUserModal" @click.self="showEditUserModal = false" class="fixed inset-0 bg-black bg-opacity-70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-md">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]">${t('adminDashboard.editUser')}</h3>
<button @click="showEditUserModal = false" class="text-[var(--text-muted)] hover:text-[var(--text-secondary)]">&times;</button>
</div>
<form @submit.prevent="updateUser">
<div class="mb-4">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">${t('adminDashboard.usernameLabel')}</label>
<input v-model="editingUser.username" type="text" required class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">${t('adminDashboard.emailLabel')}</label>
<input v-model="editingUser.email" type="email" required class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">${t('adminDashboard.newPasswordLabel')}</label>
<input v-model="editingUser.password" type="password" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
</div>
<div class="mb-4">
<label class="flex items-center cursor-pointer">
<input v-model="editingUser.is_admin" type="checkbox" class="sr-only peer">
<div class="relative w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--text-accent)]"></div>
<span class="ml-3 text-sm text-[var(--text-secondary)]">${t('adminDashboard.adminUser')}</span>
</label>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">${t('adminDashboard.monthlyTokenBudget')}</label>
<input v-model.number="editingUser.monthly_token_budget" type="number" min="100000" step="10000" :placeholder="t('adminDashboard.tokenBudgetPlaceholder')" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<p class="text-xs text-[var(--text-muted)] mt-1">${t('adminDashboard.tokenBudgetHelp')}</p>
<div v-if="editingUser.monthly_token_budget && editingUser.current_token_usage !== undefined" class="mt-2">
<div class="flex justify-between text-xs text-[var(--text-muted)] mb-1">
<span>${t('adminDashboard.currentUsage')}: ${ editingUser.current_token_usage?.toLocaleString() || 0 } ${t('adminDashboard.tokens')}</span>
<span :class="{'text-[var(--text-danger-strong)]': editingUser.token_usage_percentage >= 100, 'text-[var(--text-warn-strong)]': editingUser.token_usage_percentage >= 80 && editingUser.token_usage_percentage < 100}">${ editingUser.token_usage_percentage || 0 }%</span>
</div>
<div class="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
<div class="h-2 rounded-full" :class="{'bg-[var(--text-danger-strong)]': editingUser.token_usage_percentage >= 100, 'bg-[var(--text-warn-strong)]': editingUser.token_usage_percentage >= 80 && editingUser.token_usage_percentage < 100, 'bg-[var(--text-success-strong)]': editingUser.token_usage_percentage < 80}" :style="{width: Math.min(editingUser.token_usage_percentage || 0, 100) + '%'}"></div>
</div>
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">${t('adminDashboard.monthlyTranscriptionBudget')}</label>
<input v-model.number="editingUser.monthly_transcription_budget_minutes" type="number" min="10" step="10" :placeholder="t('adminDashboard.transcriptionBudgetPlaceholder')" class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<p class="text-xs text-[var(--text-muted)] mt-1">${t('adminDashboard.transcriptionBudgetHelp')}</p>
<div v-if="editingUser.monthly_transcription_budget && editingUser.current_transcription_usage !== undefined" class="mt-2">
<div class="flex justify-between text-xs text-[var(--text-muted)] mb-1">
<span>${t('adminDashboard.currentUsageMinutes')}: ${ editingUser.current_transcription_usage_minutes || 0 } ${t('adminDashboard.minutes')}</span>
<span :class="{'text-[var(--text-danger-strong)]': editingUser.transcription_usage_percentage >= 100, 'text-[var(--text-warn-strong)]': editingUser.transcription_usage_percentage >= 80 && editingUser.transcription_usage_percentage < 100}">${ editingUser.transcription_usage_percentage || 0 }%</span>
</div>
<div class="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
<div class="h-2 rounded-full" :class="{'bg-[var(--text-danger-strong)]': editingUser.transcription_usage_percentage >= 100, 'bg-[var(--text-warn-strong)]': editingUser.transcription_usage_percentage >= 80 && editingUser.transcription_usage_percentage < 100, 'bg-[var(--text-success-strong)]': editingUser.transcription_usage_percentage < 80}" :style="{width: Math.min(editingUser.transcription_usage_percentage || 0, 100) + '%'}"></div>
</div>
</div>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="showEditUserModal = false" class="px-4 py-2 border border-[var(--border-secondary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-tertiary)]">Cancel</button>
<button type="submit" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)]">${t('adminDashboard.updateUser')}</button>
</div>
</form>
</div>
</div>
<!-- Delete User Confirmation Modal -->
<div v-if="showDeleteUserModal" @click.self="showDeleteUserModal = false" class="fixed inset-0 bg-black bg-opacity-70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-md">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]">Confirm Delete</h3>
<button @click="showDeleteUserModal = false" class="text-[var(--text-muted)] hover:text-[var(--text-secondary)]">&times;</button>
</div>
<p class="mb-4 text-[var(--text-secondary)]">Are you sure you want to delete the user <span class="font-semibold">${ userToDelete?.username }</span>? This action cannot be undone.</p>
<div class="flex justify-end space-x-3">
<button @click="showDeleteUserModal = false" class="px-4 py-2 border border-[var(--border-secondary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-tertiary)]">Cancel</button>
<button @click="deleteUser" class="px-4 py-2 bg-[var(--bg-danger)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-danger-hover)]">Delete User</button>
</div>
</div>
</div>
<!-- Edit System Setting Modal -->
<div v-if="showEditSettingModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-[var(--bg-secondary)] rounded-lg shadow-xl w-full max-w-lg max-h-[85vh] flex flex-col overflow-hidden">
<div class="flex-shrink-0 flex justify-between items-center p-6 pb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]">Edit System Setting</h3>
<button @click="showEditSettingModal = false" class="text-[var(--text-muted)] hover:text-[var(--text-secondary)] text-2xl leading-none">&times;</button>
</div>
<div v-if="editingSetting" class="flex-1 overflow-y-auto px-6 pb-6 space-y-4">
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Setting Key</label>
<div class="px-3 py-2 bg-[var(--bg-tertiary)] text-[var(--text-muted)] rounded-md font-mono text-sm">
${ editingSetting.key }
</div>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Description</label>
<div class="px-3 py-2 text-[var(--text-muted)] text-sm">
${ editingSetting.description || 'No description available' }
</div>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Type</label>
<span class="inline-block px-2 py-1 bg-[var(--bg-accent)] text-[var(--text-accent)] rounded text-xs">
${ editingSetting.setting_type }
</span>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Current Value</label>
<div class="px-3 py-2 bg-[var(--bg-tertiary)] text-[var(--text-muted)] rounded-md max-h-24 overflow-y-auto text-sm break-words">
<span v-if="editingSetting.key === 'transcript_length_limit' && editingSetting.value === '-1'">
${t('adminDashboard.noLimit')}
</span>
<span v-else-if="editingSetting.key === 'transcript_length_limit'">
${ Number(editingSetting.value).toLocaleString() } ${t('adminDashboard.characters')}
</span>
<span v-else-if="editingSetting.key === 'max_file_size_mb'">
${ Number(editingSetting.value).toLocaleString() } ${t('adminDashboard.megabytes')}
</span>
<span v-else-if="editingSetting.key === 'asr_timeout_seconds'">
${ Number(editingSetting.value).toLocaleString() } ${t('adminDashboard.seconds')} (${ Math.round(Number(editingSetting.value) / 60) } ${t('adminDashboard.minutes')})
</span>
<span v-else>${ editingSetting.value || t('adminDashboard.notSet') }</span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-2">New Value</label>
<!-- Boolean type -->
<div v-if="editingSetting.setting_type === 'boolean'" class="flex items-center space-x-4">
<label class="flex items-center cursor-pointer">
<input v-model="settingNewValue" type="radio" value="true" class="mr-2 text-[var(--text-accent)] focus:ring-[var(--border-focus)]">
<span class="text-[var(--text-primary)]">Enabled</span>
</label>
<label class="flex items-center cursor-pointer">
<input v-model="settingNewValue" type="radio" value="false" class="mr-2 text-[var(--text-accent)] focus:ring-[var(--border-focus)]">
<span class="text-[var(--text-primary)]">Disabled</span>
</label>
</div>
<!-- Integer/Float type with special handling for specific keys -->
<div v-else-if="editingSetting.setting_type === 'integer' || editingSetting.setting_type === 'float'" class="space-y-2">
<!-- Special input for transcript_length_limit -->
<div v-if="editingSetting.key === 'transcript_length_limit'" class="space-y-2">
<div class="flex items-center space-x-2">
<input v-model="settingUseNoLimit" type="checkbox" id="noLimit" class="rounded border-[var(--border-secondary)] text-[var(--text-accent)] focus:ring-[var(--border-focus)]">
<label for="noLimit" class="text-sm text-[var(--text-secondary)]">No limit (use entire transcript)</label>
</div>
<input v-if="!settingUseNoLimit" v-model.number="settingNewValue" type="number" min="1000" step="1000"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
:placeholder="t('form.placeholderCharacterLimit')">
<small v-if="!settingUseNoLimit" class="text-[var(--text-muted)]">Recommended: 30000-50000 characters for most LLMs</small>
</div>
<!-- Special input for max_file_size_mb -->
<div v-else-if="editingSetting.key === 'max_file_size_mb'" class="space-y-2">
<input v-model.number="settingNewValue" type="number" min="1" max="10000" step="50"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
:placeholder="t('form.placeholderSizeMB')">
<small class="text-[var(--text-muted)]">${t('adminDashboard.maxFileSizeHelp')}</small>
</div>
<!-- Special input for asr_timeout_seconds -->
<div v-else-if="editingSetting.key === 'asr_timeout_seconds'" class="space-y-2">
<div class="flex space-x-2">
<div class="flex-1">
<input v-model.number="settingTimeoutMinutes" type="number" min="1" max="600" step="1"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
:placeholder="t('form.placeholderMinutes')">
<small class="text-[var(--text-muted)]">${t('adminDashboard.minutes')}</small>
</div>
<div class="flex-1">
<input v-model.number="settingTimeoutSeconds" type="number" min="0" max="59" step="1"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
:placeholder="t('form.placeholderSeconds')">
<small class="text-[var(--text-muted)]">${t('adminDashboard.seconds')}</small>
</div>
</div>
<div class="text-sm text-[var(--text-muted)]">
${t('adminDashboard.total')}: <span class="font-medium text-[var(--text-primary)]">${ (settingTimeoutMinutes * 60 + settingTimeoutSeconds).toLocaleString() } ${t('adminDashboard.seconds')}</span>
</div>
<small class="text-[var(--text-muted)]">${t('adminDashboard.timeoutRecommendation')}</small>
</div>
<!-- Default number input -->
<input v-else v-model.number="settingNewValue" :type="editingSetting.setting_type === 'integer' ? 'number' : 'text'"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
:placeholder="`Enter ${editingSetting.setting_type} value`">
</div>
<!-- String type -->
<textarea v-else v-model="settingNewValue" rows="3"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
:placeholder="'Enter value'"></textarea>
</div>
<div v-if="settingError" class="p-3 bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)] rounded-md text-sm">
${ settingError }
</div>
</div>
<div class="flex-shrink-0 flex justify-end space-x-3 p-6 border-t border-[var(--border-primary)] bg-[var(--bg-tertiary)] rounded-b-lg">
<button @click="showEditSettingModal = false"
class="px-4 py-2 border border-[var(--border-secondary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-tertiary)]">
Cancel
</button>
<button @click="saveSettingValue"
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)]">
${t('buttons.saveChanges')}
</button>
</div>
</div>
</div>
<!-- Create/Edit Group Modal -->
<div v-if="showTeamModal" @click.self="closeTeamModal" class="fixed inset-0 bg-black bg-opacity-70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-md">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]">${ editingTeam ? 'Edit Group' : 'Create Group' }</h3>
<button @click="closeTeamModal" class="text-[var(--text-muted)] hover:text-[var(--text-secondary)]">&times;</button>
</div>
<form @submit.prevent="saveTeam">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Group Name *</label>
<input v-model="teamForm.name" type="text" required
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
placeholder="Enter group name">
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Description</label>
<textarea v-model="teamForm.description" rows="3"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]"
placeholder="Optional description"></textarea>
</div>
<div v-if="teamError" class="p-3 bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)] rounded-md text-sm">
${ teamError }
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button type="button" @click="closeTeamModal"
class="px-4 py-2 border border-[var(--border-secondary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-tertiary)]">
Cancel
</button>
<button type="submit"
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)]">
${ editingTeam ? 'Update Group' : 'Create Group' }
</button>
</div>
</form>
</div>
</div>
<!-- Manage Group Members Modal -->
<div v-if="showManageTeamModal" @click.self="closeManageTeamModal" class="fixed inset-0 bg-black bg-opacity-70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]">Manage Group: ${ currentTeam?.name }</h3>
<button @click="closeManageTeamModal" class="text-[var(--text-muted)] hover:text-[var(--text-secondary)]">&times;</button>
</div>
<!-- Add Member Section -->
<div class="mb-6 p-4 bg-[var(--bg-tertiary)] rounded-lg">
<h4 class="text-sm font-medium text-[var(--text-secondary)] mb-3">Add Member</h4>
<div class="flex space-x-2">
<div class="flex-1">
<select v-model="newMemberUserId"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<option value="">Select a user...</option>
<option v-for="user in availableUsers" :key="user.id" :value="user.id">
${ user.username } (${ user.email })
</option>
</select>
</div>
<div>
<select v-model="newMemberRole"
class="px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] bg-[var(--bg-input)] text-[var(--text-primary)]">
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</div>
<button @click="addTeamMember" :disabled="!newMemberUserId"
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-plus"></i> Add
</button>
</div>
</div>
<!-- Current Members -->
<div>
<h4 class="text-sm font-medium text-[var(--text-secondary)] mb-3">Current Members (${ teamMembers.length })</h4>
<div v-if="teamMembers.length > 0" class="space-y-2">
<div v-for="member in teamMembers" :key="member.user_id"
class="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded-md">
<div class="flex items-center space-x-3">
<i class="fas fa-user text-[var(--text-muted)]"></i>
<div>
<div class="text-sm font-medium text-[var(--text-primary)]">${ member.username }</div>
<div class="text-xs text-[var(--text-muted)]">${ member.email }</div>
</div>
</div>
<div class="flex items-center space-x-2">
<select v-model="member.role" @change="updateMemberRole(member)"
class="px-2 py-1 text-sm border border-[var(--border-secondary)] rounded bg-[var(--bg-input)] text-[var(--text-primary)]">
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
<button @click="removeTeamMember(member)"
class="text-red-500 hover:text-red-700" title="Remove from group">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<div v-else class="text-center py-4 text-[var(--text-muted)]">
${t('adminDashboard.noMembersYet')}
</div>
</div>
<div v-if="teamMemberError" class="mt-4 p-3 bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)] rounded-md text-sm">
${ teamMemberError }
</div>
<div class="flex justify-between mt-6">
<button @click="syncTeamShares"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 flex items-center gap-2">
<i class="fas fa-sync-alt"></i>
Sync Group Shares
</button>
<button @click="closeManageTeamModal"
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)]">
Done
</button>
</div>
</div>
</div>
<!-- Sync Group Shares Confirmation Modal -->
<div v-if="showSyncSharesModal" @click.self="showSyncSharesModal = false" class="fixed inset-0 bg-black bg-opacity-70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-md">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]">Sync Group Shares</h3>
<button @click="showSyncSharesModal = false" class="text-[var(--text-muted)] hover:text-[var(--text-secondary)]">&times;</button>
</div>
<p class="mb-4 text-[var(--text-secondary)]">
This will create shares for all recordings with group tags that have auto-sharing enabled.
</p>
<p class="mb-4 text-[var(--text-muted)] text-sm">
<i class="fas fa-info-circle mr-1"></i>
Only missing shares will be created - existing shares won't be duplicated.
</p>
<div class="flex justify-end space-x-3">
<button @click="showSyncSharesModal = false" class="px-4 py-2 border border-[var(--border-secondary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-tertiary)]">Cancel</button>
<button @click="confirmSyncShares" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
<i class="fas fa-sync-alt mr-2"></i>Sync Now
</button>
</div>
</div>
</div>
<!-- Sync Results Modal -->
<div v-if="showSyncResultsModal" @click.self="showSyncResultsModal = false" class="fixed inset-0 bg-black bg-opacity-70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-md">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]">
<i class="fas fa-check-circle text-green-500 mr-2"></i>Sync Complete
</h3>
<button @click="showSyncResultsModal = false" class="text-[var(--text-muted)] hover:text-[var(--text-secondary)]">&times;</button>
</div>
<div class="mb-4 space-y-2">
<div class="flex justify-between text-[var(--text-secondary)]">
<span>Shares created:</span>
<span class="font-semibold">${ syncResults.shares_created }</span>
</div>
<div class="flex justify-between text-[var(--text-secondary)]">
<span>Recordings processed:</span>
<span class="font-semibold">${ syncResults.recordings_processed }</span>
</div>
</div>
<div class="flex justify-end">
<button @click="showSyncResultsModal = false" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)]">
Done
</button>
</div>
</div>
</div>
<!-- Delete Group Confirmation Modal -->
<div v-if="showDeleteTeamModal" @click.self="showDeleteTeamModal = false" class="fixed inset-0 bg-black bg-opacity-70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-md">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]">Delete Group</h3>
<button @click="showDeleteTeamModal = false" class="text-[var(--text-muted)] hover:text-[var(--text-secondary)]">&times;</button>
</div>
<p class="mb-4 text-[var(--text-secondary)]">Are you sure you want to delete the group <span class="font-semibold">${ teamToDelete?.name }</span>? This will remove all members from the group. This action cannot be undone.</p>
<div class="flex justify-end space-x-3">
<button @click="showDeleteTeamModal = false" class="px-4 py-2 border border-[var(--border-secondary)] text-[var(--text-secondary)] rounded-md hover:bg-[var(--bg-tertiary)]">Cancel</button>
<button @click="deleteTeam" class="px-4 py-2 bg-[var(--bg-danger)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-danger-hover)]">Delete Group</button>
</div>
</div>
</div>
<!-- Manage Group Tags Modal -->
<div v-if="showManageTeamTagsModal" @click.self="closeManageTeamTagsModal" class="fixed inset-0 bg-black bg-opacity-70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold text-[var(--text-primary)]">Manage Tags for ${ currentTeam?.name }</h3>
<button @click="closeManageTeamTagsModal" class="text-[var(--text-muted)] hover:text-[var(--text-secondary)]">&times;</button>
</div>
<!-- Create/Edit Tag Form -->
<div class="bg-[var(--bg-tertiary)] p-4 rounded-lg mb-4 border border-[var(--border-primary)]">
<div class="flex justify-between items-center mb-3">
<h4 class="text-sm font-medium text-[var(--text-secondary)]">
${ editingTeamTagId ? 'Edit Group Tag' : 'Create New Group Tag' }
</h4>
<button v-if="editingTeamTagId" @click="cancelEditTeamTag" type="button" class="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]">
<i class="fas fa-times mr-1"></i> Cancel
</button>
</div>
<form @submit.prevent="saveTeamTag" class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1">Tag Name *</label>
<input v-model="newTeamTag.name" type="text" required maxlength="50"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md bg-[var(--bg-input)] text-[var(--text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)]"
placeholder="e.g., Project Alpha">
</div>
<div>
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1">Color</label>
<input v-model="newTeamTag.color" type="color"
class="w-full h-10 border border-[var(--border-secondary)] rounded-md bg-[var(--bg-input)] cursor-pointer">
</div>
</div>
<!-- Custom Prompt -->
<div>
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Custom Summarization Prompt
<span class="text-[var(--text-muted)] font-normal">- Optional AI instructions</span>
</label>
<textarea v-model="newTeamTag.custom_prompt" rows="3"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md bg-[var(--bg-input)] text-[var(--text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)]"
placeholder="Enter custom instructions for AI summarization (e.g., 'Focus on action items and deadlines')"></textarea>
<p class="text-xs text-[var(--text-muted)] mt-1">
Recordings with this tag will use this prompt for AI summaries and chat.
</p>
</div>
<!-- Transcription Settings -->
<div class="grid grid-cols-3 gap-3">
<div>
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1">Langue par défaut</label>
<select v-model="newTeamTag.default_language"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md bg-[var(--bg-input)] text-[var(--text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)]">
<option value="">Détection automatique</option>
<option value="en">Anglais</option>
<option value="es">Espagnol</option>
<option value="fr">Français</option>
<option value="de">Allemand</option>
<option value="zh">Chinois</option>
<option value="ja">Japonais</option>
<option value="pt">Portugais</option>
<option value="it">Italien</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1">Min. Intervenants</label>
<input v-model.number="newTeamTag.default_min_speakers" type="number" min="1" max="10"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md bg-[var(--bg-input)] text-[var(--text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)]"
placeholder="1">
</div>
<div>
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1">Max. Intervenants</label>
<input v-model.number="newTeamTag.default_max_speakers" type="number" min="1" max="10"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md bg-[var(--bg-input)] text-[var(--text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)]"
placeholder="5">
</div>
</div>
<!-- Retention and Protection -->
<div>
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Période de rétention (jours)
<span class="text-[var(--text-muted)] font-normal">- Remplacement optionnel pour la suppression automatique</span>
</label>
<input v-model.number="newTeamTag.retention_days" type="number" min="0" step="1"
class="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md bg-[var(--bg-input)] text-[var(--text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)]"
placeholder="Laisser vide pour la rétention globale">
<p class="text-xs text-[var(--text-muted)] mt-1">
Les enregistrements avec cette étiquette seront supprimés après ce nombre de jours. Laisser vide pour la rétention globale (${ globalRetentionDays } jours).
</p>
</div>
<div class="flex items-center">
<input v-model="newTeamTag.protect_from_deletion" type="checkbox" id="protectFromDeletion"
class="mr-2 h-4 w-4 text-[var(--text-accent)] border-[var(--border-secondary)] rounded focus:ring-[var(--border-focus)]">
<label for="protectFromDeletion" class="text-xs text-[var(--text-secondary)]">
<i class="fas fa-shield-alt mr-1"></i> Protéger les enregistrements avec cette étiquette de la suppression automatique
</label>
</div>
<div class="space-y-2">
<div class="flex items-center">
<input v-model="newTeamTag.auto_share_on_apply" type="checkbox" id="autoShareOnApply"
class="mr-2 h-4 w-4 text-[var(--text-accent)] border-[var(--border-secondary)] rounded focus:ring-[var(--border-focus)]">
<label for="autoShareOnApply" class="text-xs text-[var(--text-secondary)]">
<i class="fas fa-user-friends mr-1"></i> Partager automatiquement avec tous les membres du groupe quand cette étiquette est appliquée
</label>
</div>
<div class="flex items-center">
<input v-model="newTeamTag.share_with_group_lead" type="checkbox" id="shareWithGroupLead"
class="mr-2 h-4 w-4 text-[var(--text-accent)] border-[var(--border-secondary)] rounded focus:ring-[var(--border-focus)]">
<label for="shareWithGroupLead" class="text-xs text-[var(--text-secondary)]">
<i class="fas fa-user-tie mr-1"></i> Partager les enregistrements avec les administrateurs du groupe quand cette étiquette est appliquée
</label>
</div>
<p class="text-xs text-[var(--text-muted)] ml-6">
Note : Si les deux sont activés, tous les membres du groupe auront accès. Si seulement « administrateurs du groupe » est activé, seuls les responsables du groupe y auront accès.
</p>
</div>
<div v-if="teamTagError" class="text-[var(--text-danger)] text-xs">${ teamTagError }</div>
<button type="submit" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] text-sm">
<i :class="editingTeamTagId ? 'fas fa-save' : 'fas fa-plus'" class="mr-1"></i>
${ editingTeamTagId ? 'Modifier l\'étiquette' : 'Créer l\'étiquette' }
</button>
</form>
</div>
<!-- Existing Group Tags -->
<div>
<h4 class="text-sm font-medium text-[var(--text-secondary)] mb-3">Group Tags</h4>
<div v-if="teamTags.length > 0" class="space-y-3">
<div v-for="tag in teamTags" :key="tag.id"
class="bg-[var(--bg-tertiary)] p-4 rounded-lg border border-[var(--border-primary)]">
<div class="flex items-start justify-between mb-2">
<div class="flex items-center space-x-3 flex-1">
<span class="inline-block w-4 h-4 rounded flex-shrink-0" :style="{ backgroundColor: tag.color }"></span>
<div class="flex-1">
<div class="text-sm font-medium text-[var(--text-primary)]">${ tag.name }</div>
<div class="text-xs text-[var(--text-muted)] mt-1 space-x-3">
<span v-if="tag.retention_days">
<i class="fas fa-clock mr-1"></i> ${ tag.retention_days } day retention
</span>
<span v-else>
<i class="fas fa-globe mr-1"></i> Global retention
</span>
<span v-if="tag.protect_from_deletion" class="text-green-500">
<i class="fas fa-shield-alt mr-1"></i> Protected
</span>
</div>
</div>
</div>
<div class="flex gap-2 flex-shrink-0">
<button @click="editTeamTag(tag)" class="text-blue-500 hover:text-blue-700 px-2 py-1" title="Edit Tag">
<i class="fas fa-edit"></i>
</button>
<button @click="deleteTeamTag(tag)" class="text-red-500 hover:text-red-700 px-2 py-1" title="Delete Tag">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<!-- Additional details if configured -->
<div v-if="tag.custom_prompt || tag.default_language || tag.default_min_speakers || tag.default_max_speakers"
class="mt-2 pt-2 border-t border-[var(--border-secondary)] space-y-1">
<div v-if="tag.custom_prompt" class="text-xs text-[var(--text-muted)]">
<i class="fas fa-comment-dots mr-1"></i> Custom prompt configured
</div>
<div v-if="tag.default_language" class="text-xs text-[var(--text-muted)]">
<i class="fas fa-language mr-1"></i> Language: ${ tag.default_language.toUpperCase() }
</div>
<div v-if="tag.default_min_speakers || tag.default_max_speakers" class="text-xs text-[var(--text-muted)]">
<i class="fas fa-users mr-1"></i> Speakers:
<span v-if="tag.default_min_speakers">${ tag.default_min_speakers }-</span><span v-if="tag.default_max_speakers">${ tag.default_max_speakers }</span><span v-else>auto</span>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-8 text-[var(--text-muted)]">
<i class="fas fa-tags text-3xl mb-2"></i>
<p class="text-sm">No group tags created yet</p>
</div>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted, watch } = Vue
// Initialize i18n before Vue app creation
initializeI18n().then(() => {
// Get the i18n instance to properly bind the t function
const i18nInstance = window.i18n;
createApp({
setup() {
// State
const isTeamAdminOnly = ref({% if is_group_admin_only %}true{% else %}false{% endif %});
const activeTab = ref({% if is_group_admin_only %}'teams'{% else %}'users'{% endif %});
const users = ref([]);
const stats = ref({
total_users: 0,
total_recordings: 0,
total_storage: 0,
total_queries: 0,
completed_recordings: 0,
processing_recordings: 0,
pending_recordings: 0,
failed_recordings: 0,
top_users: []
});
const tokenStats = ref({
today: { tokens: 0, cost: 0 },
current_month: { tokens: 0, cost: 0 },
user_count_with_usage: 0,
users_over_80_percent: 0,
users_at_100_percent: 0
});
const tokenUserStats = ref([]);
const transcriptionStats = ref({
today: { seconds: 0, minutes: 0, cost: 0 },
current_month: { seconds: 0, minutes: 0, cost: 0 },
user_count_with_usage: 0,
users_over_80_percent: 0,
users_at_100_percent: 0
});
const transcriptionUserStats = ref([]);
const dailyTokenChart = ref(null);
const monthlyTokenChart = ref(null);
const settings = ref([]);
const isLoadingUsers = ref(true);
const isLoadingSettings = ref(false);
const userSearchQuery = ref('');
const isDarkMode = ref(false);
const isUserMenuOpen = ref(false);
// Modal state
const showAddUserModal = ref(false);
const showEditUserModal = ref(false);
const showDeleteUserModal = ref(false);
const newUser = ref({
username: '',
email: '',
password: '',
confirmPassword: '',
isAdmin: false,
monthlyTokenBudget: null,
monthlyTranscriptionBudgetMinutes: null
});
const editingUser = ref(null);
const userToDelete = ref(null);
// Inquire Mode state
const inquireModeEnabled = ref({{ 'true' if inquire_mode_enabled else 'false' }});
const inquireStatus = ref({
total_completed_recordings: 0,
recordings_with_transcriptions: 0,
processed_for_inquire: 0,
need_processing: 0,
total_chunks: 0,
embeddings_available: false
});
const isProcessingRecordings = ref(false);
const processingResult = ref(null);
// Teams state
const groups = ref([]);
const showTeamModal = ref(false);
const showManageTeamModal = ref(false);
const showDeleteTeamModal = ref(false);
const showSyncSharesModal = ref(false);
const showSyncResultsModal = ref(false);
const syncResults = ref({ shares_created: 0, recordings_processed: 0 });
const editingTeam = ref(null);
const teamToDelete = ref(null);
const currentTeam = ref(null);
const teamForm = ref({ name: '', description: '' });
const teamError = ref('');
const teamMembers = ref([]);
const newMemberUserId = ref('');
const newMemberRole = ref('member');
const teamMemberError = ref('');
// Group Tags state
const showManageTeamTagsModal = ref(false);
const teamTags = ref([]);
const editingTeamTagId = ref(null);
const newTeamTag = ref({
name: '',
color: '#3B82F6',
custom_prompt: '',
default_language: '',
default_min_speakers: null,
default_max_speakers: null,
retention_days: null,
protect_from_deletion: false,
auto_share_on_apply: true,
share_with_group_lead: true
});
const teamTagError = ref('');
const globalRetentionDays = ref({{ global_retention_days }});
// Journal d'audit state
const auditSubTab = ref('access');
const auditAccessLogs = ref([]);
const auditAuthLogs = ref([]);
const auditAccessTotal = ref(0);
const auditAuthTotal = ref(0);
const auditPage = ref(1);
const auditPerPage = ref(50);
const auditAccessPages = ref(0);
const auditAuthPages = ref(0);
const auditFilterAction = ref('');
const auditFilterResource = ref('');
const isLoadingAudit = ref(false);
const auditEnabled = ref(false);
const auditTotalPages = computed(() => {
return auditSubTab.value === 'access' ? auditAccessPages.value : auditAuthPages.value;
});
// Default Prompts state
const defaultSummaryPrompt = ref('');
const isSavingPrompt = ref(false);
const promptSaveMessage = ref('');
const promptSaveError = ref(false);
const showFullPromptStructure = ref(false);
const originalDefaultPrompt = `Generate a comprehensive summary that includes the following sections:
- **Key Issues Discussed**: A bulleted list of the main topics
- **Key Decisions Made**: A bulleted list of any decisions reached
- **Action Items**: A bulleted list of tasks assigned, including who is responsible if mentioned`;
// Computed properties
const filteredUsers = computed(() => {
if (!userSearchQuery.value) return users.value;
const query = userSearchQuery.value.toLowerCase();
return users.value.filter(user =>
user.username.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
});
const availableUsers = computed(() => {
if (!currentTeam.value) return users.value;
const memberUserIds = teamMembers.value.map(m => m.user_id);
return users.value.filter(user => !memberUserIds.includes(user.id));
});
// Methods
const loadUsers = async () => {
isLoadingUsers.value = true;
try {
const response = await fetch('/admin/users');
if (!response.ok) throw new Error('Failed to load users');
const data = await response.json();
users.value = data;
} catch (error) {
console.error('Error loading users:', error);
// Show error notification
} finally {
isLoadingUsers.value = false;
}
};
const loadStats = async () => {
try {
const response = await fetch('/admin/stats');
if (!response.ok) throw new Error('Failed to load statistics');
const data = await response.json();
stats.value = data;
} catch (error) {
console.error('Error loading statistics:', error);
// Show error notification
}
};
const loadSettings = async () => {
isLoadingSettings.value = true;
try {
const response = await fetch('/admin/settings');
if (response.status === 429) {
throw new Error('Rate limit exceeded. Please wait a moment and try again.');
}
if (!response.ok) throw new Error('Failed to load settings');
const data = await response.json();
// Sort so related settings are grouped together
const settingOrder = [
'transcript_length_limit',
'max_file_size_mb',
'asr_timeout_seconds',
'admin_default_summary_prompt',
'recording_disclaimer',
'upload_disclaimer',
'custom_banner',
'disable_auto_summarization',
'enable_folders'
];
data.sort((a, b) => {
const ai = settingOrder.indexOf(a.key);
const bi = settingOrder.indexOf(b.key);
// Unknown keys go to the end in original order
const aOrder = ai === -1 ? settingOrder.length : ai;
const bOrder = bi === -1 ? settingOrder.length : bi;
return aOrder - bOrder;
});
settings.value = data;
} catch (error) {
console.error('Error loading settings:', error);
// Show error notification
} finally {
isLoadingSettings.value = false;
}
};
// Token Stats Functions
const loadTokenStats = async () => {
try {
// Load summary stats
const statsResponse = await fetch('/admin/token-stats');
if (statsResponse.ok) {
tokenStats.value = await statsResponse.json();
}
// Load user stats
const userStatsResponse = await fetch('/admin/token-stats/users');
if (userStatsResponse.ok) {
const data = await userStatsResponse.json();
tokenUserStats.value = data.users || [];
}
// Load and render charts
await loadTokenCharts();
} catch (error) {
console.error('Error loading token stats:', error);
}
};
const loadTokenCharts = async () => {
try {
// Load daily stats
const dailyResponse = await fetch('/admin/token-stats/daily?days=30');
if (dailyResponse.ok) {
const dailyData = await dailyResponse.json();
renderDailyChart(dailyData.stats);
}
// Load monthly stats
const monthlyResponse = await fetch('/admin/token-stats/monthly?months=12');
if (monthlyResponse.ok) {
const monthlyData = await monthlyResponse.json();
renderMonthlyChart(monthlyData.stats);
}
} catch (error) {
console.error('Error loading token charts:', error);
}
};
// Transcription Stats Functions
const loadTranscriptionStats = async () => {
try {
// Load summary stats
const statsResponse = await fetch('/admin/transcription-stats');
if (statsResponse.ok) {
transcriptionStats.value = await statsResponse.json();
}
// Load user stats
const userStatsResponse = await fetch('/admin/transcription-stats/users');
if (userStatsResponse.ok) {
const data = await userStatsResponse.json();
transcriptionUserStats.value = data.users || [];
}
} catch (error) {
console.error('Error loading transcription stats:', error);
}
};
const renderDailyChart = (data) => {
const ctx = document.getElementById('dailyTokenChart');
if (!ctx) return;
// Destroy existing chart if any
if (dailyTokenChart.value) {
dailyTokenChart.value.destroy();
}
const labels = data.map(d => d.date);
const tokens = data.map(d => d.total);
// Detect dark mode
const isDark = document.documentElement.classList.contains('dark');
const textColor = isDark ? '#9ca3af' : '#6b7280';
const gridColor = isDark ? '#374151' : '#e5e7eb';
dailyTokenChart.value = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Tokens',
data: tokens,
backgroundColor: isDark ? 'rgba(99, 102, 241, 0.7)' : 'rgba(79, 70, 229, 0.7)',
borderColor: isDark ? 'rgba(99, 102, 241, 1)' : 'rgba(79, 70, 229, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
ticks: { color: textColor, maxRotation: 45, minRotation: 45 },
grid: { color: gridColor }
},
y: {
beginAtZero: true,
ticks: { color: textColor },
grid: { color: gridColor }
}
}
}
});
};
const renderMonthlyChart = (data) => {
const ctx = document.getElementById('monthlyTokenChart');
if (!ctx) return;
// Destroy existing chart if any
if (monthlyTokenChart.value) {
monthlyTokenChart.value.destroy();
}
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const labels = data.map(d => `${monthNames[d.month - 1]} ${d.year}`);
const tokens = data.map(d => d.tokens);
// Detect dark mode
const isDark = document.documentElement.classList.contains('dark');
const textColor = isDark ? '#9ca3af' : '#6b7280';
const gridColor = isDark ? '#374151' : '#e5e7eb';
monthlyTokenChart.value = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Tokens',
data: tokens,
fill: true,
backgroundColor: isDark ? 'rgba(34, 197, 94, 0.2)' : 'rgba(22, 163, 74, 0.2)',
borderColor: isDark ? 'rgba(34, 197, 94, 1)' : 'rgba(22, 163, 74, 1)',
borderWidth: 2,
tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
ticks: { color: textColor },
grid: { color: gridColor }
},
y: {
beginAtZero: true,
ticks: { color: textColor },
grid: { color: gridColor }
}
}
}
});
};
const showEditSettingModal = ref(false);
const editingSetting = ref(null);
const settingNewValue = ref('');
const settingUseNoLimit = ref(false);
const settingTimeoutMinutes = ref(30);
const settingTimeoutSeconds = ref(0);
const settingError = ref('');
const editSetting = (setting) => {
editingSetting.value = { ...setting };
settingError.value = '';
// Initialize form values based on setting type and key
if (setting.key === 'transcript_length_limit') {
if (setting.value === '-1') {
settingUseNoLimit.value = true;
settingNewValue.value = 30000; // Default if unchecked
} else {
settingUseNoLimit.value = false;
settingNewValue.value = parseInt(setting.value) || 30000;
}
} else if (setting.key === 'asr_timeout_seconds') {
const totalSeconds = parseInt(setting.value) || 1800;
settingTimeoutMinutes.value = Math.floor(totalSeconds / 60);
settingTimeoutSeconds.value = totalSeconds % 60;
} else if (setting.setting_type === 'boolean') {
settingNewValue.value = setting.value || 'false';
} else {
settingNewValue.value = setting.value || '';
}
showEditSettingModal.value = true;
};
const saveSettingValue = async () => {
settingError.value = '';
let finalValue;
// Validate and prepare the value based on setting type
if (editingSetting.value.key === 'transcript_length_limit') {
if (settingUseNoLimit.value) {
finalValue = '-1';
} else {
const num = parseInt(settingNewValue.value);
if (isNaN(num) || num < 1000) {
settingError.value = 'Please enter a valid number of at least 1000 characters';
return;
}
finalValue = num.toString();
}
} else if (editingSetting.value.key === 'max_file_size_mb') {
const num = parseInt(settingNewValue.value);
if (isNaN(num) || num < 1 || num > 10000) {
settingError.value = 'Please enter a valid size between 1 and 10000 MB';
return;
}
finalValue = num.toString();
} else if (editingSetting.value.key === 'asr_timeout_seconds') {
const totalSeconds = (settingTimeoutMinutes.value * 60) + settingTimeoutSeconds.value;
if (totalSeconds < 60) {
settingError.value = 'Timeout must be at least 60 seconds';
return;
}
if (totalSeconds > 36000) {
settingError.value = 'Timeout cannot exceed 10 hours (36000 seconds)';
return;
}
finalValue = totalSeconds.toString();
} else if (editingSetting.value.setting_type === 'integer') {
const num = parseInt(settingNewValue.value);
if (isNaN(num)) {
settingError.value = 'Please enter a valid integer';
return;
}
finalValue = num.toString();
} else if (editingSetting.value.setting_type === 'float') {
const num = parseFloat(settingNewValue.value);
if (isNaN(num)) {
settingError.value = 'Please enter a valid number';
return;
}
finalValue = num.toString();
} else {
finalValue = settingNewValue.value;
}
// Save the setting
await updateSetting(
editingSetting.value.key,
finalValue,
editingSetting.value.description,
editingSetting.value.setting_type
);
showEditSettingModal.value = false;
};
const updateSetting = async (key, value, description, settingType) => {
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {
'Content-Type': 'application/json'
};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch('/admin/settings', {
method: 'POST',
headers: headers,
body: JSON.stringify({
key: key,
value: value,
description: description,
setting_type: settingType
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update setting');
}
// Reload settings
await loadSettings();
} catch (error) {
console.error('Error updating setting:', error);
alert(error.message);
}
};
const formatDate = (dateString) => {
if (!dateString) return t('adminDashboard.never');
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
};
const getSettingDescription = (setting) => {
// Map setting keys to translation keys
const descriptionKeys = {
'transcript_length_limit': 'adminDashboard.settings.transcriptLengthLimitDesc',
'max_file_size_mb': 'adminDashboard.settings.maxFileSizeDesc',
'asr_timeout_seconds': 'adminDashboard.settings.asrTimeoutDesc',
'admin_default_summary_prompt': 'adminDashboard.settings.defaultSummaryPromptDesc',
'recording_disclaimer': 'adminDashboard.settings.recordingDisclaimerDesc',
'upload_disclaimer': 'adminDashboard.settings.uploadDisclaimerDesc',
'custom_banner': 'adminDashboard.settings.customBannerDesc'
};
const key = descriptionKeys[setting.key];
return key ? t(key) : setting.description || t('adminDashboard.noDescriptionAvailable');
};
const addUser = async () => {
if (newUser.value.password !== newUser.value.confirmPassword) {
alert(t('adminDashboard.passwordsDoNotMatch'));
return;
}
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {
'Content-Type': 'application/json'
};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch('/admin/users', {
method: 'POST',
headers: headers,
body: JSON.stringify({
username: newUser.value.username,
email: newUser.value.email,
password: newUser.value.password,
is_admin: newUser.value.isAdmin,
monthly_token_budget: newUser.value.monthlyTokenBudget || null,
monthly_transcription_budget: newUser.value.monthlyTranscriptionBudgetMinutes ? newUser.value.monthlyTranscriptionBudgetMinutes * 60 : null
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to add user');
}
// Reset form and close modal
newUser.value = {
username: '',
email: '',
password: '',
confirmPassword: '',
isAdmin: false,
monthlyTokenBudget: null,
monthlyTranscriptionBudgetMinutes: null
};
showAddUserModal.value = false;
// Reload users
await loadUsers();
await loadStats();
} catch (error) {
console.error('Error adding user:', error);
alert(error.message);
}
};
const editUser = (user) => {
editingUser.value = { ...user, password: '' };
showEditUserModal.value = true;
};
const updateUser = async () => {
try {
// Convert transcription budget from minutes to seconds
const transcriptionBudgetSeconds = editingUser.value.monthly_transcription_budget_minutes
? editingUser.value.monthly_transcription_budget_minutes * 60
: null;
const payload = {
username: editingUser.value.username,
email: editingUser.value.email,
is_admin: editingUser.value.is_admin,
monthly_token_budget: editingUser.value.monthly_token_budget || null,
monthly_transcription_budget: transcriptionBudgetSeconds
};
// Only include password if it was changed
if (editingUser.value.password) {
payload.password = editingUser.value.password;
}
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {
'Content-Type': 'application/json'
};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch(`/admin/users/${editingUser.value.id}`, {
method: 'PUT',
headers: headers,
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update user');
}
// Close modal and reload users
showEditUserModal.value = false;
await loadUsers();
} catch (error) {
console.error('Error updating user:', error);
alert(error.message);
}
};
const confirmDeleteUser = (user) => {
userToDelete.value = user;
showDeleteUserModal.value = true;
};
const deleteUser = async () => {
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch(`/admin/users/${userToDelete.value.id}`, {
method: 'DELETE',
headers: headers
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete user');
}
// Close modal and reload users
showDeleteUserModal.value = false;
await loadUsers();
await loadStats();
} catch (error) {
console.error('Error deleting user:', error);
alert(error.message);
}
};
const toggleAdminStatus = async (user) => {
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch(`/admin/users/${user.id}/toggle-admin`, {
method: 'POST',
headers: headers
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to toggle admin status');
}
// Reload users
await loadUsers();
} catch (error) {
console.error('Error toggling admin status:', error);
alert(error.message);
}
};
const togglePublicSharingPermission = async (user) => {
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = { 'Content-Type': 'application/json' };
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch(`/admin/users/${user.id}`, {
method: 'PUT',
headers: headers,
body: JSON.stringify({
can_share_publicly: !user.can_share_publicly
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to toggle public sharing permission');
}
// Reload users
await loadUsers();
} catch (error) {
console.error('Error toggling public sharing permission:', error);
alert(error.message);
}
};
// Group Management Functions
const loadGroups = async () => {
try {
const response = await fetch('/api/admin/groups');
if (!response.ok) throw new Error('Failed to load groups');
const data = await response.json();
groups.value = data.groups || [];
} catch (error) {
console.error('Error loading teams:', error);
}
};
const openCreateTeamModal = () => {
editingTeam.value = null;
teamForm.value = { name: '', description: '' };
teamError.value = '';
showTeamModal.value = true;
};
const openEditTeamModal = (group) => {
editingTeam.value = group;
teamForm.value = { name: group.name, description: group.description || '' };
teamError.value = '';
showTeamModal.value = true;
};
const closeTeamModal = () => {
showTeamModal.value = false;
editingTeam.value = null;
teamForm.value = { name: '', description: '' };
teamError.value = '';
};
const saveTeam = async () => {
teamError.value = '';
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = { 'Content-Type': 'application/json' };
if (csrfToken) headers['X-CSRFToken'] = csrfToken;
const url = editingTeam.value
? `/api/admin/groups/${editingTeam.value.id}`
: '/api/admin/groups';
const method = editingTeam.value ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers,
body: JSON.stringify(teamForm.value)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to save group');
}
closeTeamModal();
await loadGroups();
} catch (error) {
teamError.value = error.message;
}
};
const confirmDeleteTeam = (group) => {
teamToDelete.value = group;
showDeleteTeamModal.value = true;
};
const deleteTeam = async () => {
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) headers['X-CSRFToken'] = csrfToken;
const response = await fetch(`/api/admin/groups/${teamToDelete.value.id}`, {
method: 'DELETE',
headers
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete group');
}
showDeleteTeamModal.value = false;
teamToDelete.value = null;
await loadGroups();
} catch (error) {
alert(error.message);
}
};
const openManageTeamModal = async (group) => {
currentTeam.value = group;
newMemberUserId.value = '';
newMemberRole.value = 'member';
teamMemberError.value = '';
showManageTeamModal.value = true;
await loadTeamMembers(group.id);
};
const closeManageTeamModal = () => {
showManageTeamModal.value = false;
currentTeam.value = null;
teamMembers.value = [];
newMemberUserId.value = '';
newMemberRole.value = 'member';
teamMemberError.value = '';
};
const loadTeamMembers = async (groupId) => {
try {
const response = await fetch(`/api/admin/groups/${groupId}`);
if (!response.ok) throw new Error('Failed to load group members');
const data = await response.json();
teamMembers.value = data.members || [];
} catch (error) {
console.error('Error loading group members:', error);
teamMemberError.value = error.message;
}
};
const addTeamMember = async () => {
if (!newMemberUserId.value) return;
teamMemberError.value = '';
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = { 'Content-Type': 'application/json' };
if (csrfToken) headers['X-CSRFToken'] = csrfToken;
const response = await fetch(`/api/admin/groups/${currentTeam.value.id}/members`, {
method: 'POST',
headers,
body: JSON.stringify({
user_id: parseInt(newMemberUserId.value),
role: newMemberRole.value
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to add member');
}
newMemberUserId.value = '';
newMemberRole.value = 'member';
await loadTeamMembers(currentTeam.value.id);
await loadGroups(); // Refresh to update member count
} catch (error) {
teamMemberError.value = error.message;
}
};
const updateMemberRole = async (member) => {
teamMemberError.value = '';
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = { 'Content-Type': 'application/json' };
if (csrfToken) headers['X-CSRFToken'] = csrfToken;
const response = await fetch(`/api/admin/groups/${currentTeam.value.id}/members/${member.user_id}`, {
method: 'PUT',
headers,
body: JSON.stringify({ role: member.role })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to update role');
}
} catch (error) {
teamMemberError.value = error.message;
await loadTeamMembers(currentTeam.value.id); // Reload to revert on error
}
};
const removeTeamMember = async (member) => {
if (!confirm(`Remove ${member.username} from the group?`)) return;
teamMemberError.value = '';
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) headers['X-CSRFToken'] = csrfToken;
const response = await fetch(`/api/admin/groups/${currentTeam.value.id}/members/${member.user_id}`, {
method: 'DELETE',
headers
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to remove member');
}
await loadTeamMembers(currentTeam.value.id);
await loadGroups(); // Refresh to update member count
} catch (error) {
teamMemberError.value = error.message;
}
};
const syncTeamShares = () => {
if (!currentTeam.value) return;
showSyncSharesModal.value = true;
};
const confirmSyncShares = async () => {
showSyncSharesModal.value = false;
teamMemberError.value = '';
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = { 'Content-Type': 'application/json' };
if (csrfToken) headers['X-CSRFToken'] = csrfToken;
const response = await fetch(`/api/groups/${currentTeam.value.id}/sync-shares`, {
method: 'POST',
headers
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to sync shares');
}
const result = await response.json();
syncResults.value = {
shares_created: result.shares_created,
recordings_processed: result.recordings_processed
};
showSyncResultsModal.value = true;
} catch (error) {
teamMemberError.value = error.message;
}
};
// Group Tags Management
const openManageTeamTagsModal = async (group) => {
currentTeam.value = group;
teamTagError.value = '';
await loadTeamTags(group.id);
showManageTeamTagsModal.value = true;
};
const closeManageTeamTagsModal = () => {
showManageTeamTagsModal.value = false;
currentTeam.value = null;
teamTags.value = [];
editingTeamTagId.value = null;
newTeamTag.value = {
name: '',
color: '#3B82F6',
custom_prompt: '',
default_language: '',
default_min_speakers: null,
default_max_speakers: null,
retention_days: null,
protect_from_deletion: false,
auto_share_on_apply: true,
share_with_group_lead: true
};
teamTagError.value = '';
};
const loadTeamTags = async (groupId) => {
try {
const response = await fetch(`/api/groups/${groupId}/tags`);
if (!response.ok) throw new Error('Failed to load group tags');
const data = await response.json();
teamTags.value = data.tags || [];
} catch (error) {
teamTagError.value = error.message;
}
};
const editTeamTag = (tag) => {
editingTeamTagId.value = tag.id;
newTeamTag.value = {
name: tag.name,
color: tag.color,
custom_prompt: tag.custom_prompt || '',
default_language: tag.default_language || '',
default_min_speakers: tag.default_min_speakers,
default_max_speakers: tag.default_max_speakers,
retention_days: tag.retention_days,
protect_from_deletion: tag.protect_from_deletion || false,
auto_share_on_apply: tag.auto_share_on_apply !== undefined ? tag.auto_share_on_apply : true,
share_with_group_lead: tag.share_with_group_lead !== undefined ? tag.share_with_group_lead : true
};
// Scroll to top of modal
document.querySelector('.max-h-\\[90vh\\]')?.scrollTo({ top: 0, behavior: 'smooth' });
};
const cancelEditTeamTag = () => {
editingTeamTagId.value = null;
newTeamTag.value = {
name: '',
color: '#3B82F6',
custom_prompt: '',
default_language: '',
default_min_speakers: null,
default_max_speakers: null,
retention_days: null,
protect_from_deletion: false,
auto_share_on_apply: true,
share_with_group_lead: true
};
teamTagError.value = '';
};
const saveTeamTag = async () => {
teamTagError.value = '';
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = { 'Content-Type': 'application/json' };
if (csrfToken) headers['X-CSRFToken'] = csrfToken;
const payload = {
name: newTeamTag.value.name,
color: newTeamTag.value.color,
protect_from_deletion: newTeamTag.value.protect_from_deletion,
auto_share_on_apply: newTeamTag.value.auto_share_on_apply,
share_with_group_lead: newTeamTag.value.share_with_group_lead
};
// Add custom prompt if provided
if (newTeamTag.value.custom_prompt && newTeamTag.value.custom_prompt.trim()) {
payload.custom_prompt = newTeamTag.value.custom_prompt.trim();
}
// Add language if selected
if (newTeamTag.value.default_language) {
payload.default_language = newTeamTag.value.default_language;
}
// Add speaker settings if provided
if (newTeamTag.value.default_min_speakers) {
payload.default_min_speakers = newTeamTag.value.default_min_speakers;
}
if (newTeamTag.value.default_max_speakers) {
payload.default_max_speakers = newTeamTag.value.default_max_speakers;
}
// Only include retention_days if it's set and > 0
if (newTeamTag.value.retention_days && newTeamTag.value.retention_days > 0) {
payload.retention_days = newTeamTag.value.retention_days;
}
let response;
if (editingTeamTagId.value) {
// Update existing tag
response = await fetch(`/api/tags/${editingTeamTagId.value}`, {
method: 'PUT',
headers,
body: JSON.stringify(payload)
});
} else {
// Create new tag
response = await fetch(`/api/groups/${currentTeam.value.id}/tags`, {
method: 'POST',
headers,
body: JSON.stringify(payload)
});
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || (editingTeamTagId.value ? 'Failed to update tag' : 'Failed to create tag'));
}
// Reset form and reload
editingTeamTagId.value = null;
newTeamTag.value = {
name: '',
color: '#3B82F6',
custom_prompt: '',
default_language: '',
default_min_speakers: null,
default_max_speakers: null,
retention_days: null,
protect_from_deletion: false,
auto_share_on_apply: true,
share_with_group_lead: true
};
await loadTeamTags(currentTeam.value.id);
} catch (error) {
teamTagError.value = error.message;
}
};
const deleteTeamTag = async (tag) => {
if (!confirm(`Delete the tag "${tag.name}"? This will remove the tag from all recordings.`)) return;
teamTagError.value = '';
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
const headers = {};
if (csrfToken) headers['X-CSRFToken'] = csrfToken;
const response = await fetch(`/api/tags/${tag.id}`, {
method: 'DELETE',
headers
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete tag');
}
await loadTeamTags(currentTeam.value.id);
} catch (error) {
teamTagError.value = error.message;
}
};
const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const toggleDarkMode = () => {
isDarkMode.value = !isDarkMode.value;
if (isDarkMode.value) {
document.documentElement.classList.add('dark');
localStorage.setItem('darkMode', 'true');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('darkMode', 'false');
}
};
// Initialize dark mode
const initializeDarkMode = () => {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const savedMode = localStorage.getItem('darkMode');
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
isDarkMode.value = true;
document.documentElement.classList.add('dark');
} else {
isDarkMode.value = false;
document.documentElement.classList.remove('dark');
}
};
/**
* Set up global click handler to close dropdowns when clicking outside
* Provides elegant UX by closing menus when users click elsewhere
*/
const setupGlobalClickHandler = () => {
document.addEventListener('click', (event) => {
const target = event.target;
// Close user menu if clicking outside of it
if (isUserMenuOpen.value) {
const userMenuButton = target.closest('[data-user-menu-toggle]');
const userMenuDropdown = target.closest('[data-user-menu-dropdown]');
if (!userMenuButton && !userMenuDropdown) {
isUserMenuOpen.value = false;
}
}
});
};
// Inquire Mode methods
// Default Prompts methods
const loadDefaultPrompt = async () => {
try {
// Use already loaded settings if available, otherwise load them
let settingsData = settings.value;
if (!settingsData || settingsData.length === 0) {
const response = await fetch('/admin/settings');
if (response.status === 429) {
throw new Error('Rate limit exceeded. Please wait a moment and try again.');
}
if (!response.ok) throw new Error('Failed to load settings');
settingsData = await response.json();
settings.value = settingsData;
}
const promptSetting = settingsData.find(s => s.key === 'admin_default_summary_prompt');
if (promptSetting) {
defaultSummaryPrompt.value = promptSetting.value || '';
}
} catch (error) {
console.error('Error loading default prompt:', error);
promptSaveMessage.value = t('adminDashboard.errors.failedToLoadPrompt');
promptSaveError.value = true;
}
};
const saveDefaultPrompt = async () => {
isSavingPrompt.value = true;
promptSaveMessage.value = '';
promptSaveError.value = false;
try {
const response = await fetch('/admin/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
key: 'admin_default_summary_prompt',
value: defaultSummaryPrompt.value,
description: 'Default summarization prompt used when users have not set their own prompt. This serves as the base prompt for all users.',
setting_type: 'string'
})
});
if (!response.ok) throw new Error('Failed to save prompt');
promptSaveMessage.value = t('adminDashboard.promptSavedSuccessfully');
promptSaveError.value = false;
// Clear message after 3 seconds
setTimeout(() => {
promptSaveMessage.value = '';
}, 3000);
} catch (error) {
console.error('Error saving default prompt:', error);
promptSaveMessage.value = t('adminDashboard.errors.failedToSavePrompt');
promptSaveError.value = true;
} finally {
isSavingPrompt.value = false;
}
};
const resetDefaultPrompt = () => {
defaultSummaryPrompt.value = originalDefaultPrompt;
promptSaveMessage.value = t('adminDashboard.promptResetMessage');
promptSaveError.value = false;
};
const loadInquireStatus = async () => {
try {
const response = await fetch('/admin/inquire/status');
if (!response.ok) throw new Error('Failed to load inquire status');
const data = await response.json();
inquireStatus.value = data;
} catch (error) {
console.error('Error loading inquire status:', error);
}
};
const processAllRecordings = async () => {
if (isProcessingRecordings.value) return;
isProcessingRecordings.value = true;
processingResult.value = null;
try {
const response = await fetch('/admin/inquire/process-recordings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({})
});
if (!response.ok) throw new Error('Failed to process recordings');
const data = await response.json();
processingResult.value = data;
// Reload status after processing
await loadInquireStatus();
} catch (error) {
console.error('Error processing recordings:', error);
processingResult.value = {
success: false,
message: 'Failed to process recordings. Please try again.'
};
} finally {
isProcessingRecordings.value = false;
}
};
const processBatchRecordings = async (batchSize) => {
if (isProcessingRecordings.value) return;
isProcessingRecordings.value = true;
processingResult.value = null;
try {
const response = await fetch('/admin/inquire/process-recordings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({ max_recordings: batchSize })
});
if (!response.ok) throw new Error('Failed to process recordings');
const data = await response.json();
processingResult.value = data;
// Reload status after processing
await loadInquireStatus();
} catch (error) {
console.error('Error processing recordings:', error);
processingResult.value = {
success: false,
message: 'Failed to process recordings. Please try again.'
};
} finally {
isProcessingRecordings.value = false;
}
};
const getProcessingProgress = () => {
const total = (inquireStatus.value.processed_for_inquire || 0) + (inquireStatus.value.need_processing || 0);
if (total === 0) return 100;
return Math.round((inquireStatus.value.processed_for_inquire / total) * 100);
};
// Lifecycle hooks - MUST be registered before any await statements
onMounted(async () => {
// Only load admin-specific data if user is full admin
if (!isTeamAdminOnly.value) {
loadUsers();
loadStats();
loadTokenStats();
loadTranscriptionStats();
// Load settings first, then default prompt can use that data
if (activeTab.value === 'settings' || activeTab.value === 'prompts') {
await loadSettings();
}
loadDefaultPrompt();
if (inquireModeEnabled.value) {
loadInquireStatus();
}
} else {
// Group admins only need users list (for adding members) and groups
loadUsers();
loadGroups();
}
initializeDarkMode();
setupGlobalClickHandler();
// Setup scroll indicators for tabs
const tabsContainer = document.getElementById('admin-tabs-container');
const leftIndicator = document.getElementById('admin-left-scroll-indicator');
const rightIndicator = document.getElementById('admin-right-scroll-indicator');
function updateScrollIndicators() {
if (tabsContainer && leftIndicator && rightIndicator) {
const scrollLeft = tabsContainer.scrollLeft;
const scrollWidth = tabsContainer.scrollWidth;
const clientWidth = tabsContainer.clientWidth;
// Show/hide left indicator
if (scrollLeft > 10) {
leftIndicator.classList.remove('opacity-0');
leftIndicator.classList.add('opacity-100');
} else {
leftIndicator.classList.remove('opacity-100');
leftIndicator.classList.add('opacity-0');
}
// Show/hide right indicator
if (scrollWidth - scrollLeft - clientWidth > 10) {
rightIndicator.classList.remove('opacity-0');
rightIndicator.classList.add('opacity-100');
} else {
rightIndicator.classList.remove('opacity-100');
rightIndicator.classList.add('opacity-0');
}
}
}
if (tabsContainer) {
tabsContainer.addEventListener('scroll', updateScrollIndicators);
window.addEventListener('resize', updateScrollIndicators);
// Initial check
updateScrollIndicators();
setTimeout(updateScrollIndicators, 100);
setTimeout(updateScrollIndicators, 500);
}
});
// === Journal d'audit functions ===
const auditActionLabels = {
view: 'Consultation', download: 'T\u00e9l\u00e9chargement', edit: 'Modification',
delete: 'Suppression', export: 'Export', share: 'Partage',
login: 'Connexion', logout: 'D\u00e9connexion', failed_login: 'Tentative \u00e9chou\u00e9e',
register: 'Inscription', password_change: 'Changement MDP',
password_reset: 'R\u00e9initialisation MDP', sso_login: 'Connexion SSO'
};
const auditResourceLabels = {
recording: 'Enregistrement', audio: 'Audio', transcript: 'Transcription',
user: 'Utilisateur', summary: 'R\u00e9sum\u00e9'
};
const auditActionLabel = (action) => auditActionLabels[action] || action;
const auditResourceLabel = (type) => auditResourceLabels[type] || type;
const auditStatusClass = (status) => {
if (status === 'success') return 'bg-green-100 text-green-800';
if (status === 'denied') return 'bg-amber-100 text-amber-800';
if (status === 'error') return 'bg-red-100 text-red-800';
return 'bg-gray-100 text-gray-800';
};
const auditAuthActionClass = (action) => {
if (action === 'failed_login') return 'bg-red-100 text-red-800';
if (action === 'login' || action === 'sso_login') return 'bg-green-100 text-green-800';
if (action === 'logout') return 'bg-gray-100 text-gray-800';
if (action === 'register') return 'bg-blue-100 text-blue-800';
return 'bg-amber-100 text-amber-800';
};
const loadAuditStatus = async () => {
try {
const response = await fetch('/api/admin/audit/status');
if (response.ok) {
const data = await response.json();
auditEnabled.value = data.enabled;
}
} catch (error) {
console.error('Error loading audit status:', error);
}
};
const loadAuditAccess = async () => {
try {
let url = `/api/admin/audit/access?page=${auditPage.value}&per_page=${auditPerPage.value}`;
if (auditFilterAction.value) url += `&action=${auditFilterAction.value}`;
if (auditFilterResource.value) url += `&resource_type=${auditFilterResource.value}`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
auditAccessLogs.value = data.logs;
auditAccessTotal.value = data.total;
auditAccessPages.value = data.pages;
}
} catch (error) {
console.error('Error loading access logs:', error);
}
};
const loadAuditAuth = async () => {
try {
let url = `/api/admin/audit/auth?page=${auditPage.value}&per_page=${auditPerPage.value}`;
if (auditFilterAction.value) url += `&action=${auditFilterAction.value}`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
auditAuthLogs.value = data.logs;
auditAuthTotal.value = data.total;
auditAuthPages.value = data.pages;
}
} catch (error) {
console.error('Error loading auth logs:', error);
}
};
const loadAuditData = async () => {
isLoadingAudit.value = true;
try {
await loadAuditStatus();
if (auditSubTab.value === 'access') {
await loadAuditAccess();
} else {
await loadAuditAuth();
}
} finally {
isLoadingAudit.value = false;
}
};
const exportAuditCSV = async () => {
try {
const type = auditSubTab.value;
const endpoint = type === 'access' ? '/api/admin/audit/access' : '/api/admin/audit/auth';
let url = `${endpoint}?page=1&per_page=200`;
if (auditFilterAction.value) url += `&action=${auditFilterAction.value}`;
if (type === 'access' && auditFilterResource.value) url += `&resource_type=${auditFilterResource.value}`;
const response = await fetch(url);
if (!response.ok) return;
const data = await response.json();
let csv = '';
if (type === 'access') {
csv = 'Date,Utilisateur,Action,Ressource,ID Ressource,Statut,IP\n';
data.logs.forEach(log => {
csv += `${log.timestamp},${log.username || ''},${log.action},${log.resource_type || ''},${log.resource_id || ''},${log.status},${log.ip_address || ''}\n`;
});
} else {
csv = 'Date,Utilisateur,Action,IP,D\u00e9tails\n';
data.logs.forEach(log => {
const details = log.details ? JSON.stringify(log.details).replace(/,/g, ';') : '';
csv += `${log.timestamp},${log.username || ''},${log.action},${log.ip_address || ''},${details}\n`;
});
}
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `audit-${type}-${new Date().toISOString().slice(0,10)}.csv`;
link.click();
URL.revokeObjectURL(link.href);
} catch (error) {
console.error('Error exporting CSV:', error);
}
};
// Watch for tab changes to reload data
watch(activeTab, async (newTab) => {
// Only allow full admins to load admin-specific data
if (!isTeamAdminOnly.value) {
if (newTab === 'users') {
loadUsers();
} else if (newTab === 'stats') {
loadStats();
} else if (newTab === 'settings') {
await loadSettings();
} else if (newTab === 'prompts') {
// If settings aren't loaded, load them first
if (!settings.value || settings.value.length === 0) {
await loadSettings();
}
loadDefaultPrompt();
} else if (newTab === 'audit') {
loadAuditData();
} else if (newTab === 'vectorstore' && inquireModeEnabled.value) {
loadInquireStatus();
}
}
// Both full admins and group admins can access teams
if (newTab === 'groups') {
loadGroups();
loadUsers(); // Load users for member selection
}
});
return {
// i18n - bind to i18n instance to maintain context
t: (key, params) => i18nInstance ? i18nInstance.t(key, params) : key,
// State
isTeamAdminOnly,
activeTab,
users,
stats,
tokenStats,
tokenUserStats,
transcriptionStats,
transcriptionUserStats,
settings,
isLoadingUsers,
isLoadingSettings,
userSearchQuery,
isDarkMode,
isUserMenuOpen,
// Modal state
showAddUserModal,
showEditUserModal,
showDeleteUserModal,
newUser,
editingUser,
userToDelete,
// Settings modal state
showEditSettingModal,
editingSetting,
settingNewValue,
settingUseNoLimit,
settingTimeoutMinutes,
settingTimeoutSeconds,
settingError,
// Inquire Mode state
inquireModeEnabled,
inquireStatus,
isProcessingRecordings,
processingResult,
// Teams state
groups,
showTeamModal,
showManageTeamModal,
showDeleteTeamModal,
showSyncSharesModal,
showSyncResultsModal,
syncResults,
editingTeam,
teamToDelete,
currentTeam,
teamForm,
teamError,
teamMembers,
newMemberUserId,
newMemberRole,
teamMemberError,
// Group Tags state
showManageTeamTagsModal,
teamTags,
editingTeamTagId,
newTeamTag,
teamTagError,
globalRetentionDays,
defaultSummaryPrompt,
isSavingPrompt,
promptSaveMessage,
promptSaveError,
showFullPromptStructure,
originalDefaultPrompt,
// Computed
filteredUsers,
availableUsers,
getProcessingProgress,
// Methods
loadUsers,
loadStats,
loadSettings,
loadTokenStats,
loadTranscriptionStats,
editSetting,
saveSettingValue,
updateSetting,
formatDate,
getSettingDescription,
addUser,
editUser,
updateUser,
confirmDeleteUser,
deleteUser,
toggleAdminStatus,
togglePublicSharingPermission,
formatFileSize,
toggleDarkMode,
// Group Management methods
loadGroups,
openCreateTeamModal,
openEditTeamModal,
closeTeamModal,
saveTeam,
confirmDeleteTeam,
deleteTeam,
openManageTeamModal,
closeManageTeamModal,
loadTeamMembers,
addTeamMember,
updateMemberRole,
removeTeamMember,
syncTeamShares,
confirmSyncShares,
// Group Tags methods
openManageTeamTagsModal,
closeManageTeamTagsModal,
loadTeamTags,
saveTeamTag,
editTeamTag,
cancelEditTeamTag,
deleteTeamTag,
// Inquire Mode methods
loadInquireStatus,
processAllRecordings,
processBatchRecordings,
// Journal d'audit state
auditSubTab,
auditAccessLogs,
auditAuthLogs,
auditAccessTotal,
auditAuthTotal,
auditPage,
auditPerPage,
auditAccessPages,
auditAuthPages,
auditFilterAction,
auditFilterResource,
isLoadingAudit,
auditEnabled,
auditTotalPages,
// Journal d'audit methods
auditActionLabel,
auditResourceLabel,
auditStatusClass,
auditAuthActionClass,
loadAuditData,
exportAuditCSV,
// Default Prompts methods
loadDefaultPrompt,
saveDefaultPrompt,
resetDefaultPrompt
};
},
delimiters: ['${', '}'] // Use different delimiters to avoid conflict with Flask's Jinja2
}).mount('#app');
// Hide loading overlay after Vue is mounted
Vue.nextTick(() => {
if (window.AppLoader) {
window.AppLoader.hide();
}
});
}); // End of initializeI18n().then()
</script>
</body>
</html>