2127 lines
80 KiB
Vue
2127 lines
80 KiB
Vue
<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">
|
||
<LoadingLogo size="large" text="Chargement du dashboard..." />
|
||
</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">
|
||
<LoadingLogo size="large" text="Chargement des tickets..." />
|
||
</div>
|
||
|
||
<!-- Tickets Grid -->
|
||
<div v-if="!ticketsLoading && 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"
|
||
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-if="!ticketsLoading && filteredTickets.length === 0" 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'
|
||
import LoadingLogo from '@/components/LoadingLogo.vue'
|
||
|
||
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 : `${import.meta.env.VITE_API_URL || 'http://localhost:8002'}${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> |