Files
LeDiscord/frontend/src/views/Admin.vue

2130 lines
80 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Administration</h1>
<!-- Loading state -->
<div v-if="loading" class="text-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
<p class="mt-4 text-gray-600">Chargement du dashboard...</p>
</div>
<!-- Admin content -->
<div v-else>
<!-- Overview Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">Total utilisateurs</p>
<p class="text-2xl font-bold text-gray-900">{{ dashboard.users.total }}</p>
</div>
<Users class="w-8 h-8 text-primary-600" />
</div>
<div class="mt-2 text-sm text-gray-500">
{{ dashboard.users.active }} actifs, {{ dashboard.users.admins }} admins
</div>
</div>
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">Événements</p>
<p class="text-2xl font-bold text-gray-900">{{ dashboard.content.events }}</p>
</div>
<Calendar class="w-8 h-8 text-success-600" />
</div>
</div>
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">Médias</p>
<p class="text-2xl font-bold text-gray-900">{{ dashboard.content.media_files }}</p>
</div>
<Image class="w-8 h-8 text-blue-600" />
</div>
</div>
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">Stockage total</p>
<p class="text-2xl font-bold text-gray-900">{{ dashboard.storage.total_formatted }}</p>
</div>
<HardDrive class="w-8 h-8 text-purple-600" />
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Storage Breakdown -->
<div class="card p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-6">Utilisation du stockage</h2>
<div class="space-y-4">
<div v-for="(category, name) in dashboard.storage.breakdown" :key="name">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700 capitalize">{{ name }}</span>
<span class="text-sm text-gray-600">{{ category.formatted }}</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-primary-600 h-2 rounded-full transition-all duration-300"
:style="`width: ${getStoragePercentage(category.bytes)}%`"
></div>
</div>
</div>
</div>
<div class="mt-6 pt-4 border-t border-gray-200">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700">Total</span>
<span class="text-lg font-bold text-gray-900">{{ dashboard.storage.total_formatted }}</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-6">Actions rapides</h2>
<div class="space-y-3">
<button
@click="showCleanupModal = true"
class="w-full btn-secondary text-left"
>
<Trash2 class="w-4 h-4 mr-2" />
Nettoyer les fichiers orphelins
</button>
<button
@click="refreshDashboard"
class="w-full btn-secondary text-left"
>
<RefreshCw class="w-4 h-4 mr-2" />
Actualiser les statistiques
</button>
<button
@click="exportUserData"
class="w-full btn-secondary text-left"
>
<Download class="w-4 h-4 mr-2" />
Exporter les données utilisateurs
</button>
</div>
</div>
</div>
<!-- System Settings -->
<div class="card p-6 mt-8">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900">Paramètres système</h2>
<div class="flex space-x-2">
<button
@click="initializeSettings"
class="btn-secondary"
>
<Settings class="w-4 h-4 mr-2" />
Initialiser les paramètres
</button>
<button
@click="refreshSettings"
class="btn-secondary"
>
<RefreshCw class="w-4 h-4 mr-2" />
Actualiser
</button>
<button
@click="testUploadLimits"
class="btn-secondary"
>
<TestTube class="w-4 h-4 mr-2" />
Tester les limites
</button>
</div>
</div>
<!-- Upload Limits -->
<div class="mb-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Limites d'upload</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label">Taille max albums (MB)</label>
<input
v-model="settings.max_album_size_mb"
type="number"
class="input"
min="1"
max="10000"
>
</div>
<div>
<label class="label">Taille max vlogs (MB)</label>
<input
v-model="settings.max_vlog_size_mb"
type="number"
class="input"
min="1"
max="10000"
>
</div>
<div>
<label class="label">Taille max images (MB)</label>
<input
v-model="settings.max_image_size_mb"
type="number"
class="input"
min="1"
max="1000"
>
</div>
<div>
<label class="label">Taille max vidéos (MB)</label>
<input
v-model="settings.max_video_size_mb"
type="number"
class="input"
min="1"
max="10000"
>
</div>
<div>
<label class="label">Nombre max de médias par album</label>
<input
v-model="settings.max_media_per_album"
type="number"
class="input"
min="1"
max="200"
>
<div class="mt-1 text-sm text-gray-600">
Limite le nombre de photos/vidéos qu'un utilisateur peut ajouter dans un album
</div>
</div>
</div>
<button
@click="saveUploadLimits"
class="btn-primary mt-4"
:disabled="savingSettings"
>
<Save class="w-4 h-4 mr-2" />
{{ savingSettings ? 'Sauvegarde...' : 'Sauvegarder les limites' }}
</button>
</div>
<!-- General Settings -->
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Paramètres généraux</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label">Nombre max d'utilisateurs</label>
<input
v-model="settings.max_users"
type="number"
class="input"
min="1"
max="1000"
>
<div class="mt-1 text-sm text-gray-600">
Actuellement : {{ users.length }}/{{ settings.max_users }} utilisateurs
<div class="w-full bg-gray-200 rounded-full h-2 mt-1">
<div
class="h-2 rounded-full transition-all duration-300"
:class="getUsersPercentage() >= 90 ? 'bg-red-500' : getUsersPercentage() >= 70 ? 'bg-yellow-500' : 'bg-green-500'"
:style="`width: ${getUsersPercentage()}%`"
></div>
</div>
</div>
</div>
<div class="flex items-center mt-6">
<input
v-model="settings.enable_registration"
type="checkbox"
id="enable_registration"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
>
<label for="enable_registration" class="ml-2 block text-sm text-gray-900">
Autoriser les nouvelles inscriptions
</label>
</div>
<div v-if="!settings.enable_registration" class="col-span-2">
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-800">
<strong>Attention :</strong> Les nouvelles inscriptions sont désactivées. Aucun nouvel utilisateur ne pourra s'inscrire.
</p>
</div>
</div>
</div>
</div>
</div>
<button
@click="saveGeneralSettings"
class="btn-primary mt-4"
:disabled="savingSettings"
>
<Save class="w-4 h-4 mr-2" />
{{ savingSettings ? 'Sauvegarde...' : 'Sauvegarder les paramètres' }}
</button>
</div>
</div>
<!-- Information Management -->
<div class="card p-6 mt-8">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900">Gestion des informations</h2>
<button
@click="showInformationModal = true"
class="btn-primary"
>
<Plus class="w-4 h-4 mr-2" />
Nouvelle information
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titre</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Catégorie</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Priorité</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="info in informations"
:key="info.id"
class="hover:bg-gray-50"
>
<td class="px-6 py-4 whitespace-nowrap">
<div>
<p class="font-medium text-gray-900">{{ info.title }}</p>
<p class="text-sm text-gray-600 line-clamp-2">{{ info.content.substring(0, 100) }}{{ info.content.length > 100 ? '...' : '' }}</p>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="getCategoryBadgeClass(info.category)"
>
{{ getCategoryLabel(info.category) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="info.is_published ? 'bg-success-100 text-success-800' : 'bg-yellow-100 text-yellow-800'"
>
{{ info.is_published ? 'Publié' : 'Brouillon' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ info.priority }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ formatDate(info.created_at) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex space-x-2">
<button
@click="editInformation(info)"
class="text-blue-600 hover:text-blue-900"
>
Modifier
</button>
<button
@click="toggleInformationPublish(info.id)"
class="text-green-600 hover:text-green-900"
>
{{ info.is_published ? 'Dépublier' : 'Publier' }}
</button>
<button
@click="deleteInformation(info.id)"
class="text-red-600 hover:text-red-900"
>
Supprimer
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Users Management -->
<div class="card p-6 mt-8">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900">Gestion des utilisateurs</h2>
<button
@click="refreshUsers"
class="btn-secondary"
>
<RefreshCw class="w-4 h-4 mr-2" />
Actualiser
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Utilisateur</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rôle</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Stockage</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Activité</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="user in users"
:key="user.id"
class="hover:bg-gray-50"
>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-3">
<img
v-if="user.avatar_url"
:src="user.avatar_url"
:alt="user.full_name"
class="w-8 h-8 rounded-full object-cover"
>
<div v-else class="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
<User class="w-4 h-4 text-gray-500" />
</div>
<div>
<p class="font-medium text-gray-900">{{ user.full_name }}</p>
<p class="text-sm text-gray-600">{{ user.email }}</p>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="user.is_active ? 'bg-success-100 text-success-800' : 'bg-accent-100 text-accent-800'"
>
{{ user.is_active ? 'Actif' : 'Inactif' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="user.is_admin ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-800'"
>
{{ user.is_admin ? 'Admin' : 'Utilisateur' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ user.storage_used }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900">
<div>{{ user.content_count.posts }} posts</div>
<div>{{ user.content_count.vlogs }} vlogs</div>
<div>{{ user.content_count.albums }} albums</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex space-x-2">
<button
@click="toggleUserStatus(user.id)"
:disabled="user.id === currentUser?.id"
class="text-sm"
:class="user.is_active ? 'text-accent-600 hover:text-accent-900' : 'text-success-600 hover:text-success-900'"
>
{{ user.is_active ? 'Désactiver' : 'Activer' }}
</button>
<button
@click="toggleUserAdmin(user.id)"
:disabled="user.id === currentUser?.id"
class="text-sm text-purple-600 hover:text-purple-900"
>
{{ user.is_admin ? 'Retirer admin' : 'Donner admin' }}
</button>
<button
@click="deleteUser(user.id)"
:disabled="user.id === currentUser?.id"
class="text-sm text-accent-600 hover:text-accent-900"
>
Supprimer
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Gestion des tickets -->
<div class="bg-white rounded-lg shadow p-6 mb-6 mt-8">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-2xl font-bold text-gray-900">Gestion des tickets</h3>
<p class="text-gray-600 mt-1">Suivi et résolution des demandes utilisateurs</p>
</div>
<div class="flex items-center space-x-3">
<div class="text-right">
<div class="text-2xl font-bold text-primary-600 stats-number">{{ filteredTickets.length }}</div>
<div class="text-sm text-gray-500">Tickets actifs</div>
</div>
<div class="w-px h-12 bg-gray-300"></div>
<div class="text-right">
<div class="text-2xl font-bold text-success-600 stats-number">{{ resolvedTicketsCount }}</div>
<div class="text-sm text-gray-500">Résolus</div>
</div>
</div>
</div>
<!-- Filtres avancés -->
<div class="bg-gray-50 rounded-xl p-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Recherche -->
<div class="relative">
<input
v-model="ticketFilters.search"
type="text"
placeholder="Rechercher un ticket..."
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent filter-input"
>
<svg class="absolute left-3 top-2.5 h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<!-- Statut -->
<select v-model="ticketFilters.status" class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
<option value="">Tous les statuts</option>
<option value="open">🔴 Ouverts</option>
<option value="in_progress">🟡 En cours</option>
<option value="resolved">🟢 Résolus</option>
<option value="closed"> Fermés</option>
</select>
<!-- Type -->
<select v-model="ticketFilters.type" class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
<option value="">Tous les types</option>
<option value="bug">🐛 Bug</option>
<option value="feature_request">💡 Fonctionnalité</option>
<option value="improvement"> Amélioration</option>
<option value="support"> Support</option>
<option value="other">📝 Autre</option>
</select>
<!-- Priorité -->
<select v-model="ticketFilters.priority" class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent">
<option value="">Toutes les priorités</option>
<option value="urgent">🔴 Urgente</option>
<option value="high">🟠 Élevée</option>
<option value="medium">🟡 Moyenne</option>
<option value="low">🟢 Faible</option>
</select>
</div>
<!-- Filtres rapides -->
<div class="flex flex-wrap gap-2 mt-4">
<button
@click="setQuickFilter('urgent')"
class="px-3 py-1 text-xs font-medium rounded-full bg-red-100 text-red-800 hover:bg-red-200 transition-colors quick-filter-btn"
>
🔴 Urgents ({{ urgentTicketsCount }})
</button>
<button
@click="setQuickFilter('unassigned')"
class="px-3 py-1 text-xs font-medium rounded-full bg-orange-100 text-orange-800 hover:bg-orange-200 transition-colors quick-filter-btn"
>
Non assignés ({{ unassignedTicketsCount }})
</button>
<button
@click="setQuickFilter('recent')"
class="px-3 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800 hover:bg-blue-200 transition-colors quick-filter-btn"
>
📅 Récents ({{ recentTicketsCount }})
</button>
<button
@click="clearFilters"
class="px-3 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-800 hover:bg-gray-200 transition-colors quick-filter-btn"
>
🗑 Effacer les filtres
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="ticketsLoading" class="text-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
<p class="text-gray-600">Chargement des tickets...</p>
</div>
<!-- Tickets Grid -->
<div v-else-if="filteredTickets.length > 0" class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
<div
v-for="ticket in filteredTickets"
:key="ticket.id"
v-if="ticket && ticket.id"
class="bg-white border border-gray-200 rounded-xl shadow-sm hover:shadow-lg transition-all duration-200 cursor-pointer group ticket-card"
@click="showTicketDetails(ticket)"
>
<!-- Header avec priorité et statut -->
<div class="p-4 border-b border-gray-100">
<div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0">
<h4 class="text-lg font-semibold text-gray-900 truncate group-hover:text-primary-600 transition-colors">
{{ ticket.title }}
</h4>
<p class="text-sm text-gray-500 mt-1">
Par {{ ticket.user_name }} {{ formatRelativeDate(ticket.created_at) }}
</p>
</div>
<div class="flex flex-col items-end space-y-2">
<!-- Badge priorité -->
<span :class="getPriorityBadgeClass(ticket.priority)" class="text-xs font-medium px-2 py-1 rounded-full">
{{ getPriorityLabel(ticket.priority) }}
</span>
<!-- Badge statut -->
<span :class="getStatusBadgeClass(ticket.status)" class="text-xs font-medium px-2 py-1 rounded-full">
{{ getStatusLabel(ticket.status) }}
</span>
</div>
</div>
<!-- Type de ticket -->
<div class="flex items-center space-x-2">
<span :class="getTypeBadgeClass(ticket.ticket_type)" class="text-xs font-medium px-2 py-1 rounded-full">
{{ getTypeLabel(ticket.ticket_type) }}
</span>
<span v-if="ticket.assigned_to" class="text-xs text-gray-500">
{{ getAssignedAdminName(ticket.assigned_to) }}
</span>
</div>
</div>
<!-- Description -->
<div class="p-4">
<p class="text-gray-700 text-sm line-clamp-3 leading-relaxed">
{{ ticket.description }}
</p>
<div v-if="ticket.description && ticket.description.length > 150" class="text-xs text-primary-600 mt-2 font-medium">
Cliquez pour voir plus...
</div>
</div>
<!-- Footer avec actions rapides -->
<div class="p-4 border-t border-gray-100 bg-gray-50 rounded-b-xl">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<span v-if="ticket.screenshot_path" class="text-xs text-gray-500 flex items-center">
📸 Screenshot
</span>
<span v-if="ticket.admin_notes" class="text-xs text-gray-500 flex items-center">
📝 Notes admin
</span>
</div>
<div class="flex space-x-2">
<button
@click.stop="editTicket(ticket)"
class="p-2 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
title="Modifier le ticket"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
@click.stop="quickStatusUpdate(ticket)"
class="p-2 text-gray-400 hover:text-success-600 hover:bg-success-50 rounded-lg transition-colors"
title="Mettre à jour le statut"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- No Tickets -->
<div v-else class="text-center py-12">
<div class="w-24 h-24 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
<svg class="w-12 h-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun ticket trouvé</h3>
<p class="text-gray-500">Aucun ticket ne correspond à vos critères de recherche.</p>
</div>
</div>
<!-- Ticket Edit Modal -->
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showTicketEditModal"
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
>
<div class="bg-white rounded-xl max-w-3xl w-full p-6 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-2xl font-bold text-gray-900">Modifier le ticket</h2>
<p class="text-gray-600 mt-1">Mettre à jour les informations et le statut</p>
</div>
<button
@click="showTicketEditModal = false"
class="text-gray-400 hover:text-gray-600 p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="saveTicketChanges" class="space-y-6">
<!-- Informations du ticket -->
<div class="bg-gradient-to-r from-gray-50 to-gray-100 rounded-xl p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Informations du ticket
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Titre</label>
<div class="bg-white px-4 py-3 rounded-lg border border-gray-200 text-gray-900">
{{ editingTicket.title }}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Type</label>
<div class="bg-white px-4 py-3 rounded-lg border border-gray-200 text-gray-900">
{{ getTypeLabel(editingTicket.ticket_type) }}
</div>
</div>
</div>
</div>
<!-- Gestion du statut et priorité -->
<div class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Gestion du ticket
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Status -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Statut</label>
<select v-model="editingTicket.status" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors">
<option value="open">🔴 Ouvert</option>
<option value="in_progress">🟡 En cours</option>
<option value="resolved">🟢 Résolu</option>
<option value="closed"> Fermé</option>
</select>
</div>
<!-- Priority -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Priorité</label>
<select v-model="editingTicket.priority" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors">
<option value="low">🟢 Faible</option>
<option value="medium">🟡 Moyenne</option>
<option value="high">🟠 Élevée</option>
<option value="urgent">🔴 Urgente</option>
</select>
</div>
</div>
</div>
<!-- Assignation et notes -->
<div class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Assignation et communication
</h3>
<div class="space-y-6">
<!-- Assign to -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Assigner à</label>
<select v-model="editingTicket.assigned_to" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors">
<option value="">👤 Non assigné</option>
<option v-for="admin in adminUsers" :key="admin.id" :value="admin.id">
👑 {{ admin.full_name }}
</option>
</select>
</div>
<!-- Admin Notes -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Notes administrateur</label>
<textarea
v-model="editingTicket.admin_notes"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors resize-none"
rows="4"
placeholder="Notes internes, instructions pour l'équipe ou réponse à l'utilisateur..."
></textarea>
<p class="text-xs text-gray-500 mt-1">Ces notes sont visibles uniquement par les administrateurs</p>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex gap-4 pt-4">
<button
type="button"
@click="showTicketEditModal = false"
class="flex-1 px-6 py-3 bg-gray-600 text-white font-medium rounded-lg hover:bg-gray-700 transition-colors"
>
Annuler
</button>
<button
type="submit"
class="flex-1 px-6 py-3 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="savingTicket"
>
<span v-if="savingTicket" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Sauvegarde...
</span>
<span v-else>Sauvegarder les modifications</span>
</button>
</div>
</form>
</div>
</div>
</transition>
</div>
<!-- Ticket Details Modal -->
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showTicketDetailsModal"
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
>
<div class="bg-white rounded-xl max-w-5xl w-full p-6 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-gray-900">Détails du ticket</h2>
<button
@click="showTicketDetailsModal = false"
class="text-gray-400 hover:text-gray-600 p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div v-if="selectedTicket" class="space-y-6">
<!-- Header avec métadonnées -->
<div class="bg-gradient-to-r from-gray-50 to-gray-100 rounded-xl p-6">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">{{ selectedTicket.title }}</h3>
<p class="text-sm text-gray-600">
<span class="font-medium">Créé par :</span> {{ selectedTicket.user_name }}
</p>
<p class="text-sm text-gray-600">
<span class="font-medium">Email :</span> {{ selectedTicket.user_email }}
</p>
<p class="text-sm text-gray-600">
<span class="font-medium">Date :</span> {{ formatDate(selectedTicket.created_at) }}
</p>
</div>
<div class="flex flex-col space-y-3">
<div class="flex items-center space-x-2">
<span class="text-sm font-medium text-gray-700">Statut :</span>
<span :class="getStatusBadgeClass(selectedTicket.status)" class="px-3 py-1 rounded-full text-sm font-medium">
{{ getStatusLabel(selectedTicket.status) }}
</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-sm font-medium text-gray-700">Priorité :</span>
<span :class="getPriorityBadgeClass(selectedTicket.priority)" class="px-3 py-1 rounded-full text-sm font-medium">
{{ getPriorityLabel(selectedTicket.priority) }}
</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-sm font-medium text-gray-700">Type :</span>
<span :class="getTypeBadgeClass(selectedTicket.ticket_type)" class="px-3 py-1 rounded-full text-sm font-medium">
{{ getTypeLabel(selectedTicket.ticket_type) }}
</span>
</div>
</div>
<div class="text-right">
<div v-if="selectedTicket.assigned_to" class="mb-3">
<p class="text-sm font-medium text-gray-700">Assigné à :</p>
<p class="text-sm text-gray-600">{{ getAssignedAdminName(selectedTicket.assigned_to) }}</p>
</div>
<div v-else class="mb-3">
<p class="text-sm text-orange-600 font-medium"> Non assigné</p>
</div>
<!-- Actions rapides -->
<div class="flex flex-col space-y-2">
<button
@click="quickStatusUpdate(selectedTicket)"
class="px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-lg hover:bg-primary-700 transition-colors"
>
{{ getNextStatusLabel(selectedTicket.status) }}
</button>
<button
@click="editTicket(selectedTicket)"
class="px-4 py-2 bg-gray-600 text-white text-sm font-medium rounded-lg hover:bg-gray-700 transition-colors"
>
Modifier
</button>
</div>
</div>
</div>
</div>
<!-- Description -->
<div class="bg-white border border-gray-200 rounded-xl p-6">
<h4 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Description
</h4>
<div class="bg-gray-50 rounded-lg p-4 modal-description">
<p class="text-gray-900 whitespace-pre-wrap leading-relaxed break-words">{{ selectedTicket.description }}</p>
</div>
</div>
<!-- Screenshot -->
<div v-if="selectedTicket.screenshot_path" class="bg-white border border-gray-200 rounded-xl p-6">
<h4 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Screenshot
</h4>
<div class="bg-gray-50 rounded-lg p-4">
<img
:src="getMediaUrl(selectedTicket.screenshot_path)"
:alt="'Screenshot du ticket ' + selectedTicket.title"
class="max-w-full h-auto rounded-lg shadow-sm ticket-screenshot cursor-pointer"
@click="openImageModal(selectedTicket.screenshot_path)"
>
<p class="text-xs text-gray-500 mt-2">Cliquez sur l'image pour l'agrandir</p>
</div>
</div>
<!-- Admin Notes -->
<div v-if="selectedTicket.admin_notes" class="bg-white border border-gray-200 rounded-xl p-6">
<h4 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Notes administrateur
</h4>
<div class="bg-blue-50 rounded-lg p-4">
<p class="text-gray-900 whitespace-pre-wrap leading-relaxed">{{ selectedTicket.admin_notes }}</p>
</div>
</div>
<!-- Historique des mises à jour -->
<div class="bg-white border border-gray-200 rounded-xl p-6">
<h4 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Historique
</h4>
<div class="space-y-3">
<div class="flex items-center space-x-3 text-sm">
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<span class="text-gray-600">Créé le {{ formatDate(selectedTicket.created_at) }}</span>
</div>
<div v-if="selectedTicket.updated_at !== selectedTicket.created_at" class="flex items-center space-x-3 text-sm">
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
<span class="text-gray-600">Modifié le {{ formatDate(selectedTicket.updated_at) }}</span>
</div>
<div v-if="selectedTicket.resolved_at" class="flex items-center space-x-3 text-sm">
<div class="w-2 h-2 bg-purple-500 rounded-full"></div>
<span class="text-gray-600">Résolu le {{ formatDate(selectedTicket.resolved_at) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
<!-- Image Modal -->
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showImageModal"
class="fixed inset-0 bg-black bg-opacity-90 z-60 flex items-center justify-center p-4"
@click="showImageModal = false"
>
<div class="relative max-w-4xl max-h-[90vh]">
<img
:src="selectedImageUrl"
alt="Image agrandie"
class="max-w-full max-h-full object-contain"
>
<button
@click="showImageModal = false"
class="absolute top-4 right-4 text-white hover:text-gray-300 bg-black bg-opacity-50 rounded-full p-2"
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</transition>
<!-- Cleanup Modal -->
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showCleanupModal"
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
>
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
<h2 class="text-xl font-semibold mb-4">Nettoyage des fichiers orphelins</h2>
<div v-if="cleanupLoading" class="text-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto mb-4"></div>
<p class="text-gray-600">Analyse en cours...</p>
</div>
<div v-else-if="orphanedFiles.length > 0">
<p class="text-gray-700 mb-4">
{{ orphanedFiles.length }} fichier(s) orphelin(s) trouvé(s) pour un total de {{ totalOrphanedSize }}.
</p>
<div class="max-h-60 overflow-y-auto space-y-2 mb-4">
<div
v-for="file in orphanedFiles"
:key="file.path"
class="flex items-center justify-between p-2 bg-gray-50 rounded text-sm"
>
<div>
<span class="font-medium">{{ file.type }}</span>
<span class="text-gray-600 ml-2">{{ file.path }}</span>
</div>
<span class="text-gray-500">{{ file.size }}</span>
</div>
</div>
<div class="flex gap-3">
<button
@click="showCleanupModal = false"
class="flex-1 btn-secondary"
>
Annuler
</button>
<button
@click="performCleanup"
class="flex-1 btn-primary"
>
Nettoyer ({{ totalOrphanedSize }})
</button>
</div>
</div>
<div v-else class="text-center py-8 text-gray-500">
<CheckCircle class="w-16 h-16 mx-auto mb-4 text-success-500" />
<h3 class="text-lg font-medium mb-2">Aucun fichier orphelin</h3>
<p>Votre stockage est propre !</p>
</div>
</div>
</div>
</transition>
<!-- Information Modal -->
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showInformationModal"
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
>
<div class="bg-white rounded-xl max-w-4xl w-full p-6 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold">
{{ editingInformation ? 'Modifier l\'information' : 'Nouvelle information' }}
</h2>
<button
@click="showInformationModal = false"
class="text-gray-400 hover:text-gray-600"
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="saveInformation" class="space-y-6">
<!-- Title -->
<div>
<label class="label">Titre *</label>
<input
v-model="informationForm.title"
type="text"
class="input"
required
maxlength="200"
placeholder="Titre de l'information"
>
</div>
<!-- Category -->
<div>
<label class="label">Catégorie</label>
<select v-model="informationForm.category" class="input">
<option value="general">Général</option>
<option value="release">Nouvelle version</option>
<option value="upcoming">À venir</option>
<option value="maintenance">Maintenance</option>
<option value="feature">Nouvelle fonctionnalité</option>
<option value="bugfix">Correction de bug</option>
</select>
</div>
<!-- Priority -->
<div>
<label class="label">Priorité</label>
<input
v-model="informationForm.priority"
type="number"
class="input"
min="0"
max="10"
placeholder="0 (normal) à 10 (très important)"
>
<div class="mt-1 text-sm text-gray-600">
Les informations avec une priorité plus élevée apparaîtront en premier
</div>
</div>
<!-- Content -->
<div>
<label class="label">Contenu *</label>
<textarea
v-model="informationForm.content"
class="input resize-none"
rows="8"
required
placeholder="Contenu de l'information (supporte le formatage basique)"
></textarea>
<div class="mt-1 text-sm text-gray-600">
Utilisez des sauts de ligne pour structurer votre contenu
</div>
</div>
<!-- Published Status -->
<div class="flex items-center">
<input
v-model="informationForm.is_published"
type="checkbox"
id="is_published"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
>
<label for="is_published" class="ml-2 block text-sm text-gray-900">
Publier immédiatement
</label>
</div>
<!-- Actions -->
<div class="flex gap-3 pt-4">
<button
type="button"
@click="showInformationModal = false"
class="flex-1 btn-secondary"
>
Annuler
</button>
<button
type="submit"
class="flex-1 btn-primary"
:disabled="savingInformation"
>
<Save class="w-4 h-4 mr-2" />
{{ savingInformation ? 'Sauvegarde...' : 'Sauvegarder' }}
</button>
</div>
</form>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification'
import axios from '@/utils/axios'
import {
Users,
Calendar,
Image,
HardDrive,
User,
Trash2,
RefreshCw,
Download,
CheckCircle,
Settings,
Save,
TestTube,
Plus
} from 'lucide-vue-next'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
const authStore = useAuthStore()
const toast = useToast()
const loading = ref(true)
const dashboard = ref({})
const users = ref([])
const showCleanupModal = ref(false)
const cleanupLoading = ref(false)
const orphanedFiles = ref([])
// Information Management
const showInformationModal = ref(false)
const informations = ref([])
const editingInformation = ref(null)
const savingInformation = ref(false)
const informationForm = ref({
title: '',
content: '',
category: 'general',
priority: 0,
is_published: true
})
// Settings
const settings = ref({
max_album_size_mb: 100,
max_vlog_size_mb: 500,
max_image_size_mb: 10,
max_video_size_mb: 3000, // Corrigé pour correspondre à la DB
max_media_per_album: 50,
max_users: 50,
enable_registration: true
})
const savingSettings = ref(false)
const currentUser = computed(() => authStore.user)
const totalOrphanedSize = computed(() => {
if (orphanedFiles.value.length === 0) return '0 B'
const totalBytes = orphanedFiles.value.reduce((sum, file) => {
const size = file.size.replace(/[^\d.]/g, '')
const unit = file.size.replace(/[\d.]/g, '')
const multiplier = unit === 'KB' ? 1024 : unit === 'MB' ? 1024 * 1024 : unit === 'GB' ? 1024 * 1024 * 1024 : 1
return sum + (parseFloat(size) * multiplier)
}, 0)
if (totalBytes < 1024) return `${totalBytes} B`
if (totalBytes < 1024 * 1024) return `${(totalBytes / 1024).toFixed(1)} KB`
if (totalBytes < 1024 * 1024 * 1024) return `${(totalBytes / (1024 * 1024)).toFixed(1)} MB`
return `${(totalBytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
})
function getStoragePercentage(bytes) {
if (dashboard.value.storage.total_bytes === 0) return 0
return Math.round((bytes / dashboard.value.storage.total_bytes) * 100)
}
function getUsersPercentage() {
if (settings.value.max_users === 0) return 0
return Math.round((users.value.length / settings.value.max_users) * 100)
}
async function fetchDashboard() {
try {
const response = await axios.get('/api/admin/dashboard')
dashboard.value = response.data
} catch (error) {
toast.error('Erreur lors du chargement du dashboard')
console.error('Error fetching dashboard:', error)
}
}
async function fetchUsers() {
try {
const response = await axios.get('/api/admin/users')
users.value = response.data
} catch (error) {
toast.error('Erreur lors du chargement des utilisateurs')
console.error('Error fetching users:', error)
}
}
async function refreshDashboard() {
await fetchDashboard()
toast.success('Dashboard actualisé')
}
async function refreshUsers() {
await fetchUsers()
toast.success('Liste des utilisateurs actualisée')
}
async function toggleUserStatus(userId) {
try {
const response = await axios.put(`/api/admin/users/${userId}/toggle-active`)
await fetchUsers()
toast.success(response.data.message)
} catch (error) {
toast.error('Erreur lors de la modification du statut')
}
}
async function toggleUserAdmin(userId) {
try {
const response = await axios.put(`/api/admin/users/${userId}/toggle-admin`)
await fetchUsers()
toast.success(response.data.message)
} catch (error) {
toast.error('Erreur lors de la modification du rôle')
}
}
async function deleteUser(userId) {
if (!confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.')) return
try {
await axios.delete(`/api/admin/users/${userId}`)
await fetchUsers()
toast.success('Utilisateur supprimé')
} catch (error) {
toast.error('Erreur lors de la suppression')
}
}
async function exportUserData() {
try {
const response = await axios.get('/api/admin/users', { responseType: 'blob' })
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', 'users_export.json')
document.body.appendChild(link)
link.click()
link.remove()
toast.success('Export des utilisateurs téléchargé')
} catch (error) {
toast.error('Erreur lors de l\'export')
}
}
async function checkOrphanedFiles() {
cleanupLoading.value = true
try {
const response = await axios.get('/api/admin/storage/cleanup')
orphanedFiles.value = response.data.orphaned_files
} catch (error) {
toast.error('Erreur lors de la vérification des fichiers orphelins')
} finally {
cleanupLoading.value = false
}
}
async function performCleanup() {
try {
await axios.delete('/api/admin/storage/cleanup')
orphanedFiles.value = []
await fetchDashboard()
toast.success('Nettoyage terminé')
showCleanupModal.value = false
} catch (error) {
toast.error('Erreur lors du nettoyage')
}
}
// Settings functions
async function fetchSettings() {
try {
const [uploadLimitsResponse, generalResponse] = await Promise.all([
axios.get('/api/settings/upload-limits'),
axios.get('/api/settings/category/general')
])
// Update upload limits
const uploadLimits = uploadLimitsResponse.data
console.log('DEBUG - Upload limits from API:', uploadLimits)
// Convertir en nombres pour éviter les problèmes de type
settings.value.max_album_size_mb = parseInt(uploadLimits.max_album_size_mb)
settings.value.max_vlog_size_mb = parseInt(uploadLimits.max_vlog_size_mb)
settings.value.max_image_size_mb = parseInt(uploadLimits.max_image_size_mb)
settings.value.max_video_size_mb = parseInt(uploadLimits.max_video_size_mb)
settings.value.max_media_per_album = parseInt(uploadLimits.max_media_per_album)
console.log('DEBUG - Settings after update:', settings.value)
// Update general settings
const generalSettings = generalResponse.data.settings
const maxUsersSetting = generalSettings.find(s => s.key === 'max_users')
const enableRegSetting = generalSettings.find(s => s.key === 'enable_registration')
if (maxUsersSetting) settings.value.max_users = parseInt(maxUsersSetting.value)
if (enableRegSetting) settings.value.enable_registration = enableRegSetting.value === 'true'
} catch (error) {
console.error('Error fetching settings:', error)
}
}
async function saveUploadLimits() {
savingSettings.value = true
try {
const updates = [
{ key: 'max_album_size_mb', value: settings.value.max_album_size_mb.toString() },
{ key: 'max_vlog_size_mb', value: settings.value.max_vlog_size_mb.toString() },
{ key: 'max_image_size_mb', value: settings.value.max_image_size_mb.toString() },
{ key: 'max_video_size_mb', value: settings.value.max_video_size_mb.toString() },
{ key: 'max_media_per_album', value: settings.value.max_media_per_album.toString() }
]
await Promise.all(updates.map(update =>
axios.put(`/api/settings/${update.key}`, { value: update.value })
))
toast.success('Limites d\'upload mises à jour')
} catch (error) {
toast.error('Erreur lors de la sauvegarde des limites')
console.error('Error saving upload limits:', error)
} finally {
savingSettings.value = false
}
}
async function saveGeneralSettings() {
savingSettings.value = true
try {
const updates = [
{ key: 'max_users', value: settings.value.max_users.toString() },
{ key: 'enable_registration', value: settings.value.enable_registration.toString() }
]
await Promise.all(updates.map(update =>
axios.put(`/api/settings/${update.key}`, { value: update.value })
))
toast.success('Paramètres généraux mis à jour')
// Afficher un avertissement si l'inscription est désactivée
if (!settings.value.enable_registration) {
toast.warning('⚠️ Les nouvelles inscriptions sont maintenant désactivées')
}
// Afficher un avertissement si la limite d'utilisateurs est proche
const currentUsersCount = users.value.length
if (currentUsersCount >= settings.value.max_users * 0.9) {
toast.warning(`⚠️ Attention : ${currentUsersCount}/${settings.value.max_users} utilisateurs (${Math.round(currentUsersCount/settings.value.max_users*100)}%)`)
}
} catch (error) {
toast.error('Erreur lors de la sauvegarde des paramètres')
console.error('Error saving general settings:', error)
} finally {
savingSettings.value = false
}
}
async function initializeSettings() {
try {
await axios.post('/api/settings/initialize-defaults')
await fetchSettings()
toast.success('Paramètres par défaut initialisés')
} catch (error) {
toast.error('Erreur lors de l\'initialisation')
console.error('Error initializing settings:', error)
}
}
async function refreshSettings() {
await fetchSettings()
toast.success('Paramètres actualisés')
}
async function testUploadLimits() {
try {
const response = await axios.get('/api/settings/test-upload-limits')
const testResults = response.data
console.log('Test results:', testResults)
// Afficher les résultats dans une alerte
const message = `
Test des limites d'upload :
Images (JPEG) : ${testResults.image_upload_limit.mb}MB (${testResults.image_upload_limit.bytes} bytes)
Vidéos (MP4) : ${testResults.video_upload_limit.mb}MB (${testResults.video_upload_limit.bytes} bytes)
Validation des types :
- Images JPEG autorisées : ${testResults.file_type_validation.image_jpeg_allowed ? '✅' : '❌'}
- Vidéos MP4 autorisées : ${testResults.file_type_validation.video_mp4_allowed ? '✅' : '❌'}
- PDF non autorisé : ${testResults.file_type_validation.pdf_not_allowed ? '✅' : '❌'}
`.trim()
alert(message)
toast.success('Test des limites terminé - voir la console pour plus de détails')
} catch (error) {
toast.error('Erreur lors du test des limites')
console.error('Error testing upload limits:', error)
}
}
// Information Management Functions
async function fetchInformations() {
try {
const response = await axios.get('/api/information/')
informations.value = response.data
} catch (error) {
toast.error('Erreur lors du chargement des informations')
console.error('Error fetching informations:', error)
}
}
function resetInformationForm() {
informationForm.value = {
title: '',
content: '',
category: 'general',
priority: 0,
is_published: true
}
editingInformation.value = null
}
function editInformation(info) {
editingInformation.value = info
informationForm.value = {
title: info.title,
content: info.content,
category: info.category,
priority: info.priority,
is_published: info.is_published
}
showInformationModal.value = true
}
async function saveInformation() {
savingInformation.value = true
try {
if (editingInformation.value) {
// Update existing information
await axios.put(`/api/information/${editingInformation.value.id}`, informationForm.value)
toast.success('Information mise à jour')
} else {
// Create new information
await axios.post('/api/information/', informationForm.value)
toast.success('Information créée')
}
await fetchInformations()
showInformationModal.value = false
resetInformationForm()
} catch (error) {
toast.error('Erreur lors de la sauvegarde')
console.error('Error saving information:', error)
} finally {
savingInformation.value = false
}
}
async function toggleInformationPublish(informationId) {
try {
const response = await axios.put(`/api/information/${informationId}/toggle-publish`)
await fetchInformations()
toast.success(response.data.message)
} catch (error) {
toast.error('Erreur lors de la modification du statut')
}
}
async function deleteInformation(informationId) {
if (!confirm('Êtes-vous sûr de vouloir supprimer cette information ? Cette action est irréversible.')) return
try {
await axios.delete(`/api/information/${informationId}`)
await fetchInformations()
toast.success('Information supprimée')
} catch (error) {
toast.error('Erreur lors de la suppression')
}
}
function getCategoryLabel(category) {
const labels = {
'general': 'Général',
'release': 'Nouvelle version',
'upcoming': 'À venir',
'maintenance': 'Maintenance',
'feature': 'Nouvelle fonctionnalité',
'bugfix': 'Correction de bug'
}
return labels[category] || category
}
function getCategoryBadgeClass(category) {
const classes = {
'general': 'bg-gray-100 text-gray-800',
'release': 'bg-green-100 text-green-800',
'upcoming': 'bg-blue-100 text-blue-800',
'maintenance': 'bg-yellow-100 text-yellow-800',
'feature': 'bg-purple-100 text-purple-800',
'bugfix': 'bg-red-100 text-red-800'
}
return classes[category] || 'bg-gray-100 text-gray-800'
}
// Fonctions utilitaires pour les tickets
function getTypeLabel(type) {
const labels = {
'bug': '🐛 Bug',
'feature_request': '💡 Fonctionnalité',
'improvement': '✨ Amélioration',
'support': '❓ Support',
'other': '📝 Autre'
}
return labels[type] || type
}
function getStatusLabel(status) {
const labels = {
'open': 'Ouvert',
'in_progress': 'En cours',
'resolved': 'Résolu',
'closed': 'Fermé'
}
return labels[status] || status
}
function getPriorityLabel(priority) {
const labels = {
'low': 'Faible',
'medium': 'Moyenne',
'high': 'Élevée',
'urgent': 'Urgente'
}
return labels[priority] || priority
}
function getTypeBadgeClass(type) {
const classes = {
'bug': 'bg-red-100 text-red-800',
'feature_request': 'bg-blue-100 text-blue-800',
'improvement': 'bg-green-100 text-green-800',
'support': 'bg-yellow-100 text-yellow-800',
'other': 'bg-gray-100 text-gray-800'
}
return classes[type] || 'bg-gray-100 text-gray-800'
}
function getStatusBadgeClass(status) {
const classes = {
'open': 'bg-blue-100 text-blue-800',
'in_progress': 'bg-yellow-100 text-yellow-800',
'resolved': 'bg-green-100 text-green-800',
'closed': 'bg-gray-100 text-gray-800'
}
return classes[status] || 'bg-gray-100 text-gray-800'
}
function getPriorityBadgeClass(priority) {
const classes = {
'low': 'bg-green-100 text-green-800',
'medium': 'bg-yellow-100 text-yellow-800',
'high': 'bg-orange-100 text-orange-800',
'urgent': 'bg-red-100 text-red-800'
}
return classes[priority] || 'bg-gray-100 text-gray-800'
}
function formatDate(date) {
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
}
// Gestion des tickets
const ticketsLoading = ref(false)
const tickets = ref([])
const showTicketEditModal = ref(false)
const editingTicket = ref({
id: null,
title: '',
description: '',
ticket_type: 'bug',
priority: 'medium',
status: 'open',
assigned_to: '',
admin_notes: ''
})
const savingTicket = ref(false)
const ticketFilters = ref({
status: '',
type: '',
priority: '',
search: ''
})
// Nouvelles variables pour le modal de détails
const showTicketDetailsModal = ref(false)
const selectedTicket = ref(null)
const showImageModal = ref(false)
const selectedImageUrl = ref('')
// Computed properties pour les statistiques
const resolvedTicketsCount = computed(() => {
if (!tickets.value || !Array.isArray(tickets.value)) return 0
return tickets.value.filter(ticket => ticket && ticket.status && (ticket.status === 'resolved' || ticket.status === 'closed')).length
})
const urgentTicketsCount = computed(() => {
if (!tickets.value || !Array.isArray(tickets.value)) return 0
return tickets.value.filter(ticket => ticket && ticket.priority && ticket.priority === 'urgent').length
})
const unassignedTicketsCount = computed(() => {
if (!tickets.value || !Array.isArray(tickets.value)) return 0
return tickets.value.filter(ticket => ticket && !ticket.assigned_to).length
})
const recentTicketsCount = computed(() => {
if (!tickets.value || !Array.isArray(tickets.value)) return 0
const oneWeekAgo = new Date()
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7)
return tickets.value.filter(ticket => ticket && ticket.created_at && new Date(ticket.created_at) > oneWeekAgo).length
})
async function fetchTickets() {
ticketsLoading.value = true
try {
const response = await axios.get('/api/tickets/admin')
tickets.value = response.data
} catch (error) {
toast.error('Erreur lors du chargement des tickets')
console.error('Error fetching tickets:', error)
} finally {
ticketsLoading.value = false
}
}
async function editTicket(ticket) {
editingTicket.value = { ...ticket }
showTicketEditModal.value = true
}
async function saveTicketChanges() {
savingTicket.value = true
try {
await axios.put(`/api/tickets/${editingTicket.value.id}/admin`, editingTicket.value)
toast.success('Ticket mis à jour')
await fetchTickets()
showTicketEditModal.value = false
} catch (error) {
toast.error('Erreur lors de la sauvegarde du ticket')
console.error('Error saving ticket:', error)
} finally {
savingTicket.value = false
}
}
async function deleteTicket(ticketId) {
if (!confirm('Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.')) return
try {
await axios.delete(`/api/tickets/${ticketId}`)
toast.success('Ticket supprimé')
await fetchTickets()
} catch (error) {
toast.error('Erreur lors de la suppression du ticket')
console.error('Error deleting ticket:', error)
}
}
const filteredTickets = computed(() => {
if (!tickets.value || !Array.isArray(tickets.value)) {
return []
}
return tickets.value.filter(ticket => {
if (!ticket || !ticket.id) {
return false
}
const matchesStatus = ticketFilters.value.status ? ticket.status === ticketFilters.value.status : true
const matchesType = ticketFilters.value.type ? ticket.ticket_type === ticketFilters.value.type : true
const matchesPriority = ticketFilters.value.priority ? ticket.priority === ticketFilters.value.priority : true
const matchesSearch = ticketFilters.value.search ?
(ticket.title && ticket.title.toLowerCase().includes(ticketFilters.value.search.toLowerCase())) ||
(ticket.description && ticket.description.toLowerCase().includes(ticketFilters.value.search.toLowerCase())) ||
(ticket.user_name && ticket.user_name.toLowerCase().includes(ticketFilters.value.search.toLowerCase())) ||
(ticket.assigned_to?.full_name && ticket.assigned_to.full_name.toLowerCase().includes(ticketFilters.value.search.toLowerCase()))
: true
return matchesStatus && matchesType && matchesPriority && matchesSearch
})
})
const adminUsers = computed(() => {
return users.value.filter(user => user.is_admin)
})
// Nouvelles fonctions pour le modal de détails
function showTicketDetails(ticket) {
selectedTicket.value = ticket
showTicketDetailsModal.value = true
}
function openImageModal(imageUrl) {
selectedImageUrl.value = getMediaUrl(imageUrl)
showImageModal.value = true
}
function getMediaUrl(path) {
if (!path) return ''
return path.startsWith('http') ? path : `http://localhost:8000${path}`
}
// Nouvelles fonctions pour les filtres et actions rapides
function setQuickFilter(filterType) {
switch (filterType) {
case 'urgent':
ticketFilters.value.priority = 'urgent'
ticketFilters.value.status = ''
ticketFilters.value.type = ''
break
case 'unassigned':
ticketFilters.value.assigned_to = null
ticketFilters.value.status = 'open'
break
case 'recent':
ticketFilters.value.status = 'open'
ticketFilters.value.priority = ''
break
}
}
function clearFilters() {
ticketFilters.value = {
status: '',
type: '',
priority: '',
search: ''
}
}
function quickStatusUpdate(ticket) {
// Logique pour mettre à jour rapidement le statut
const nextStatus = getNextStatus(ticket.status)
if (nextStatus) {
updateTicketStatus(ticket.id, nextStatus)
}
}
function getNextStatus(currentStatus) {
const statusFlow = {
'open': 'in_progress',
'in_progress': 'resolved',
'resolved': 'closed',
'closed': 'open'
}
return statusFlow[currentStatus]
}
function getNextStatusLabel(currentStatus) {
const statusLabels = {
'open': '🟡 Mettre en cours',
'in_progress': '🟢 Marquer résolu',
'resolved': '⚫ Fermer',
'closed': '🔴 Réouvrir'
}
return statusLabels[currentStatus] || 'Mettre à jour'
}
async function updateTicketStatus(ticketId, newStatus) {
try {
await axios.put(`/api/tickets/${ticketId}/admin`, { status: newStatus })
await fetchTickets()
toast.success(`Statut mis à jour vers ${getStatusLabel(newStatus)}`)
} catch (error) {
toast.error('Erreur lors de la mise à jour du statut')
console.error('Error updating ticket status:', error)
}
}
function getAssignedAdminName(adminId) {
const admin = users.value.find(user => user.id === adminId)
return admin ? admin.full_name : 'Admin inconnu'
}
function formatRelativeDate(date) {
const now = new Date()
const ticketDate = new Date(date)
const diffTime = Math.abs(now - ticketDate)
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays === 1) return 'Aujourd\'hui'
if (diffDays === 2) return 'Hier'
if (diffDays <= 7) return `Il y a ${diffDays - 1} jours`
if (diffDays <= 30) return `Il y a ${Math.floor(diffDays / 7)} semaines`
return formatDate(date)
}
onMounted(async () => {
await Promise.all([fetchDashboard(), fetchUsers(), fetchSettings(), fetchInformations(), fetchTickets()])
loading.value = false
})
// Watch for cleanup modal
watch(showCleanupModal, (newValue) => {
if (newValue) {
checkOrphanedFiles()
}
})
// Watch for information modal
watch(showInformationModal, (newValue) => {
if (!newValue) {
resetInformationForm()
}
})
// Watch for ticket edit modal
watch(showTicketEditModal, (newValue) => {
if (!newValue) {
editingTicket.value = {
id: null,
title: '',
description: '',
ticket_type: 'bug',
priority: 'medium',
status: 'open',
assigned_to: '',
admin_notes: ''
}
}
})
</script>
<style scoped>
/* Amélioration de l'affichage des descriptions longues */
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Style pour la description dans le modal */
.modal-description {
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-word;
}
.modal-description::-webkit-scrollbar {
width: 6px;
}
.modal-description::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
.modal-description::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.modal-description::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Amélioration de l'affichage des tickets */
.ticket-row {
transition: all 0.2s ease;
}
.ticket-row:hover {
background-color: #f9fafb;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Amélioration de l'affichage des descriptions */
.ticket-description {
max-height: 4rem;
overflow: hidden;
position: relative;
}
.ticket-description::after {
content: '';
position: absolute;
bottom: 0;
right: 0;
width: 100%;
height: 1.5rem;
background: linear-gradient(transparent, #f9fafb);
pointer-events: none;
}
/* Amélioration des modals */
.modal-overlay {
backdrop-filter: blur(4px);
}
/* Amélioration de l'affichage des images */
.ticket-screenshot {
cursor: pointer;
transition: transform 0.2s ease;
}
.ticket-screenshot:hover {
transform: scale(1.02);
}
/* Styles pour les cartes de tickets */
.ticket-card {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.ticket-card:hover {
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* Animation pour les badges */
.badge-animate {
animation: badgePulse 2s infinite;
}
@keyframes badgePulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
/* Styles pour les filtres */
.filter-input {
transition: all 0.2s ease;
}
.filter-input:focus {
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* Styles pour les boutons de filtres rapides */
.quick-filter-btn {
transition: all 0.2s ease;
}
.quick-filter-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
/* Styles pour les statistiques */
.stats-number {
animation: countUp 0.8s ease-out;
}
@keyframes countUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive design */
@media (max-width: 768px) {
.ticket-grid {
grid-template-columns: 1fr;
}
.filter-grid {
grid-template-columns: 1fr;
}
}
</style>