2026-01-10 15:30:51 +01:00
|
|
|
"""
|
|
|
|
|
Staff Analytics Module
|
|
|
|
|
======================
|
|
|
|
|
Advanced analytics for individual staff performance and ratings.
|
|
|
|
|
"""
|
|
|
|
|
import io
|
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
from collections import defaultdict
|
|
|
|
|
from typing import List, Dict, Any, Optional
|
|
|
|
|
|
|
|
|
|
import discord
|
2026-05-01 16:16:33 +02:00
|
|
|
import matplotlib.pyplot as plt
|
|
|
|
|
import plotly.graph_objects as go
|
|
|
|
|
import plotly.express as px
|
|
|
|
|
from plotly.subplots import make_subplots
|
|
|
|
|
import numpy as np
|
2026-01-10 15:30:51 +01:00
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
|
|
|
|
|
|
from .data.storage import get_storage
|
|
|
|
|
from .data.models import TicketData, TicketStatus
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class StaffAnalytics:
|
|
|
|
|
"""Handles staff-specific analytics and visualizations"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, bot):
|
|
|
|
|
self.bot = bot
|
|
|
|
|
self.storage = get_storage()
|
|
|
|
|
|
|
|
|
|
async def get_staff_list(self, guild_id: int) -> List[Dict[str, Any]]:
|
|
|
|
|
"""Get list of staff members with their stats"""
|
|
|
|
|
config = self.storage.load_config(guild_id)
|
2026-01-14 21:32:20 +01:00
|
|
|
# Invalidate cache to ensure we get the latest ticket data with new ratings
|
|
|
|
|
self.storage.invalidate_cache(guild_id)
|
2026-01-10 15:30:51 +01:00
|
|
|
tickets = self.storage.get_guild_tickets(guild_id)
|
|
|
|
|
|
|
|
|
|
staff_stats = defaultdict(lambda: {
|
|
|
|
|
'user_id': 0,
|
|
|
|
|
'name': 'Unknown',
|
|
|
|
|
'tickets_claimed': 0,
|
|
|
|
|
'tickets_closed': 0,
|
|
|
|
|
'average_rating': 0,
|
|
|
|
|
'total_ratings': 0,
|
|
|
|
|
'response_times': [],
|
|
|
|
|
'ratings': [],
|
|
|
|
|
'categories': defaultdict(int)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
for ticket in tickets:
|
|
|
|
|
if ticket.claimed_by:
|
|
|
|
|
staff_id = ticket.claimed_by
|
|
|
|
|
staff_stats[staff_id]['user_id'] = staff_id
|
|
|
|
|
staff_stats[staff_id]['tickets_claimed'] += 1
|
|
|
|
|
|
|
|
|
|
if ticket.status == TicketStatus.CLOSED:
|
|
|
|
|
staff_stats[staff_id]['tickets_closed'] += 1
|
|
|
|
|
|
|
|
|
|
if ticket.rating:
|
|
|
|
|
staff_stats[staff_id]['ratings'].append(ticket.rating)
|
|
|
|
|
staff_stats[staff_id]['total_ratings'] += 1
|
|
|
|
|
|
|
|
|
|
# Calculate response time
|
|
|
|
|
if ticket.created_at:
|
|
|
|
|
claimed_time = None
|
|
|
|
|
for msg in ticket.messages:
|
|
|
|
|
if msg.author_id == staff_id and msg.is_staff:
|
|
|
|
|
claimed_time = msg.timestamp
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if claimed_time:
|
|
|
|
|
response_time = (claimed_time - ticket.created_at).total_seconds() / 60
|
|
|
|
|
staff_stats[staff_id]['response_times'].append(response_time)
|
|
|
|
|
|
|
|
|
|
# Category stats
|
|
|
|
|
staff_stats[staff_id]['categories'][ticket.category_id] += 1
|
|
|
|
|
|
|
|
|
|
# Calculate averages and get names
|
|
|
|
|
result = []
|
|
|
|
|
for staff_id, stats in staff_stats.items():
|
|
|
|
|
if stats['ratings']:
|
|
|
|
|
stats['average_rating'] = sum(stats['ratings']) / len(stats['ratings'])
|
|
|
|
|
|
|
|
|
|
if stats['response_times']:
|
|
|
|
|
stats['average_response_time'] = sum(stats['response_times']) / len(stats['response_times'])
|
|
|
|
|
else:
|
|
|
|
|
stats['average_response_time'] = 0
|
|
|
|
|
|
|
|
|
|
# Get user name
|
|
|
|
|
try:
|
|
|
|
|
user = self.bot.get_user(staff_id)
|
|
|
|
|
if user:
|
|
|
|
|
stats['name'] = str(user)
|
|
|
|
|
else:
|
|
|
|
|
# Try to get from guild
|
|
|
|
|
guild = self.bot.get_guild(guild_id)
|
|
|
|
|
if guild:
|
|
|
|
|
member = guild.get_member(staff_id)
|
|
|
|
|
if member:
|
|
|
|
|
stats['name'] = str(member)
|
|
|
|
|
except:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
result.append(stats)
|
|
|
|
|
|
|
|
|
|
# Sort by tickets claimed
|
|
|
|
|
result.sort(key=lambda x: x['tickets_claimed'], reverse=True)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
def create_staff_rating_chart(self, staff_list: List[Dict[str, Any]]) -> io.BytesIO:
|
2026-05-01 16:16:33 +02:00
|
|
|
"""Create a comprehensive staff rating chart using matplotlib"""
|
2026-01-10 15:30:51 +01:00
|
|
|
# Filter staff with ratings
|
|
|
|
|
rated_staff = [s for s in staff_list if s['total_ratings'] > 0]
|
|
|
|
|
|
|
|
|
|
if not rated_staff:
|
|
|
|
|
# Return empty chart
|
2026-05-01 16:16:33 +02:00
|
|
|
fig, ax = plt.subplots(figsize=(10, 6), facecolor='#2F3136')
|
|
|
|
|
ax.set_facecolor('#2F3136')
|
|
|
|
|
ax.text(0.5, 0.5, 'Aucune évaluation disponible', ha='center', va='center',
|
|
|
|
|
transform=ax.transAxes, color='white')
|
|
|
|
|
ax.set_title('Évaluations des Membres du Staff', color='white')
|
2026-01-10 15:30:51 +01:00
|
|
|
buf = io.BytesIO()
|
2026-05-01 16:16:33 +02:00
|
|
|
plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136')
|
2026-01-10 15:30:51 +01:00
|
|
|
buf.seek(0)
|
2026-05-01 16:16:33 +02:00
|
|
|
plt.close()
|
2026-01-10 15:30:51 +01:00
|
|
|
return buf
|
|
|
|
|
|
2026-05-01 16:16:33 +02:00
|
|
|
# Sort by average rating
|
|
|
|
|
rated_staff.sort(key=lambda x: x['average_rating'], reverse=True)
|
|
|
|
|
|
|
|
|
|
# Prepare data - limit to top 10 for better readability
|
|
|
|
|
top_staff = rated_staff[:10]
|
|
|
|
|
names = [s['name'][:15] + '...' if len(s['name']) > 15 else s['name'] for s in top_staff]
|
2026-01-10 15:30:51 +01:00
|
|
|
ratings = [s['average_rating'] for s in top_staff]
|
|
|
|
|
counts = [s['total_ratings'] for s in top_staff]
|
|
|
|
|
|
2026-05-01 16:16:33 +02:00
|
|
|
# Create figure
|
|
|
|
|
fig, ax = plt.subplots(figsize=(12, 8), facecolor='#2F3136')
|
|
|
|
|
fig.patch.set_facecolor('#2F3136')
|
|
|
|
|
ax.set_facecolor('#2F3136')
|
|
|
|
|
|
|
|
|
|
# Create bars with color coding
|
|
|
|
|
colors = ['#57F287' if r >= 4.5 else '#FEE75C' if r >= 3.5 else '#FAA61A' if r >= 2.5 else '#ED4245' for r in ratings]
|
|
|
|
|
bars = ax.bar(range(len(ratings)), ratings, color=colors)
|
|
|
|
|
|
|
|
|
|
# Customize
|
|
|
|
|
ax.set_xticks(range(len(names)))
|
|
|
|
|
ax.set_xticklabels(names, rotation=45, ha='right', color='white')
|
|
|
|
|
ax.set_ylabel('Note moyenne', color='white')
|
|
|
|
|
ax.set_title('Évaluations des Membres du Staff', color='white', fontsize=16)
|
|
|
|
|
ax.tick_params(colors='white')
|
|
|
|
|
ax.grid(True, alpha=0.3, color='gray')
|
|
|
|
|
|
|
|
|
|
# Add value labels on bars (avoiding Unicode stars to prevent warnings)
|
|
|
|
|
for i, (bar, rating, count) in enumerate(zip(bars, ratings, counts)):
|
|
|
|
|
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.05,
|
|
|
|
|
f'{rating:.1f}\n({count})', ha='center', va='bottom', color='white', fontsize=8)
|
|
|
|
|
|
|
|
|
|
# Set y-axis limits
|
|
|
|
|
ax.set_ylim(0, 5.2)
|
|
|
|
|
|
|
|
|
|
# Add legend
|
|
|
|
|
legend_elements = [
|
|
|
|
|
plt.Rectangle((0,0),1,1, color='#57F287', label='Excellent (4.5+)'),
|
|
|
|
|
plt.Rectangle((0,0),1,1, color='#FEE75C', label='Bon (3.5-4.4)'),
|
|
|
|
|
plt.Rectangle((0,0),1,1, color='#FAA61A', label='Moyen (2.5-3.4)'),
|
|
|
|
|
plt.Rectangle((0,0),1,1, color='#ED4245', label='À améliorer (<2.5)')
|
2026-01-10 15:30:51 +01:00
|
|
|
]
|
2026-05-01 16:16:33 +02:00
|
|
|
ax.legend(handles=legend_elements, loc='upper right', facecolor='#2F3136', edgecolor='white', labelcolor='white')
|
|
|
|
|
|
|
|
|
|
# Adjust layout
|
|
|
|
|
plt.tight_layout()
|
|
|
|
|
|
|
|
|
|
# Save to buffer
|
2026-01-10 15:30:51 +01:00
|
|
|
buf = io.BytesIO()
|
2026-05-01 16:16:33 +02:00
|
|
|
plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136')
|
2026-01-10 15:30:51 +01:00
|
|
|
buf.seek(0)
|
2026-05-01 16:16:33 +02:00
|
|
|
plt.close()
|
2026-01-10 15:30:51 +01:00
|
|
|
return buf
|
|
|
|
|
|
|
|
|
|
def create_individual_staff_chart(self, staff_data: Dict[str, Any]) -> io.BytesIO:
|
|
|
|
|
"""Create detailed chart for individual staff member"""
|
|
|
|
|
width, height = 800, 500
|
|
|
|
|
img = Image.new('RGB', (width, height), '#2F3136')
|
|
|
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
font_title = ImageFont.truetype("arial.ttf", 18)
|
|
|
|
|
font_label = ImageFont.truetype("arial.ttf", 14)
|
|
|
|
|
font_value = ImageFont.truetype("arial.ttf", 12)
|
|
|
|
|
except:
|
|
|
|
|
font_title = ImageFont.load_default()
|
|
|
|
|
font_label = ImageFont.load_default()
|
|
|
|
|
font_value = ImageFont.load_default()
|
|
|
|
|
|
|
|
|
|
# Title
|
|
|
|
|
title = f"Statistiques de {staff_data['name']}"
|
|
|
|
|
draw.text((width//2, 30), title, fill='white', font=font_title, anchor='mm')
|
|
|
|
|
|
|
|
|
|
# Basic stats
|
|
|
|
|
stats_y = 80
|
|
|
|
|
draw.text((50, stats_y), f"Tickets réclamés: {staff_data['tickets_claimed']}", fill='white', font=font_label)
|
|
|
|
|
draw.text((50, stats_y + 25), f"Tickets fermés: {staff_data['tickets_closed']}", fill='white', font=font_label)
|
|
|
|
|
|
|
|
|
|
if staff_data['total_ratings'] > 0:
|
|
|
|
|
avg_rating = staff_data['average_rating']
|
|
|
|
|
color = '#57F287' if avg_rating >= 4 else '#FEE75C' if avg_rating >= 3 else '#ED4245'
|
|
|
|
|
draw.text((50, stats_y + 50), f"Note moyenne: {avg_rating:.1f}⭐ ({staff_data['total_ratings']} évaluations)", fill=color, font=font_label)
|
|
|
|
|
|
|
|
|
|
if staff_data['response_times']:
|
|
|
|
|
avg_response = staff_data['average_response_time']
|
|
|
|
|
draw.text((50, stats_y + 75), f"Temps de réponse moyen: {avg_response:.1f} min", fill='white', font=font_label)
|
|
|
|
|
|
|
|
|
|
# Rating distribution if available
|
|
|
|
|
if staff_data['ratings']:
|
|
|
|
|
chart_left = 400
|
|
|
|
|
chart_top = 150
|
|
|
|
|
chart_width = 350
|
|
|
|
|
chart_height = 200
|
|
|
|
|
|
|
|
|
|
# Count ratings
|
|
|
|
|
rating_counts = defaultdict(int)
|
|
|
|
|
for rating in staff_data['ratings']:
|
|
|
|
|
rating_counts[rating] += 1
|
|
|
|
|
|
|
|
|
|
max_count = max(rating_counts.values()) if rating_counts else 1
|
|
|
|
|
|
|
|
|
|
# Draw mini bars
|
|
|
|
|
bar_width = chart_width // 5
|
|
|
|
|
for i in range(1, 6):
|
|
|
|
|
count = rating_counts[i]
|
|
|
|
|
if max_count > 0:
|
|
|
|
|
bar_height = int((count / max_count) * chart_height)
|
|
|
|
|
else:
|
|
|
|
|
bar_height = 0
|
|
|
|
|
|
|
|
|
|
x1 = chart_left + (i-1) * bar_width + 10
|
|
|
|
|
y1 = chart_top + chart_height - bar_height
|
|
|
|
|
x2 = x1 + bar_width - 20
|
|
|
|
|
y2 = chart_top + chart_height
|
|
|
|
|
|
|
|
|
|
draw.rectangle([x1, y1, x2, y2], fill='#5865F2', outline='#FFFFFF', width=1)
|
|
|
|
|
draw.text((x1 + (x2-x1)//2, y1 - 15), str(count), fill='white', font=font_value, anchor='mm')
|
|
|
|
|
|
|
|
|
|
# Labels
|
|
|
|
|
for i in range(1, 6):
|
|
|
|
|
x = chart_left + (i-1) * bar_width + bar_width//2
|
|
|
|
|
draw.text((x, chart_top + chart_height + 20), f"{i}⭐", fill='white', font=font_label, anchor='mm')
|
|
|
|
|
|
|
|
|
|
draw.text((chart_left + chart_width//2, chart_top - 20), "Distribution des Notes", fill='white', font=font_label, anchor='mm')
|
|
|
|
|
|
|
|
|
|
buf = io.BytesIO()
|
|
|
|
|
img.save(buf, format='PNG')
|
|
|
|
|
buf.seek(0)
|
|
|
|
|
return buf
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class StaffStatsView(discord.ui.View):
|
|
|
|
|
"""View for selecting individual staff member stats"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, bot, staff_list: List[Dict[str, Any]], analytics: StaffAnalytics):
|
|
|
|
|
super().__init__(timeout=300)
|
|
|
|
|
self.bot = bot
|
|
|
|
|
self.staff_list = staff_list
|
|
|
|
|
self.analytics = analytics
|
|
|
|
|
self._build_select()
|
|
|
|
|
|
|
|
|
|
def _build_select(self):
|
|
|
|
|
"""Build staff selection dropdown"""
|
|
|
|
|
options = []
|
|
|
|
|
for staff in self.staff_list[:25]: # Discord limit
|
|
|
|
|
label = f"{staff['name'][:20]} ({staff['tickets_claimed']} tickets)"
|
|
|
|
|
description = f"Note: {staff['average_rating']:.1f}⭐" if staff['total_ratings'] > 0 else "Aucune évaluation"
|
|
|
|
|
|
|
|
|
|
options.append(
|
|
|
|
|
discord.SelectOption(
|
|
|
|
|
label=label,
|
|
|
|
|
value=str(staff['user_id']),
|
|
|
|
|
description=description[:50]
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if options:
|
|
|
|
|
select = discord.ui.Select(
|
|
|
|
|
placeholder="Sélectionnez un membre du staff...",
|
|
|
|
|
options=options
|
|
|
|
|
)
|
|
|
|
|
select.callback = self._staff_selected
|
|
|
|
|
self.add_item(select)
|
|
|
|
|
|
|
|
|
|
async def _staff_selected(self, interaction: discord.Interaction):
|
|
|
|
|
"""Handle staff selection"""
|
|
|
|
|
staff_id = int(interaction.data["values"][0])
|
|
|
|
|
staff_data = next((s for s in self.staff_list if s['user_id'] == staff_id), None)
|
|
|
|
|
|
|
|
|
|
if not staff_data:
|
|
|
|
|
await interaction.response.send_message("Membre du staff introuvable.", ephemeral=True)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
await interaction.response.defer(ephemeral=True)
|
|
|
|
|
|
|
|
|
|
# Create individual chart
|
|
|
|
|
chart = self.analytics.create_individual_staff_chart(staff_data)
|
|
|
|
|
|
|
|
|
|
embed = discord.Embed(
|
|
|
|
|
title=f"📊 Statistiques de {staff_data['name']}",
|
|
|
|
|
description="Performances détaillées du membre du staff",
|
|
|
|
|
color=discord.Color.blue(),
|
|
|
|
|
timestamp=datetime.now()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
embed.add_field(
|
|
|
|
|
name="📈 Activité",
|
|
|
|
|
value=f"**Tickets réclamés:** {staff_data['tickets_claimed']}\n"
|
|
|
|
|
f"**Tickets fermés:** {staff_data['tickets_closed']}\n"
|
|
|
|
|
f"**Taux de résolution:** {staff_data['tickets_closed']/staff_data['tickets_claimed']*100:.1f}%" if staff_data['tickets_claimed'] > 0 else "N/A",
|
|
|
|
|
inline=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if staff_data['total_ratings'] > 0:
|
|
|
|
|
embed.add_field(
|
|
|
|
|
name="⭐ Évaluations",
|
|
|
|
|
value=f"**Note moyenne:** {staff_data['average_rating']:.1f}/5\n"
|
|
|
|
|
f"**Total d'évaluations:** {staff_data['total_ratings']}",
|
|
|
|
|
inline=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if staff_data['response_times']:
|
|
|
|
|
embed.add_field(
|
|
|
|
|
name="⏱️ Performance",
|
|
|
|
|
value=f"**Temps de réponse moyen:** {staff_data['average_response_time']:.1f} min",
|
|
|
|
|
inline=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await interaction.followup.send(embed=embed, file=discord.File(chart, 'staff_stats.png'), ephemeral=True)
|