211 lines
9.1 KiB
Python
211 lines
9.1 KiB
Python
"""
|
|
Dashboard views - PX Command Center and analytics dashboards
|
|
"""
|
|
from datetime import timedelta
|
|
|
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
from django.db.models import Avg, Count, Q
|
|
from django.shortcuts import redirect
|
|
from django.utils import timezone
|
|
from django.views.generic import TemplateView
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
|
|
|
|
class CommandCenterView(LoginRequiredMixin, TemplateView):
|
|
"""
|
|
PX Command Center Dashboard - Real-time control panel.
|
|
|
|
Shows:
|
|
- Top KPI cards (complaints, actions, surveys, etc.)
|
|
- Charts (trends, satisfaction, leaderboards)
|
|
- Live feed (latest complaints, actions, events)
|
|
- Filters (date range, hospital, department)
|
|
"""
|
|
template_name = 'dashboard/command_center.html'
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
"""Check PX Admin has selected a hospital before processing request"""
|
|
# Only check hospital selection for authenticated users
|
|
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
|
|
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
|
|
|
|
# Date filters
|
|
now = timezone.now()
|
|
last_24h = now - timedelta(hours=24)
|
|
last_7d = now - timedelta(days=7)
|
|
last_30d = now - timedelta(days=30)
|
|
|
|
# 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()
|
|
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()
|
|
elif user.is_hospital_admin() and user.hospital:
|
|
complaints_qs = Complaint.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)
|
|
elif user.is_department_manager() and user.department:
|
|
complaints_qs = Complaint.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)
|
|
else:
|
|
complaints_qs = Complaint.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()
|
|
|
|
# Top KPI Stats
|
|
context['stats'] = [
|
|
{
|
|
'label': _("Active Complaints"),
|
|
'value': complaints_qs.filter(status__in=['open', 'in_progress']).count(),
|
|
'icon': 'exclamation-triangle',
|
|
'color': 'danger'
|
|
},
|
|
{
|
|
'label': _('Overdue Complaints'),
|
|
'value': complaints_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(),
|
|
'icon': 'clock-history',
|
|
'color': 'warning'
|
|
},
|
|
{
|
|
'label': _('Open PX Actions'),
|
|
'value': actions_qs.filter(status__in=['open', 'in_progress']).count(),
|
|
'icon': 'clipboard-check',
|
|
'color': 'primary'
|
|
},
|
|
{
|
|
'label': _('Overdue Actions'),
|
|
'value': actions_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(),
|
|
'icon': 'alarm',
|
|
'color': 'danger'
|
|
},
|
|
{
|
|
'label': _('Negative Surveys (24h)'),
|
|
'value': surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count(),
|
|
'icon': 'emoji-frown',
|
|
'color': 'warning'
|
|
},
|
|
{
|
|
'label': _('Negative Social Mentions'),
|
|
'value': sum(
|
|
1 for comment in social_qs.filter(published_at__gte=last_7d)
|
|
if comment.ai_analysis and
|
|
comment.ai_analysis.get('sentiment', {}).get('classification', {}).get('en') == 'negative'
|
|
),
|
|
'icon': 'chat-dots',
|
|
'color': 'danger'
|
|
},
|
|
{
|
|
'label': _('Low Call Center Ratings'),
|
|
'value': calls_qs.filter(is_low_rating=True, call_started_at__gte=last_7d).count(),
|
|
'icon': 'telephone',
|
|
'color': 'warning'
|
|
},
|
|
{
|
|
'label': _('Avg Survey Score'),
|
|
'value': f"{surveys_qs.filter(completed_at__gte=last_30d).aggregate(Avg('total_score'))['total_score__avg'] or 0:.1f}",
|
|
'icon': 'star',
|
|
'color': 'success'
|
|
},
|
|
]
|
|
|
|
# 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 integration events
|
|
context['latest_events'] = InboundEvent.objects.filter(
|
|
status='processed'
|
|
).select_related().order_by('-processed_at')[:10]
|
|
|
|
# Staff ratings data
|
|
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 (simplified for now)
|
|
import json
|
|
context['chart_data'] = {
|
|
'complaints_trend': json.dumps(self.get_complaints_trend(complaints_qs, last_30d)),
|
|
'survey_satisfaction': self.get_survey_satisfaction(surveys_qs, last_30d),
|
|
}
|
|
|
|
# Add hospital context
|
|
context['current_hospital'] = self.request.tenant_hospital
|
|
context['is_px_admin'] = user.is_px_admin()
|
|
|
|
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_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
|