""" Dashboard views - PX Command Center and analytics dashboards """ import json from datetime import timedelta, datetime from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin from django.core.paginator import Paginator from django.db.models import Avg, Count, Q, Sum from django.http import JsonResponse from django.shortcuts import redirect, render, reverse from django.utils import timezone from django.views.generic import TemplateView from django.utils.translation import gettext_lazy as _ from django.contrib import messages class CommandCenterView(LoginRequiredMixin, TemplateView): """ PX Command Center Dashboard - Real-time control panel. Shows: - Red Alert Banner (urgent items requiring immediate attention) - Top KPI cards (complaints, actions, surveys, etc.) with drill-down - Charts (trends, satisfaction, leaderboards) - Live feed (latest complaints, actions, events) - Enhanced modules (Inquiries, Observations) - Filters (date range, hospital, department) Follows the "5-Second Rule": Critical signals dominant and comprehensible within 5 seconds. Uses modular tile/card system with 30-60 second auto-refresh capability. """ template_name = 'dashboard/command_center.html' def dispatch(self, request, *args, **kwargs): """Check user type and redirect accordingly""" # Redirect Source Users to their dashboard if request.user.is_authenticated and request.user.is_source_user(): return redirect('px_sources:source_user_dashboard') # Check PX Admin has selected a hospital before processing request if request.user.is_authenticated and request.user.is_px_admin() and not request.tenant_hospital: return redirect('core:select_hospital') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) user = self.request.user # Import models from apps.complaints.models import Complaint, Inquiry from apps.px_action_center.models import PXAction from apps.surveys.models import SurveyInstance from apps.social.models import SocialMediaComment from apps.callcenter.models import CallCenterInteraction from apps.integrations.models import InboundEvent from apps.physicians.models import PhysicianMonthlyRating from apps.organizations.models import Staff from apps.observations.models import Observation # Date filters now = timezone.now() last_24h = now - timedelta(hours=24) last_7d = now - timedelta(days=7) last_30d = now - timedelta(days=30) last_60d = now - timedelta(days=60) # Base querysets (filtered by user role and tenant_hospital) if user.is_px_admin(): # PX Admins use their selected hospital from session hospital = self.request.tenant_hospital complaints_qs = Complaint.objects.filter(hospital=hospital) if hospital else Complaint.objects.none() inquiries_qs = Inquiry.objects.filter(hospital=hospital) if hospital else Inquiry.objects.none() actions_qs = PXAction.objects.filter(hospital=hospital) if hospital else PXAction.objects.none() surveys_qs = SurveyInstance.objects.all() # Surveys can be viewed across hospitals social_qs = SocialMediaComment.objects.all() # Social media is organization-wide, not hospital-specific calls_qs = CallCenterInteraction.objects.filter(hospital=hospital) if hospital else CallCenterInteraction.objects.none() observations_qs = Observation.objects.filter(hospital=hospital) if hospital else Observation.objects.none() elif user.is_hospital_admin() and user.hospital: complaints_qs = Complaint.objects.filter(hospital=user.hospital) inquiries_qs = Inquiry.objects.filter(hospital=user.hospital) actions_qs = PXAction.objects.filter(hospital=user.hospital) surveys_qs = SurveyInstance.objects.filter(survey_template__hospital=user.hospital) social_qs = SocialMediaComment.objects.all() # Social media is organization-wide, not hospital-specific calls_qs = CallCenterInteraction.objects.filter(hospital=user.hospital) observations_qs = Observation.objects.filter(hospital=user.hospital) elif user.is_department_manager() and user.department: complaints_qs = Complaint.objects.filter(department=user.department) inquiries_qs = Inquiry.objects.filter(department=user.department) actions_qs = PXAction.objects.filter(department=user.department) surveys_qs = SurveyInstance.objects.filter(journey_stage_instance__department=user.department) social_qs = SocialMediaComment.objects.all() # Social media is organization-wide, not department-specific calls_qs = CallCenterInteraction.objects.filter(department=user.department) observations_qs = Observation.objects.filter(assigned_department=user.department) else: complaints_qs = Complaint.objects.none() inquiries_qs = Inquiry.objects.none() actions_qs = PXAction.objects.none() surveys_qs = SurveyInstance.objects.none() social_qs = SocialMediaComment.objects.all() # Show all social media comments calls_qs = CallCenterInteraction.objects.none() observations_qs = Observation.objects.none() # ======================================== # RED ALERT ITEMS (5-Second Rule) # ======================================== red_alerts = [] # Critical complaints critical_complaints = complaints_qs.filter( severity='critical', status__in=['open', 'in_progress'] ).count() if critical_complaints > 0: red_alerts.append({ 'type': 'critical_complaints', 'label': _('Critical Complaints'), 'value': critical_complaints, 'icon': 'alert-octagon', 'color': 'red', 'url': f"{reverse('complaints:complaint_list')}?severity=critical&status=open,in_progress", 'priority': 1 }) # Overdue complaints overdue_complaints = complaints_qs.filter( is_overdue=True, status__in=['open', 'in_progress'] ).count() if overdue_complaints > 0: red_alerts.append({ 'type': 'overdue_complaints', 'label': _('Overdue Complaints'), 'value': overdue_complaints, 'icon': 'clock', 'color': 'orange', 'url': f"{reverse('complaints:complaint_list')}?is_overdue=true&status=open,in_progress", 'priority': 2 }) # Escalated actions escalated_actions = actions_qs.filter( escalation_level__gt=0, status__in=['open', 'in_progress'] ).count() if escalated_actions > 0: red_alerts.append({ 'type': 'escalated_actions', 'label': _('Escalated Actions'), 'value': escalated_actions, 'icon': 'arrow-up-circle', 'color': 'red', 'url': reverse('actions:action_list'), 'priority': 3 }) # Negative surveys in last 24h negative_surveys_24h = surveys_qs.filter( is_negative=True, completed_at__gte=last_24h ).count() if negative_surveys_24h > 0: red_alerts.append({ 'type': 'negative_surveys', 'label': _('Negative Surveys (24h)'), 'value': negative_surveys_24h, 'icon': 'frown', 'color': 'yellow', 'url': reverse('surveys:instance_list'), 'priority': 4 }) # Sort by priority red_alerts.sort(key=lambda x: x['priority']) context['red_alerts'] = red_alerts context['has_red_alerts'] = len(red_alerts) > 0 # ======================================== # COMPLAINTS MODULE DATA # ======================================== complaints_current = complaints_qs.filter(created_at__gte=last_30d).count() complaints_previous = complaints_qs.filter( created_at__gte=last_60d, created_at__lt=last_30d ).count() complaints_variance = 0 if complaints_previous > 0: complaints_variance = round(((complaints_current - complaints_previous) / complaints_previous) * 100, 1) # Resolution time calculation resolved_complaints = complaints_qs.filter( status='closed', closed_at__isnull=False, created_at__gte=last_30d ) avg_resolution_hours = 0 if resolved_complaints.exists(): total_hours = sum( (c.closed_at - c.created_at).total_seconds() / 3600 for c in resolved_complaints ) avg_resolution_hours = round(total_hours / resolved_complaints.count(), 1) # Complaints by severity for donut chart complaints_by_severity = { 'critical': complaints_qs.filter(severity='critical', status__in=['open', 'in_progress']).count(), 'high': complaints_qs.filter(severity='high', status__in=['open', 'in_progress']).count(), 'medium': complaints_qs.filter(severity='medium', status__in=['open', 'in_progress']).count(), 'low': complaints_qs.filter(severity='low', status__in=['open', 'in_progress']).count(), } # Complaints by department for heatmap complaints_by_department = list( complaints_qs.filter( status__in=['open', 'in_progress'], department__isnull=False ).values('department__name').annotate( count=Count('id') ).order_by('-count')[:10] ) context['complaints_module'] = { 'total_active': complaints_qs.filter(status__in=['open', 'in_progress']).count(), 'current_period': complaints_current, 'previous_period': complaints_previous, 'variance': complaints_variance, 'variance_direction': 'up' if complaints_variance > 0 else 'down' if complaints_variance < 0 else 'neutral', 'avg_resolution_hours': avg_resolution_hours, 'overdue': complaints_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(), 'by_severity': complaints_by_severity, 'by_department': complaints_by_department, 'critical_new': complaints_qs.filter(severity='critical', created_at__gte=last_24h).count(), } # ======================================== # SURVEY INSIGHTS MODULE DATA # ======================================== surveys_completed_30d = surveys_qs.filter(completed_at__gte=last_30d) total_surveys_30d = surveys_completed_30d.count() # Calculate average satisfaction avg_satisfaction = surveys_completed_30d.filter( total_score__isnull=False ).aggregate(Avg('total_score'))['total_score__avg'] or 0 # NPS-style calculation (promoters - detractors) positive_count = surveys_completed_30d.filter(is_negative=False).count() negative_count = surveys_completed_30d.filter(is_negative=True).count() nps_score = 0 if total_surveys_30d > 0: nps_score = round(((positive_count - negative_count) / total_surveys_30d) * 100) # Response rate (completed vs sent in last 30 days) surveys_sent_30d = surveys_qs.filter(sent_at__gte=last_30d).count() response_rate = 0 if surveys_sent_30d > 0: response_rate = round((total_surveys_30d / surveys_sent_30d) * 100, 1) context['survey_module'] = { 'avg_satisfaction': round(avg_satisfaction, 1), 'nps_score': nps_score, 'response_rate': response_rate, 'total_completed': total_surveys_30d, 'positive_count': positive_count, 'negative_count': negative_count, 'neutral_count': total_surveys_30d - positive_count - negative_count, 'negative_24h': surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count(), } # ======================================== # PX ACTIONS MODULE DATA # ======================================== actions_open = actions_qs.filter(status='open').count() actions_in_progress = actions_qs.filter(status='in_progress').count() actions_pending_approval = actions_qs.filter(status='pending_approval').count() actions_closed_30d = actions_qs.filter(status='closed', closed_at__gte=last_30d).count() # Time to close calculation closed_actions = actions_qs.filter( status='closed', closed_at__isnull=False, created_at__gte=last_30d ) avg_time_to_close_hours = 0 if closed_actions.exists(): total_hours = sum( (a.closed_at - a.created_at).total_seconds() / 3600 for a in closed_actions ) avg_time_to_close_hours = round(total_hours / closed_actions.count(), 1) # Actions by source for breakdown actions_by_source = list( actions_qs.filter( status__in=['open', 'in_progress'] ).values('source_type').annotate( count=Count('id') ).order_by('-count') ) context['actions_module'] = { 'open': actions_open, 'in_progress': actions_in_progress, 'pending_approval': actions_pending_approval, 'overdue': actions_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(), 'escalated': actions_qs.filter(escalation_level__gt=0, status__in=['open', 'in_progress']).count(), 'closed_30d': actions_closed_30d, 'avg_time_to_close_hours': avg_time_to_close_hours, 'by_source': actions_by_source, 'new_today': actions_qs.filter(created_at__gte=last_24h).count(), } # ======================================== # INQUIRIES MODULE DATA # ======================================== inquiries_open = inquiries_qs.filter(status='open').count() inquiries_in_progress = inquiries_qs.filter(status='in_progress').count() inquiries_resolved_30d = inquiries_qs.filter(status='resolved', updated_at__gte=last_30d).count() context['inquiries_module'] = { 'open': inquiries_open, 'in_progress': inquiries_in_progress, 'total_active': inquiries_open + inquiries_in_progress, 'resolved_30d': inquiries_resolved_30d, 'new_24h': inquiries_qs.filter(created_at__gte=last_24h).count(), } # ======================================== # OBSERVATIONS MODULE DATA # ======================================== observations_new = observations_qs.filter(status='new').count() observations_in_progress = observations_qs.filter(status='in_progress').count() observations_critical = observations_qs.filter( severity='critical', status__in=['new', 'triaged', 'assigned', 'in_progress'] ).count() # Observations by category observations_by_category = list( observations_qs.filter( status__in=['new', 'triaged', 'assigned', 'in_progress'] ).values('category__name_en').annotate( count=Count('id') ).order_by('-count')[:5] ) context['observations_module'] = { 'new': observations_new, 'in_progress': observations_in_progress, 'critical': observations_critical, 'total_active': observations_new + observations_in_progress, 'resolved_30d': observations_qs.filter(status='resolved', resolved_at__gte=last_30d).count(), 'by_category': observations_by_category, } # ======================================== # COMMUNICATION/CALL CENTER MODULE DATA # ======================================== calls_7d = calls_qs.filter(call_started_at__gte=last_7d) total_calls = calls_7d.count() low_rating_calls = calls_7d.filter(is_low_rating=True).count() context['calls_module'] = { 'total_7d': total_calls, 'low_ratings': low_rating_calls, 'satisfaction_rate': round(((total_calls - low_rating_calls) / total_calls * 100), 1) if total_calls > 0 else 0, } # ======================================== # LEGACY STATS (for backward compatibility) # ======================================== context['stats'] = { 'total_complaints': context['complaints_module']['total_active'], 'avg_resolution_time': f"{avg_resolution_hours}h", 'satisfaction_score': round(avg_satisfaction, 0), 'active_actions': context['actions_module']['open'] + context['actions_module']['in_progress'], 'new_today': context['actions_module']['new_today'], } # ======================================== # LATEST ITEMS FOR LIVE FEED # ======================================== # Latest high severity complaints context['latest_complaints'] = complaints_qs.filter( severity__in=['high', 'critical'] ).select_related('patient', 'hospital', 'department').order_by('-created_at')[:5] # Latest escalated actions context['latest_actions'] = actions_qs.filter( escalation_level__gt=0 ).select_related('hospital', 'assigned_to').order_by('-escalated_at')[:5] # Latest inquiries context['latest_inquiries'] = inquiries_qs.filter( status__in=['open', 'in_progress'] ).select_related('patient', 'hospital', 'department').order_by('-created_at')[:5] # Latest observations context['latest_observations'] = observations_qs.filter( status__in=['new', 'triaged', 'assigned'] ).select_related('hospital', 'category').order_by('-created_at')[:5] # Latest integration events context['latest_events'] = InboundEvent.objects.filter( status='processed' ).select_related().order_by('-processed_at')[:10] # ======================================== # PHYSICIAN LEADERBOARD # ======================================== current_month_ratings = PhysicianMonthlyRating.objects.filter( year=now.year, month=now.month ).select_related('staff', 'staff__hospital', 'staff__department') # Filter by user role if user.is_hospital_admin() and user.hospital: current_month_ratings = current_month_ratings.filter(staff__hospital=user.hospital) elif user.is_department_manager() and user.department: current_month_ratings = current_month_ratings.filter(staff__department=user.department) # Top 5 staff this month context['top_physicians'] = current_month_ratings.order_by('-average_rating')[:5] # Staff stats physician_stats = current_month_ratings.aggregate( total_physicians=Count('id'), avg_rating=Avg('average_rating'), total_surveys=Count('total_surveys') ) context['physician_stats'] = physician_stats # ======================================== # CHART DATA # ======================================== context['chart_data'] = { 'complaints_trend': json.dumps(self.get_complaints_trend(complaints_qs, last_30d)), 'complaints_by_severity': json.dumps(complaints_by_severity), 'survey_satisfaction': avg_satisfaction, 'nps_trend': json.dumps(self.get_nps_trend(surveys_qs, last_30d)), 'actions_funnel': json.dumps({ 'open': actions_open, 'in_progress': actions_in_progress, 'pending_approval': actions_pending_approval, 'closed': actions_closed_30d, }), } # Add hospital context context['current_hospital'] = self.request.tenant_hospital context['is_px_admin'] = user.is_px_admin() # Last updated timestamp context['last_updated'] = now.strftime('%Y-%m-%d %H:%M:%S') return context def get_complaints_trend(self, queryset, start_date): """Get complaints trend data for chart""" # Group by day for last 30 days data = [] for i in range(30): date = start_date + timedelta(days=i) count = queryset.filter( created_at__date=date.date() ).count() data.append({ 'date': date.strftime('%Y-%m-%d'), 'count': count }) return data def get_nps_trend(self, queryset, start_date): """Get NPS trend data for chart""" data = [] for i in range(30): date = start_date + timedelta(days=i) day_surveys = queryset.filter(completed_at__date=date.date()) total = day_surveys.count() if total > 0: positive = day_surveys.filter(is_negative=False).count() negative = day_surveys.filter(is_negative=True).count() nps = round(((positive - negative) / total) * 100) else: nps = 0 data.append({ 'date': date.strftime('%Y-%m-%d'), 'nps': nps }) return data def get_survey_satisfaction(self, queryset, start_date): """Get survey satisfaction averages""" return queryset.filter( completed_at__gte=start_date, total_score__isnull=False ).aggregate(Avg('total_score'))['total_score__avg'] or 0 @login_required def my_dashboard(request): """ My Dashboard - Personal view of all assigned items. Shows: - Summary cards with statistics - Tabbed interface for 6 model types: * Complaints * Inquiries * Observations * PX Actions * Tasks (QI Project Tasks) * Feedback - Date range filtering - Search and filter controls - Export functionality (CSV/Excel) - Bulk actions support - Charts showing trends """ # Redirect Source Users to their dashboard if request.user.is_source_user(): return redirect('px_sources:source_user_dashboard') user = request.user # Get selected hospital for PX Admins (from middleware) selected_hospital = getattr(request, 'tenant_hospital', None) # Get date range filter date_range_days = int(request.GET.get('date_range', 30)) if date_range_days == -1: # All time start_date = None else: start_date = timezone.now() - timedelta(days=date_range_days) # Get active tab active_tab = request.GET.get('tab', 'complaints') # Get search query search_query = request.GET.get('search', '') # Get status filter status_filter = request.GET.get('status', '') # Get priority/severity filter priority_filter = request.GET.get('priority', '') # Build querysets for all models querysets = {} # 1. Complaints from apps.complaints.models import Complaint complaints_qs = Complaint.objects.filter(assigned_to=user) # Filter by selected hospital for PX Admins if selected_hospital: complaints_qs = complaints_qs.filter(hospital=selected_hospital) if start_date: complaints_qs = complaints_qs.filter(created_at__gte=start_date) if search_query: complaints_qs = complaints_qs.filter( Q(title__icontains=search_query) | Q(description__icontains=search_query) ) if status_filter: complaints_qs = complaints_qs.filter(status=status_filter) if priority_filter: complaints_qs = complaints_qs.filter(severity=priority_filter) querysets['complaints'] = complaints_qs.select_related( 'patient', 'hospital', 'department', 'source', 'created_by' ).order_by('-created_at') # 2. Inquiries from apps.complaints.models import Inquiry inquiries_qs = Inquiry.objects.filter(assigned_to=user) # Filter by selected hospital for PX Admins if selected_hospital: inquiries_qs = inquiries_qs.filter(hospital=selected_hospital) if start_date: inquiries_qs = inquiries_qs.filter(created_at__gte=start_date) if search_query: inquiries_qs = inquiries_qs.filter( Q(subject__icontains=search_query) | Q(message__icontains=search_query) ) if status_filter: inquiries_qs = inquiries_qs.filter(status=status_filter) querysets['inquiries'] = inquiries_qs.select_related( 'patient', 'hospital', 'department' ).order_by('-created_at') # 3. Observations from apps.observations.models import Observation observations_qs = Observation.objects.filter(assigned_to=user) # Filter by selected hospital for PX Admins if selected_hospital: observations_qs = observations_qs.filter(hospital=selected_hospital) if start_date: observations_qs = observations_qs.filter(created_at__gte=start_date) if search_query: observations_qs = observations_qs.filter( Q(title__icontains=search_query) | Q(description__icontains=search_query) ) if status_filter: observations_qs = observations_qs.filter(status=status_filter) if priority_filter: observations_qs = observations_qs.filter(severity=priority_filter) querysets['observations'] = observations_qs.select_related( 'hospital', 'assigned_department' ).order_by('-created_at') # 4. PX Actions from apps.px_action_center.models import PXAction actions_qs = PXAction.objects.filter(assigned_to=user) # Filter by selected hospital for PX Admins if selected_hospital: actions_qs = actions_qs.filter(hospital=selected_hospital) if start_date: actions_qs = actions_qs.filter(created_at__gte=start_date) if search_query: actions_qs = actions_qs.filter( Q(title__icontains=search_query) | Q(description__icontains=search_query) ) if status_filter: actions_qs = actions_qs.filter(status=status_filter) if priority_filter: actions_qs = actions_qs.filter(severity=priority_filter) querysets['actions'] = actions_qs.select_related( 'hospital', 'department', 'approved_by' ).order_by('-created_at') # 5. QI Project Tasks from apps.projects.models import QIProjectTask tasks_qs = QIProjectTask.objects.filter(assigned_to=user) # Filter by selected hospital for PX Admins (via project) if selected_hospital: tasks_qs = tasks_qs.filter(project__hospital=selected_hospital) if start_date: tasks_qs = tasks_qs.filter(created_at__gte=start_date) if search_query: tasks_qs = tasks_qs.filter( Q(title__icontains=search_query) | Q(description__icontains=search_query) ) if status_filter: tasks_qs = tasks_qs.filter(status=status_filter) querysets['tasks'] = tasks_qs.select_related('project').order_by('-created_at') # 6. Feedback from apps.feedback.models import Feedback feedback_qs = Feedback.objects.filter(assigned_to=user) # Filter by selected hospital for PX Admins if selected_hospital: feedback_qs = feedback_qs.filter(hospital=selected_hospital) if start_date: feedback_qs = feedback_qs.filter(created_at__gte=start_date) if search_query: feedback_qs = feedback_qs.filter( Q(title__icontains=search_query) | Q(message__icontains=search_query) ) if status_filter: feedback_qs = feedback_qs.filter(status=status_filter) if priority_filter: feedback_qs = feedback_qs.filter(priority=priority_filter) querysets['feedback'] = feedback_qs.select_related( 'hospital', 'department', 'patient' ).order_by('-created_at') # Calculate statistics stats = {} total_stats = { 'total': 0, 'open': 0, 'in_progress': 0, 'resolved': 0, 'closed': 0, 'overdue': 0 } # Complaints stats complaints_open = querysets['complaints'].filter(status='open').count() complaints_in_progress = querysets['complaints'].filter(status='in_progress').count() complaints_resolved = querysets['complaints'].filter(status='resolved').count() complaints_closed = querysets['complaints'].filter(status='closed').count() complaints_overdue = querysets['complaints'].filter(is_overdue=True).count() stats['complaints'] = { 'total': querysets['complaints'].count(), 'open': complaints_open, 'in_progress': complaints_in_progress, 'resolved': complaints_resolved, 'closed': complaints_closed, 'overdue': complaints_overdue } total_stats['total'] += stats['complaints']['total'] total_stats['open'] += complaints_open total_stats['in_progress'] += complaints_in_progress total_stats['resolved'] += complaints_resolved total_stats['closed'] += complaints_closed total_stats['overdue'] += complaints_overdue # Inquiries stats inquiries_open = querysets['inquiries'].filter(status='open').count() inquiries_in_progress = querysets['inquiries'].filter(status='in_progress').count() inquiries_resolved = querysets['inquiries'].filter(status='resolved').count() inquiries_closed = querysets['inquiries'].filter(status='closed').count() stats['inquiries'] = { 'total': querysets['inquiries'].count(), 'open': inquiries_open, 'in_progress': inquiries_in_progress, 'resolved': inquiries_resolved, 'closed': inquiries_closed, 'overdue': 0 } total_stats['total'] += stats['inquiries']['total'] total_stats['open'] += inquiries_open total_stats['in_progress'] += inquiries_in_progress total_stats['resolved'] += inquiries_resolved total_stats['closed'] += inquiries_closed # Observations stats observations_open = querysets['observations'].filter(status='open').count() observations_in_progress = querysets['observations'].filter(status='in_progress').count() observations_closed = querysets['observations'].filter(status='closed').count() # Observations don't have is_overdue field - set to 0 observations_overdue = 0 stats['observations'] = { 'total': querysets['observations'].count(), 'open': observations_open, 'in_progress': observations_in_progress, 'resolved': 0, 'closed': observations_closed, 'overdue': observations_overdue } total_stats['total'] += stats['observations']['total'] total_stats['open'] += observations_open total_stats['in_progress'] += observations_in_progress total_stats['closed'] += observations_closed total_stats['overdue'] += observations_overdue # PX Actions stats actions_open = querysets['actions'].filter(status='open').count() actions_in_progress = querysets['actions'].filter(status='in_progress').count() actions_closed = querysets['actions'].filter(status='closed').count() actions_overdue = querysets['actions'].filter(is_overdue=True).count() stats['actions'] = { 'total': querysets['actions'].count(), 'open': actions_open, 'in_progress': actions_in_progress, 'resolved': 0, 'closed': actions_closed, 'overdue': actions_overdue } total_stats['total'] += stats['actions']['total'] total_stats['open'] += actions_open total_stats['in_progress'] += actions_in_progress total_stats['closed'] += actions_closed total_stats['overdue'] += actions_overdue # Tasks stats tasks_open = querysets['tasks'].filter(status='open').count() tasks_in_progress = querysets['tasks'].filter(status='in_progress').count() tasks_closed = querysets['tasks'].filter(status='closed').count() stats['tasks'] = { 'total': querysets['tasks'].count(), 'open': tasks_open, 'in_progress': tasks_in_progress, 'resolved': 0, 'closed': tasks_closed, 'overdue': 0 } total_stats['total'] += stats['tasks']['total'] total_stats['open'] += tasks_open total_stats['in_progress'] += tasks_in_progress total_stats['closed'] += tasks_closed # Feedback stats feedback_open = querysets['feedback'].filter(status='submitted').count() feedback_in_progress = querysets['feedback'].filter(status='reviewed').count() feedback_acknowledged = querysets['feedback'].filter(status='acknowledged').count() feedback_closed = querysets['feedback'].filter(status='closed').count() stats['feedback'] = { 'total': querysets['feedback'].count(), 'open': feedback_open, 'in_progress': feedback_in_progress, 'resolved': feedback_acknowledged, 'closed': feedback_closed, 'overdue': 0 } total_stats['total'] += stats['feedback']['total'] total_stats['open'] += feedback_open total_stats['in_progress'] += feedback_in_progress total_stats['resolved'] += feedback_acknowledged total_stats['closed'] += feedback_closed # Paginate all querysets page_size = int(request.GET.get('page_size', 25)) paginated_data = {} for tab_name, qs in querysets.items(): paginator = Paginator(qs, page_size) page_number = request.GET.get(f'page_{tab_name}', 1) paginated_data[tab_name] = paginator.get_page(page_number) # Get chart data chart_data = get_dashboard_chart_data(user, start_date, selected_hospital) context = { 'stats': stats, 'total_stats': total_stats, 'paginated_data': paginated_data, 'active_tab': active_tab, 'date_range': date_range_days, 'search_query': search_query, 'status_filter': status_filter, 'priority_filter': priority_filter, 'chart_data': chart_data, 'selected_hospital': selected_hospital, # For hospital filter display } return render(request, 'dashboard/my_dashboard.html', context) def get_dashboard_chart_data(user, start_date=None, selected_hospital=None): """ Get chart data for dashboard trends. Returns JSON-serializable data for ApexCharts. """ from apps.complaints.models import Complaint from apps.px_action_center.models import PXAction from apps.observations.models import Observation from apps.feedback.models import Feedback from apps.complaints.models import Inquiry from apps.projects.models import QIProjectTask # Default to last 30 days if no start_date if not start_date: start_date = timezone.now() - timedelta(days=30) # Get completion trends completion_data = [] labels = [] # Group by day for last 30 days for i in range(30): date = start_date + timedelta(days=i) date_str = date.strftime('%Y-%m-%d') labels.append(date.strftime('%b %d')) completed_count = 0 # Check each model for completions on this date # Apply hospital filter for PX Admins complaint_qs = Complaint.objects.filter( assigned_to=user, status='closed', closed_at__date=date.date() ) if selected_hospital: complaint_qs = complaint_qs.filter(hospital=selected_hospital) completed_count += complaint_qs.count() inquiry_qs = Inquiry.objects.filter( assigned_to=user, status='closed', updated_at__date=date.date() ) if selected_hospital: inquiry_qs = inquiry_qs.filter(hospital=selected_hospital) completed_count += inquiry_qs.count() observation_qs = Observation.objects.filter( assigned_to=user, status='closed', updated_at__date=date.date() ) if selected_hospital: observation_qs = observation_qs.filter(hospital=selected_hospital) completed_count += observation_qs.count() action_qs = PXAction.objects.filter( assigned_to=user, status='closed', closed_at__date=date.date() ) if selected_hospital: action_qs = action_qs.filter(hospital=selected_hospital) completed_count += action_qs.count() task_qs = QIProjectTask.objects.filter( assigned_to=user, status='closed', completed_date=date.date() ) if selected_hospital: task_qs = task_qs.filter(project__hospital=selected_hospital) completed_count += task_qs.count() feedback_qs = Feedback.objects.filter( assigned_to=user, status='closed', closed_at__date=date.date() ) if selected_hospital: feedback_qs = feedback_qs.filter(hospital=selected_hospital) completed_count += feedback_qs.count() completion_data.append(completed_count) return { 'completion_trend': { 'labels': labels, 'data': completion_data } } @login_required def dashboard_bulk_action(request): """ Handle bulk actions on dashboard items. Supported actions: - bulk_assign: Assign to user - bulk_status: Change status """ if request.method != 'POST': return JsonResponse({'success': False, 'error': 'POST required'}, status=405) import json try: data = json.loads(request.body) action = data.get('action') tab_name = data.get('tab') item_ids = data.get('item_ids', []) if not action or not tab_name: return JsonResponse({'success': False, 'error': 'Missing required fields'}, status=400) # Route to appropriate handler based on tab if tab_name == 'complaints': from apps.complaints.models import Complaint queryset = Complaint.objects.filter(id__in=item_ids, assigned_to=request.user) elif tab_name == 'inquiries': from apps.complaints.models import Inquiry queryset = Inquiry.objects.filter(id__in=item_ids, assigned_to=request.user) elif tab_name == 'observations': from apps.observations.models import Observation queryset = Observation.objects.filter(id__in=item_ids, assigned_to=request.user) elif tab_name == 'actions': from apps.px_action_center.models import PXAction queryset = PXAction.objects.filter(id__in=item_ids, assigned_to=request.user) elif tab_name == 'tasks': from apps.projects.models import QIProjectTask queryset = QIProjectTask.objects.filter(id__in=item_ids, assigned_to=request.user) elif tab_name == 'feedback': from apps.feedback.models import Feedback queryset = Feedback.objects.filter(id__in=item_ids, assigned_to=request.user) else: return JsonResponse({'success': False, 'error': 'Invalid tab'}, status=400) # Apply bulk action if action == 'bulk_status': new_status = data.get('new_status') if not new_status: return JsonResponse({'success': False, 'error': 'Missing new_status'}, status=400) count = queryset.update(status=new_status) return JsonResponse({'success': True, 'updated_count': count}) elif action == 'bulk_assign': user_id = data.get('user_id') if not user_id: return JsonResponse({'success': False, 'error': 'Missing user_id'}, status=400) from apps.accounts.models import User assignee = User.objects.get(id=user_id) count = queryset.update(assigned_to=assignee, assigned_at=timezone.now()) return JsonResponse({'success': True, 'updated_count': count}) else: return JsonResponse({'success': False, 'error': 'Invalid action'}, status=400) except json.JSONDecodeError: return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400) except Exception as e: return JsonResponse({'success': False, 'error': str(e)}, status=500) @login_required def admin_evaluation(request): """ Admin Evaluation Dashboard - Staff performance analysis. Shows: - Performance metrics for all staff members - Complaints: Source breakdown, status distribution, response time, activation time - Inquiries: Status distribution, response time - Multi-staff comparison - Date range filtering - Hospital/department filtering Access: PX Admin and Hospital Admin only """ from django.core.exceptions import PermissionDenied from apps.analytics.services.analytics_service import UnifiedAnalyticsService from apps.accounts.models import User from apps.organizations.models import Hospital, Department user = request.user # Only PX Admins and Hospital Admins can access if not (user.is_px_admin() or user.is_hospital_admin()): raise PermissionDenied("Only PX Admins and Hospital Admins can access the Admin Evaluation dashboard.") # Get date range filter date_range = request.GET.get('date_range', '30d') custom_start = request.GET.get('custom_start') custom_end = request.GET.get('custom_end') # Parse custom dates if provided if custom_start: from datetime import datetime custom_start = datetime.fromisoformat(custom_start) if custom_end: from datetime import datetime custom_end = datetime.fromisoformat(custom_end) # Get hospital and department filters hospital_id = request.GET.get('hospital_id') department_id = request.GET.get('department_id') # Get selected staff IDs for comparison selected_staff_ids = request.GET.getlist('staff_ids') # Get available hospitals (for PX Admins) if user.is_px_admin(): hospitals = Hospital.objects.filter(status='active') elif user.is_hospital_admin() and user.hospital: hospitals = Hospital.objects.filter(id=user.hospital.id) hospital_id = hospital_id or user.hospital.id # Default to user's hospital else: hospitals = Hospital.objects.none() # Get available departments based on hospital filter if hospital_id: departments = Department.objects.filter(hospital_id=hospital_id, status='active') elif user.hospital: departments = Department.objects.filter(hospital=user.hospital, status='active') else: departments = Department.objects.none() # Get staff performance metrics performance_data = UnifiedAnalyticsService.get_staff_performance_metrics( user=user, date_range=date_range, hospital_id=hospital_id, department_id=department_id, staff_ids=selected_staff_ids if selected_staff_ids else None, custom_start=custom_start, custom_end=custom_end ) # Get all staff for the dropdown staff_queryset = User.objects.all() if user.is_px_admin() and hospital_id: staff_queryset = staff_queryset.filter(hospital_id=hospital_id) elif not user.is_px_admin() and user.hospital: staff_queryset = staff_queryset.filter(hospital=user.hospital) hospital_id = hospital_id or user.hospital.id if department_id: staff_queryset = staff_queryset.filter(department_id=department_id) # Only staff with assigned complaints or inquiries staff_queryset = staff_queryset.filter( Q(assigned_complaints__isnull=False) | Q(assigned_inquiries__isnull=False) ).distinct().select_related('hospital', 'department') context = { 'hospitals': hospitals, 'departments': departments, 'staff_list': staff_queryset, 'selected_hospital_id': hospital_id, 'selected_department_id': department_id, 'selected_staff_ids': selected_staff_ids, 'date_range': date_range, 'custom_start': custom_start, 'custom_end': custom_end, 'performance_data': performance_data, } return render(request, 'dashboard/admin_evaluation.html', context) @login_required def admin_evaluation_chart_data(request): """ API endpoint to get chart data for admin evaluation dashboard. Access: PX Admin and Hospital Admin only """ from apps.analytics.services.analytics_service import UnifiedAnalyticsService if request.method != 'GET': return JsonResponse({'success': False, 'error': 'GET required'}, status=405) user = request.user # Only PX Admins and Hospital Admins can access if not (user.is_px_admin() or user.is_hospital_admin()): return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403) chart_type = request.GET.get('chart_type') date_range = request.GET.get('date_range', '30d') hospital_id = request.GET.get('hospital_id') department_id = request.GET.get('department_id') staff_ids = request.GET.getlist('staff_ids') # Parse custom dates if provided custom_start = request.GET.get('custom_start') custom_end = request.GET.get('custom_end') if custom_start: from datetime import datetime custom_start = datetime.fromisoformat(custom_start) if custom_end: from datetime import datetime custom_end = datetime.fromisoformat(custom_end) try: if chart_type == 'staff_performance': data = UnifiedAnalyticsService.get_staff_performance_metrics( user=user, date_range=date_range, hospital_id=hospital_id, department_id=department_id, staff_ids=staff_ids if staff_ids else None, custom_start=custom_start, custom_end=custom_end ) else: data = {'error': f'Unknown chart type: {chart_type}'} return JsonResponse({'success': True, 'data': data}) except Exception as e: return JsonResponse({'success': False, 'error': str(e)}, status=500) # ============================================================================ # ENHANCED ADMIN EVALUATION VIEWS # ============================================================================ @login_required def staff_performance_detail(request, staff_id): """ Detailed performance view for a single staff member. Shows: - Performance score with breakdown - Daily workload trends - Recent complaints and inquiries - Performance metrics Access: PX Admin and Hospital Admin only """ from django.core.exceptions import PermissionDenied from apps.analytics.services.analytics_service import UnifiedAnalyticsService # Only PX Admins and Hospital Admins can access if not (request.user.is_px_admin() or request.user.is_hospital_admin()): raise PermissionDenied("Only PX Admins and Hospital Admins can access staff performance details.") from apps.accounts.models import User user = request.user # Get date range date_range = request.GET.get('date_range', '30d') try: staff = User.objects.select_related('hospital', 'department').get(id=staff_id) # Check permissions if not user.is_px_admin(): if user.hospital and staff.hospital != user.hospital: messages.error(request, "You don't have permission to view this staff member's performance.") return redirect('dashboard:admin_evaluation') # Get detailed performance performance = UnifiedAnalyticsService.get_staff_detailed_performance( staff_id=staff_id, user=user, date_range=date_range ) # Get trends trends = UnifiedAnalyticsService.get_staff_performance_trends( staff_id=staff_id, user=user, months=6 ) context = { 'staff': performance['staff'], 'performance': performance, 'trends': trends, 'date_range': date_range } return render(request, 'dashboard/staff_performance_detail.html', context) except User.DoesNotExist: messages.error(request, "Staff member not found.") return redirect('dashboard:admin_evaluation') except PermissionError: messages.error(request, "You don't have permission to view this staff member.") return redirect('dashboard:admin_evaluation') @login_required def staff_performance_trends(request, staff_id): """ API endpoint to get staff performance trends as JSON. Access: PX Admin and Hospital Admin only """ from apps.analytics.services.analytics_service import UnifiedAnalyticsService from apps.accounts.models import User # Only PX Admins and Hospital Admins can access if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403) user = request.user months = int(request.GET.get('months', 6)) try: trends = UnifiedAnalyticsService.get_staff_performance_trends( staff_id=staff_id, user=user, months=months ) return JsonResponse({'success': True, 'trends': trends}) except Exception as e: return JsonResponse({'success': False, 'error': str(e)}, status=400) @login_required def department_benchmarks(request): """ Department benchmarking view comparing all staff. Access: PX Admin and Hospital Admin only """ from django.core.exceptions import PermissionDenied from apps.analytics.services.analytics_service import UnifiedAnalyticsService # Only PX Admins and Hospital Admins can access if not (request.user.is_px_admin() or request.user.is_hospital_admin()): raise PermissionDenied("Only PX Admins and Hospital Admins can access department benchmarks.") user = request.user # Get filters department_id = request.GET.get('department_id') date_range = request.GET.get('date_range', '30d') # If user is department manager, use their department if user.is_department_manager() and user.department and not department_id: department_id = str(user.department.id) try: benchmarks = UnifiedAnalyticsService.get_department_benchmarks( user=user, department_id=department_id, date_range=date_range ) context = { 'benchmarks': benchmarks, 'date_range': date_range } return render(request, 'dashboard/department_benchmarks.html', context) except Exception as e: messages.error(request, f"Error loading benchmarks: {str(e)}") return redirect('dashboard:admin_evaluation') @login_required def export_staff_performance(request): """ Export staff performance report in various formats. Access: PX Admin and Hospital Admin only """ from django.core.exceptions import PermissionDenied from apps.analytics.services.analytics_service import UnifiedAnalyticsService import csv import json from django.http import HttpResponse # Only PX Admins and Hospital Admins can access if not (request.user.is_px_admin() or request.user.is_hospital_admin()): raise PermissionDenied("Only PX Admins and Hospital Admins can export staff performance.") user = request.user if request.method != 'POST': return JsonResponse({'error': 'POST required'}, status=405) try: data = json.loads(request.body) staff_ids = data.get('staff_ids', []) date_range = data.get('date_range', '30d') format_type = data.get('format', 'csv') # Generate report report = UnifiedAnalyticsService.export_staff_performance_report( staff_ids=staff_ids, user=user, date_range=date_range, format_type=format_type ) if format_type == 'csv': response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = f'attachment; filename="staff_performance_{timezone.now().strftime("%Y%m%d")}.csv"' if report['data']: writer = csv.DictWriter(response, fieldnames=report['data'][0].keys()) writer.writeheader() writer.writerows(report['data']) return response elif format_type == 'json': return JsonResponse(report) else: return JsonResponse({'error': f'Unsupported format: {format_type}'}, status=400) except Exception as e: return JsonResponse({'error': str(e)}, status=500) @login_required def command_center_api(request): """ API endpoint for Command Center live data updates. Returns JSON with all module data for AJAX refresh without page reload. Enables true real-time updates every 30-60 seconds. """ from apps.complaints.models import Complaint, Inquiry from apps.px_action_center.models import PXAction from apps.surveys.models import SurveyInstance from apps.physicians.models import PhysicianMonthlyRating from apps.observations.models import Observation user = request.user now = timezone.now() last_24h = now - timedelta(hours=24) last_30d = now - timedelta(days=30) last_60d = now - timedelta(days=60) # Build querysets based on user role if user.is_px_admin(): hospital = request.tenant_hospital complaints_qs = Complaint.objects.filter(hospital=hospital) if hospital else Complaint.objects.none() inquiries_qs = Inquiry.objects.filter(hospital=hospital) if hospital else Inquiry.objects.none() actions_qs = PXAction.objects.filter(hospital=hospital) if hospital else PXAction.objects.none() surveys_qs = SurveyInstance.objects.all() observations_qs = Observation.objects.filter(hospital=hospital) if hospital else Observation.objects.none() elif user.is_hospital_admin() and user.hospital: complaints_qs = Complaint.objects.filter(hospital=user.hospital) inquiries_qs = Inquiry.objects.filter(hospital=user.hospital) actions_qs = PXAction.objects.filter(hospital=user.hospital) surveys_qs = SurveyInstance.objects.filter(survey_template__hospital=user.hospital) observations_qs = Observation.objects.filter(hospital=user.hospital) elif user.is_department_manager() and user.department: complaints_qs = Complaint.objects.filter(department=user.department) inquiries_qs = Inquiry.objects.filter(department=user.department) actions_qs = PXAction.objects.filter(department=user.department) surveys_qs = SurveyInstance.objects.filter(journey_stage_instance__department=user.department) observations_qs = Observation.objects.filter(assigned_department=user.department) else: complaints_qs = Complaint.objects.none() inquiries_qs = Inquiry.objects.none() actions_qs = PXAction.objects.none() surveys_qs = SurveyInstance.objects.none() observations_qs = Observation.objects.none() # Calculate all module data # Complaints complaints_current = complaints_qs.filter(created_at__gte=last_30d).count() complaints_previous = complaints_qs.filter(created_at__gte=last_60d, created_at__lt=last_30d).count() complaints_variance = round(((complaints_current - complaints_previous) / complaints_previous) * 100, 1) if complaints_previous > 0 else 0 # Surveys surveys_completed_30d = surveys_qs.filter(completed_at__gte=last_30d) total_surveys_30d = surveys_completed_30d.count() positive_count = surveys_completed_30d.filter(is_negative=False).count() negative_count = surveys_completed_30d.filter(is_negative=True).count() nps_score = round(((positive_count - negative_count) / total_surveys_30d) * 100) if total_surveys_30d > 0 else 0 avg_satisfaction = surveys_completed_30d.filter(total_score__isnull=False).aggregate(Avg('total_score'))['total_score__avg'] or 0 surveys_sent_30d = surveys_qs.filter(sent_at__gte=last_30d).count() response_rate = round((total_surveys_30d / surveys_sent_30d) * 100, 1) if surveys_sent_30d > 0 else 0 # Actions actions_open = actions_qs.filter(status='open').count() actions_in_progress = actions_qs.filter(status='in_progress').count() actions_pending_approval = actions_qs.filter(status='pending_approval').count() actions_closed_30d = actions_qs.filter(status='closed', closed_at__gte=last_30d).count() # Red alerts red_alerts = [] critical_complaints = complaints_qs.filter(severity='critical', status__in=['open', 'in_progress']).count() if critical_complaints > 0: red_alerts.append({'type': 'critical_complaints', 'value': critical_complaints}) overdue_complaints = complaints_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count() if overdue_complaints > 0: red_alerts.append({'type': 'overdue_complaints', 'value': overdue_complaints}) escalated_actions = actions_qs.filter(escalation_level__gt=0, status__in=['open', 'in_progress']).count() if escalated_actions > 0: red_alerts.append({'type': 'escalated_actions', 'value': escalated_actions}) negative_surveys_24h = surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count() if negative_surveys_24h > 0: red_alerts.append({'type': 'negative_surveys', 'value': negative_surveys_24h}) return JsonResponse({ 'success': True, 'timestamp': now.isoformat(), 'last_updated': now.strftime('%Y-%m-%d %H:%M:%S'), 'red_alerts': { 'has_alerts': len(red_alerts) > 0, 'count': len(red_alerts), 'items': red_alerts }, 'modules': { 'complaints': { 'total_active': complaints_qs.filter(status__in=['open', 'in_progress']).count(), 'variance': complaints_variance, 'variance_direction': 'up' if complaints_variance > 0 else 'down' if complaints_variance < 0 else 'neutral', 'overdue': complaints_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(), 'critical': complaints_qs.filter(severity='critical', status__in=['open', 'in_progress']).count(), 'by_severity': { 'critical': complaints_qs.filter(severity='critical', status__in=['open', 'in_progress']).count(), 'high': complaints_qs.filter(severity='high', status__in=['open', 'in_progress']).count(), 'medium': complaints_qs.filter(severity='medium', status__in=['open', 'in_progress']).count(), 'low': complaints_qs.filter(severity='low', status__in=['open', 'in_progress']).count(), } }, 'surveys': { 'nps_score': nps_score, 'avg_satisfaction': round(avg_satisfaction, 1), 'response_rate': response_rate, 'total_completed': total_surveys_30d, 'negative_24h': surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count() }, 'actions': { 'open': actions_open, 'in_progress': actions_in_progress, 'pending_approval': actions_pending_approval, 'closed_30d': actions_closed_30d, 'overdue': actions_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(), 'escalated': actions_qs.filter(escalation_level__gt=0, status__in=['open', 'in_progress']).count() }, 'inquiries': { 'open': inquiries_qs.filter(status='open').count(), 'in_progress': inquiries_qs.filter(status='in_progress').count(), 'total_active': inquiries_qs.filter(status__in=['open', 'in_progress']).count(), 'new_24h': inquiries_qs.filter(created_at__gte=last_24h).count() }, 'observations': { 'new': observations_qs.filter(status='new').count(), 'in_progress': observations_qs.filter(status='in_progress').count(), 'total_active': observations_qs.filter(status__in=['new', 'in_progress']).count(), 'critical': observations_qs.filter(severity='critical', status__in=['new', 'triaged', 'assigned', 'in_progress']).count() } } }) @login_required def performance_analytics_api(request): """ API endpoint for various performance analytics. Access: PX Admin and Hospital Admin only """ from apps.analytics.services.analytics_service import UnifiedAnalyticsService # Only PX Admins and Hospital Admins can access if not (request.user.is_px_admin() or request.user.is_hospital_admin()): return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403) user = request.user chart_type = request.GET.get('chart_type') try: if chart_type == 'staff_trends': staff_id = request.GET.get('staff_id') months = int(request.GET.get('months', 6)) data = UnifiedAnalyticsService.get_staff_performance_trends( staff_id=staff_id, user=user, months=months ) elif chart_type == 'department_benchmarks': department_id = request.GET.get('department_id') date_range = request.GET.get('date_range', '30d') data = UnifiedAnalyticsService.get_department_benchmarks( user=user, department_id=department_id, date_range=date_range ) else: return JsonResponse({'error': f'Unknown chart type: {chart_type}'}, status=400) return JsonResponse({'success': True, 'data': data}) except Exception as e: return JsonResponse({'success': False, 'error': str(e)}, status=500)