""" 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 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 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) # Invalidate cache to ensure we get the latest ticket data with new ratings self.storage.invalidate_cache(guild_id) 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: """Create a comprehensive staff rating chart using matplotlib""" # Filter staff with ratings rated_staff = [s for s in staff_list if s['total_ratings'] > 0] if not rated_staff: # Return empty chart 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') buf = io.BytesIO() plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136') buf.seek(0) plt.close() return buf # 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] ratings = [s['average_rating'] for s in top_staff] counts = [s['total_ratings'] for s in top_staff] # 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)') ] ax.legend(handles=legend_elements, loc='upper right', facecolor='#2F3136', edgecolor='white', labelcolor='white') # Adjust layout plt.tight_layout() # Save to buffer buf = io.BytesIO() plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136') buf.seek(0) plt.close() 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)