3690 lines
232 KiB
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>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <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)]">×</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)]">×</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)]">×</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">×</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)]">×</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)]">×</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)]">×</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)]">×</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)]">×</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)]">×</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>
|