1536 lines
62 KiB
Python
1536 lines
62 KiB
Python
"""
|
|
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)
|