1880 lines
82 KiB
HTML
1880 lines
82 KiB
HTML
{% extends "layouts/base.html" %}
|
||
{% load i18n %}
|
||
{% load static %}
|
||
|
||
{% block title %}{% trans "Analytics Dashboard" %} - PX360{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
/* PX360 App Theme Variables */
|
||
:root {
|
||
--hh-navy: #005696;
|
||
--hh-blue: #007bbd;
|
||
--hh-light: #eef6fb;
|
||
--hh-slate: #64748b;
|
||
--hh-success: #10b981;
|
||
--hh-warning: #f59e0b;
|
||
--hh-danger: #ef4444;
|
||
}
|
||
|
||
/* Page Header */
|
||
.page-header-gradient {
|
||
background: linear-gradient(135deg, var(--hh-navy) 0%, #0069a8 50%, var(--hh-blue) 100%);
|
||
color: white;
|
||
padding: 2rem 2.5rem;
|
||
border-radius: 1rem;
|
||
margin-bottom: 2rem;
|
||
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.page-header-gradient::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
width: 200px;
|
||
height: 200px;
|
||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||
}
|
||
|
||
/* Section Cards */
|
||
.section-card {
|
||
background: white;
|
||
border-radius: 1rem;
|
||
border: 2px solid #e2e8f0;
|
||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||
overflow: hidden;
|
||
transition: all 0.3s ease;
|
||
}
|
||
.section-card:hover {
|
||
border-color: #005696;
|
||
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
|
||
}
|
||
|
||
.section-header {
|
||
padding: 1rem 1.5rem;
|
||
border-bottom: 2px solid #e2e8f0;
|
||
background: linear-gradient(to right, #f8fafc, #f1f5f9);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.section-icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 0.75rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* KPI Cards */
|
||
.kpi-card {
|
||
background: white;
|
||
border-radius: 1rem;
|
||
border: 2px solid #e2e8f0;
|
||
padding: 1.25rem;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||
transition: all 0.3s ease;
|
||
height: 100%;
|
||
}
|
||
.kpi-card:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||
border-color: #005696;
|
||
}
|
||
|
||
.kpi-icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 0.75rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.kpi-value {
|
||
font-size: 1.75rem;
|
||
font-weight: 800;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.kpi-label {
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
color: #64748b;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.025em;
|
||
}
|
||
|
||
/* Chart Cards */
|
||
.chart-card {
|
||
background: white;
|
||
border-radius: 1rem;
|
||
border: 2px solid #e2e8f0;
|
||
overflow: hidden;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||
transition: all 0.3s ease;
|
||
}
|
||
.chart-card:hover {
|
||
border-color: #005696;
|
||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.chart-header {
|
||
background: linear-gradient(135deg, #f8fafc, #eef6fb);
|
||
padding: 1rem 1.25rem;
|
||
border-bottom: 2px solid #e2e8f0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.chart-header h3 {
|
||
color: #005696;
|
||
font-weight: 700;
|
||
margin: 0;
|
||
}
|
||
|
||
/* Form Styling - PX360 Theme */
|
||
.form-select-px360 {
|
||
background: white;
|
||
border: 2px solid #e2e8f0;
|
||
border-radius: 0.75rem;
|
||
padding: 0.625rem 1rem;
|
||
color: #1e293b;
|
||
font-size: 0.875rem;
|
||
min-width: 200px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.form-select-px360:hover {
|
||
border-color: #005696;
|
||
}
|
||
.form-select-px360:focus {
|
||
outline: none;
|
||
border-color: #005696;
|
||
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
|
||
}
|
||
|
||
/* Data Tables */
|
||
.data-table {
|
||
width: 100%;
|
||
border-collapse: separate;
|
||
border-spacing: 0;
|
||
}
|
||
.data-table th {
|
||
background: #f8fafc;
|
||
border-bottom: 2px solid #e2e8f0;
|
||
padding: 0.875rem 1rem;
|
||
font-size: 0.75rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
color: #64748b;
|
||
text-align: left;
|
||
}
|
||
.data-table td {
|
||
border-bottom: 1px solid #e2e8f0;
|
||
padding: 0.875rem 1rem;
|
||
color: #1e293b;
|
||
}
|
||
.data-table tr:hover td {
|
||
background: #f8fafc;
|
||
}
|
||
|
||
/* Status Badges */
|
||
.badge {
|
||
padding: 0.25rem 0.75rem;
|
||
border-radius: 9999px;
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
}
|
||
.badge-blue { background: #dbeafe; color: #1d4ed8; }
|
||
.badge-green { background: #d1fae5; color: #047857; }
|
||
.badge-orange { background: #fef3c7; color: #b45309; }
|
||
.badge-red { background: #fee2e2; color: #b91c1c; }
|
||
.badge-purple { background: #f3e8ff; color: #7c3aed; }
|
||
|
||
/* Progress Bars */
|
||
.progress-bar {
|
||
height: 8px;
|
||
border-radius: 4px;
|
||
background: #e2e8f0;
|
||
overflow: hidden;
|
||
}
|
||
.progress-fill {
|
||
height: 100%;
|
||
border-radius: 4px;
|
||
transition: width 0.5s ease;
|
||
}
|
||
|
||
/* Animations */
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
.animate-in {
|
||
animation: fadeIn 0.4s ease-out forwards;
|
||
}
|
||
.delay-100 { animation-delay: 100ms; }
|
||
.delay-200 { animation-delay: 200ms; }
|
||
.delay-300 { animation-delay: 300ms; }
|
||
.delay-400 { animation-delay: 400ms; }
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="px-4 py-6 max-w-[1600px] mx-auto">
|
||
|
||
<!-- Page Header -->
|
||
<div class="page-header-gradient animate-in">
|
||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 relative z-10">
|
||
<div>
|
||
<h1 class="text-2xl font-bold flex items-center gap-3">
|
||
<i data-lucide="bar-chart-3" class="w-8 h-8"></i>
|
||
{% trans "Analytics Dashboard" %}
|
||
</h1>
|
||
<p class="mt-1 opacity-90">{% trans "Comprehensive overview of patient experience metrics" %}</p>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
<button id="refreshAiBtn" onclick="refreshAiAnalytics()" class="px-4 py-2.5 bg-white/20 hover:bg-white/30 rounded-xl transition text-sm font-medium flex items-center gap-2" title="{% trans 'Refresh AI Analytics' %}">
|
||
<i data-lucide="sparkles" class="w-4 h-4 text-white"></i>
|
||
<span>{% trans "Refresh AI" %}</span>
|
||
</button>
|
||
<button onclick="refreshDashboard()" class="p-2.5 bg-white/20 hover:bg-white/30 rounded-xl transition" title="{% trans 'Refresh' %}">
|
||
<i data-lucide="refresh-cw" class="w-5 h-5 text-white"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- KPI Grid Row 1 -->
|
||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-8 gap-4 mb-6">
|
||
<!-- Total Complaints -->
|
||
<div class="kpi-card animate-in">
|
||
<div class="flex items-start justify-between mb-3">
|
||
<div class="kpi-icon" style="background: linear-gradient(135deg, #fee2e2, #fecaca);">
|
||
<i data-lucide="message-square-warning" class="w-5 h-5 text-red-600"></i>
|
||
</div>
|
||
<span class="badge badge-red">{{ kpis.open_complaints }} {% trans "open" %}</span>
|
||
</div>
|
||
<p class="kpi-value">{{ kpis.total_complaints }}</p>
|
||
<p class="kpi-label mt-1">{% trans "Total Complaints" %}</p>
|
||
</div>
|
||
|
||
<!-- SLA Compliance -->
|
||
<div class="kpi-card animate-in delay-100">
|
||
<div class="flex items-start justify-between mb-3">
|
||
<div class="kpi-icon" style="background: linear-gradient(135deg, #d1fae5, #a7f3d0);">
|
||
<i data-lucide="check-circle" class="w-5 h-5 text-emerald-600"></i>
|
||
</div>
|
||
</div>
|
||
<p class="kpi-value">{{ kpis.sla_compliance }}%</p>
|
||
<p class="kpi-label mt-1">{% trans "SLA Compliance" %}</p>
|
||
</div>
|
||
|
||
<!-- Avg Resolution -->
|
||
<div class="kpi-card animate-in delay-200">
|
||
<div class="flex items-start justify-between mb-3">
|
||
<div class="kpi-icon" style="background: linear-gradient(135deg, #f3e8ff, #e9d5ff);">
|
||
<i data-lucide="clock" class="w-5 h-5 text-purple-600"></i>
|
||
</div>
|
||
</div>
|
||
<p class="kpi-value">{{ kpis.avg_resolution_hours }}h</p>
|
||
<p class="kpi-label mt-1">{% trans "Avg Resolution" %}</p>
|
||
</div>
|
||
|
||
<!-- Total Actions -->
|
||
<div class="kpi-card animate-in delay-300">
|
||
<div class="flex items-start justify-between mb-3">
|
||
<div class="kpi-icon" style="background: linear-gradient(135deg, #fef3c7, #fde68a);">
|
||
<i data-lucide="zap" class="w-5 h-5 text-orange-600"></i>
|
||
</div>
|
||
<span class="badge badge-orange">{{ kpis.open_actions }} {% trans "open" %}</span>
|
||
</div>
|
||
<p class="kpi-value">{{ kpis.total_actions }}</p>
|
||
<p class="kpi-label mt-1">{% trans "Total Actions" %}</p>
|
||
</div>
|
||
|
||
<!-- Reopened -->
|
||
<div class="kpi-card animate-in delay-400">
|
||
<div class="flex items-start justify-between mb-3">
|
||
<div class="kpi-icon" style="background: linear-gradient(135deg, #ffedd5, #fed7aa);">
|
||
<i data-lucide="rotate-ccw" class="w-5 h-5 text-orange-500"></i>
|
||
</div>
|
||
</div>
|
||
<p class="kpi-value">{{ kpis.reopened_complaints }}</p>
|
||
<p class="kpi-label mt-1">{% trans "Reopened" %}</p>
|
||
</div>
|
||
|
||
<!-- Escalated OVR -->
|
||
<div class="kpi-card animate-in delay-500">
|
||
<div class="flex items-start justify-between mb-3">
|
||
<div class="kpi-icon" style="background: linear-gradient(135deg, #f3e8ff, #e9d5ff);">
|
||
<i data-lucide="shield-alert" class="w-5 h-5 text-purple-500"></i>
|
||
</div>
|
||
</div>
|
||
<p class="kpi-value">{{ kpis.escalated_ovr_complaints }}</p>
|
||
<p class="kpi-label mt-1">{% trans "Escalated OVR" %}</p>
|
||
</div>
|
||
|
||
<!-- Survey Score -->
|
||
<div class="kpi-card animate-in delay-600">
|
||
<div class="flex items-start justify-between mb-3">
|
||
<div class="kpi-icon" style="background: linear-gradient(135deg, #dbeafe, #bfdbfe);">
|
||
<i data-lucide="star" class="w-5 h-5 text-blue-600"></i>
|
||
</div>
|
||
</div>
|
||
<p class="kpi-value">{{ kpis.avg_survey_score }}</p>
|
||
<p class="kpi-label mt-1">{% trans "Avg Survey Score" %}</p>
|
||
</div>
|
||
|
||
<!-- NPS Score -->
|
||
<div class="kpi-card animate-in delay-700">
|
||
<div class="flex items-start justify-between mb-3">
|
||
<div class="kpi-icon" style="background: linear-gradient(135deg, #005696, #007bbd);">
|
||
<i data-lucide="thumbs-up" class="w-5 h-5 text-white"></i>
|
||
</div>
|
||
</div>
|
||
<p class="kpi-value">{{ kpis.nps_score }}</p>
|
||
<p class="kpi-label mt-1">{% trans "NPS Score" %}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Charts Section: Complaints -->
|
||
<div class="section-card mb-6 animate-in delay-100">
|
||
<div class="section-header">
|
||
<div class="section-icon" style="background: linear-gradient(135deg, #fee2e2, #fecaca);">
|
||
<i data-lucide="message-square-warning" class="w-6 h-6 text-red-600"></i>
|
||
</div>
|
||
<div>
|
||
<h2 class="text-xl font-bold text-navy">{% trans "Complaints Analytics" %}</h2>
|
||
<p class="text-sm text-slate">{% trans "Status, sources, and severity breakdown" %}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="p-6">
|
||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
<!-- Status Distribution -->
|
||
<div class="chart-card">
|
||
<div class="chart-header">
|
||
<i data-lucide="pie-chart" class="w-5 h-5 text-navy"></i>
|
||
<h3>{% trans "Status Distribution" %}</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<canvas id="complaintStatusChart" class="h-64 w-full"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sources -->
|
||
<div class="chart-card">
|
||
<div class="chart-header">
|
||
<i data-lucide="git-branch" class="w-5 h-5 text-navy"></i>
|
||
<h3>{% trans "Complaint Sources" %}</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<canvas id="complaintSourcesChart" class="h-64 w-full"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Severity -->
|
||
<div class="chart-card">
|
||
<div class="chart-header">
|
||
<i data-lucide="alert-triangle" class="w-5 h-5 text-navy"></i>
|
||
<h3>{% trans "Severity Levels" %}</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<canvas id="severityChart" class="h-64 w-full"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cross-Module Yearly Trends - Area Charts -->
|
||
<div class="section-card mb-6 animate-in delay-150">
|
||
<div class="section-header">
|
||
<div class="section-icon" style="background: linear-gradient(135deg, #dbeafe, #bfdbfe);">
|
||
<i data-lucide="area-chart" class="w-6 h-6 text-blue-600"></i>
|
||
</div>
|
||
<div class="flex-1">
|
||
<h2 class="text-xl font-bold text-navy">{% trans "Cross-Module Yearly Trends" %}</h2>
|
||
<p class="text-sm text-slate">{% trans "Quarterly volume trends across all modules" %}</p>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
<label for="fromYear" class="text-xs font-semibold text-slate">{% trans "From" %}</label>
|
||
<input type="number" id="fromYear" name="from_year" value="{{ chart_from_year|default:'' }}"
|
||
min="2020" max="2100" step="1"
|
||
class="px-3 py-1.5 border-2 border-gray-200 rounded-xl text-sm w-24 focus:border-navy focus:ring-0"
|
||
onchange="updateYearQuarterChart()">
|
||
<label for="toYear" class="text-xs font-semibold text-slate">{% trans "To" %}</label>
|
||
<input type="number" id="toYear" name="to_year" value="{{ chart_to_year|default:'' }}"
|
||
min="2020" max="2100" step="1"
|
||
class="px-3 py-1.5 border-2 border-gray-200 rounded-xl text-sm w-24 focus:border-navy focus:ring-0"
|
||
onchange="updateYearQuarterChart()">
|
||
</div>
|
||
</div>
|
||
<div class="p-6">
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
<!-- Complaints -->
|
||
<div class="chart-card">
|
||
<div class="chart-header">
|
||
<i data-lucide="message-square-warning" class="w-5 h-5 text-red-600"></i>
|
||
<h3>{% trans "Complaints" %}</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<canvas id="complaintsByQuarterChart" style="height: 280px;"></canvas>
|
||
</div>
|
||
</div>
|
||
<!-- Inquiries -->
|
||
<div class="chart-card">
|
||
<div class="chart-header">
|
||
<i data-lucide="help-circle" class="w-5 h-5 text-amber-600"></i>
|
||
<h3>{% trans "Inquiries" %}</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<canvas id="inquiriesByQuarterChart" style="height: 280px;"></canvas>
|
||
</div>
|
||
</div>
|
||
<!-- Suggestions -->
|
||
<div class="chart-card">
|
||
<div class="chart-header">
|
||
<i data-lucide="lightbulb" class="w-5 h-5 text-purple-600"></i>
|
||
<h3>{% trans "Suggestions" %}</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<canvas id="suggestionsByQuarterChart" style="height: 280px;"></canvas>
|
||
</div>
|
||
</div>
|
||
<!-- Observations -->
|
||
<div class="chart-card">
|
||
<div class="chart-header">
|
||
<i data-lucide="eye" class="w-5 h-5 text-green-600"></i>
|
||
<h3>{% trans "Observations" %}</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<canvas id="observationsByQuarterChart" style="height: 280px;"></canvas>
|
||
</div>
|
||
</div>
|
||
<!-- Appreciations -->
|
||
<div class="chart-card">
|
||
<div class="chart-header">
|
||
<i data-lucide="heart" class="w-5 h-5 text-pink-600"></i>
|
||
<h3>{% trans "Appreciations" %}</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<canvas id="appreciationsByQuarterChart" style="height: 280px;"></canvas>
|
||
</div>
|
||
</div>
|
||
<!-- Patient Visits -->
|
||
<div class="chart-card">
|
||
<div class="chart-header">
|
||
<i data-lucide="users" class="w-5 h-5 text-teal-600"></i>
|
||
<h3>{% trans "Patient Visits" %}</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<canvas id="visitsByQuarterChart" style="height: 280px;"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Charts Section: Actions -->
|
||
<div class="section-card mb-6 animate-in delay-200">
|
||
<div class="section-header">
|
||
<div class="section-icon" style="background: linear-gradient(135deg, #fef3c7, #fde68a);">
|
||
<i data-lucide="zap" class="w-6 h-6 text-orange-600"></i>
|
||
</div>
|
||
<div>
|
||
<h2 class="text-xl font-bold text-navy">{% trans "PX Actions Analytics" %}</h2>
|
||
<p class="text-sm text-slate">{% trans "Action status and categories" %}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="p-6">
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
<!-- Action Status -->
|
||
<div class="chart-card">
|
||
<div class="chart-header">
|
||
<i data-lucide="layers" class="w-5 h-5 text-navy"></i>
|
||
<h3>{% trans "Action Status" %}</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<canvas id="actionStatusChart" class="h-64 w-full"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Action Categories -->
|
||
<div class="chart-card">
|
||
<div class="chart-header">
|
||
<i data-lucide="tag" class="w-5 h-5 text-navy"></i>
|
||
<h3>{% trans "Action Categories" %}</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<canvas id="actionCategoriesChart" class="h-64 w-full"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Charts Section: Surveys -->
|
||
<div class="section-card mb-6 animate-in delay-300">
|
||
<div class="section-header">
|
||
<div class="section-icon" style="background: linear-gradient(135deg, #f3e8ff, #e9d5ff);">
|
||
<i data-lucide="clipboard-check" class="w-6 h-6 text-purple-600"></i>
|
||
</div>
|
||
<div>
|
||
<h2 class="text-xl font-bold text-navy">{% trans "Survey Analytics" %}</h2>
|
||
<p class="text-sm text-slate">{% trans "Patient satisfaction and NPS trends" %}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="p-6">
|
||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
<!-- NPS Card -->
|
||
<div class="chart-card flex flex-col justify-center items-center">
|
||
<div class="chart-header w-full">
|
||
<i data-lucide="thumbs-up" class="w-5 h-5 text-navy"></i>
|
||
<h3>{% trans "Net Promoter Score" %}</h3>
|
||
</div>
|
||
<div class="p-6 text-center">
|
||
<div class="text-6xl font-bold text-purple-600 mb-2">{{ kpis.nps_score }}</div>
|
||
<div class="text-sm text-slate mb-4">{% trans "Industry Avg: +32" %}</div>
|
||
<div class="w-full bg-slate-200 h-2 rounded-full overflow-hidden" style="width: 200px;">
|
||
{% widthratio kpis.nps_score|add:100 2 1 as nps_width %}
|
||
<div class="bg-purple-500 h-full" style="width: {{ nps_width }}%"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Score Trend -->
|
||
<div class="chart-card lg:col-span-2">
|
||
<div class="chart-header">
|
||
<i data-lucide="trending-up" class="w-5 h-5 text-navy"></i>
|
||
<h3>{% trans "Survey Score Trend" %}</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<canvas id="surveyTrendChart" class="h-64 w-full"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Visit Analytics -->
|
||
<div class="section-card animate-in delay-350">
|
||
<div class="section-header">
|
||
<div class="section-icon" style="background: linear-gradient(135deg, #d1fae5, #a7f3d0);">
|
||
<i data-lucide="route" class="w-6 h-6 text-emerald-600"></i>
|
||
</div>
|
||
<div>
|
||
<h2 class="text-xl font-bold text-navy">{% trans "Visit Analytics" %}</h2>
|
||
<p class="text-sm text-slate">{% trans "Patient visit metrics by type" %}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- KPI Cards -->
|
||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 p-6">
|
||
<div class="bg-slate-50 rounded-xl p-4 border border-slate-200">
|
||
<p class="text-xs font-bold text-slate uppercase tracking-wider">{% trans "Total Visits" %}</p>
|
||
<p class="text-2xl font-bold text-navy mt-1">{{ total_visits }}</p>
|
||
<p class="text-[10px] text-slate mt-1">{% trans "All visit types" %}</p>
|
||
</div>
|
||
<div class="bg-amber-50 rounded-xl p-4 border border-amber-200">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<p class="text-xs font-bold text-amber-600 uppercase tracking-wider">{% trans "Emergency (ED)" %}</p>
|
||
<p class="text-2xl font-bold text-amber-700 mt-1">{{ visit_type_data.ED.count }}</p>
|
||
</div>
|
||
<span class="text-xs font-bold px-2 py-1 rounded-full {% if visit_type_data.ED.completion_rate >= 90 %}bg-green-100 text-green-700{% else %}bg-amber-100 text-amber-700{% endif %}">{{ visit_type_data.ED.completion_rate }}%</span>
|
||
</div>
|
||
<p class="text-[10px] text-amber-500 mt-1">{% trans "Avg Duration" %}: {{ visit_type_data.ED.avg_duration }}</p>
|
||
</div>
|
||
<div class="bg-blue-50 rounded-xl p-4 border border-blue-200">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<p class="text-xs font-bold text-blue-600 uppercase tracking-wider">{% trans "Inpatient (IP)" %}</p>
|
||
<p class="text-2xl font-bold text-blue-700 mt-1">{{ visit_type_data.IP.count }}</p>
|
||
</div>
|
||
<span class="text-xs font-bold px-2 py-1 rounded-full {% if visit_type_data.IP.completion_rate >= 90 %}bg-green-100 text-green-700{% else %}bg-amber-100 text-amber-700{% endif %}">{{ visit_type_data.IP.completion_rate }}%</span>
|
||
</div>
|
||
<p class="text-[10px] text-blue-500 mt-1">{% trans "Avg Duration" %}: {{ visit_type_data.IP.avg_duration }}</p>
|
||
</div>
|
||
<div class="bg-green-50 rounded-xl p-4 border border-green-200">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<p class="text-xs font-bold text-green-600 uppercase tracking-wider">{% trans "Outpatient (OP)" %}</p>
|
||
<p class="text-2xl font-bold text-green-700 mt-1">{{ visit_type_data.OP.count }}</p>
|
||
</div>
|
||
<span class="text-xs font-bold px-2 py-1 rounded-full {% if visit_type_data.OP.completion_rate >= 90 %}bg-green-100 text-green-700{% else %}bg-amber-100 text-amber-700{% endif %}">{{ visit_type_data.OP.completion_rate }}%</span>
|
||
</div>
|
||
<p class="text-[10px] text-green-500 mt-1">{% trans "Avg Duration" %}: {{ visit_type_data.OP.avg_duration }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Charts Row -->
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 px-6 pb-6">
|
||
<div class="chart-card">
|
||
<div class="chart-header">
|
||
<i data-lucide="bar-chart-3" class="w-5 h-5 text-navy"></i>
|
||
<h3>{% trans "Visit Volume by Type" %}</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<div class="relative" style="height: 280px;">
|
||
<canvas id="visitVolumeChart"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="chart-card">
|
||
<div class="chart-header">
|
||
<i data-lucide="trending-up" class="w-5 h-5 text-navy"></i>
|
||
<h3>{% trans "Monthly Visit Trend" %}</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<div class="relative" style="height: 280px;">
|
||
<canvas id="visitTrendChart"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Stage Duration Breakdown -->
|
||
<div class="px-6 pb-6">
|
||
<div class="chart-card">
|
||
<div class="chart-header">
|
||
<i data-lucide="layers" class="w-5 h-5 text-navy"></i>
|
||
<h3>{% trans "Avg Time per Journey Stage" %}</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<div class="relative" style="height: 320px;">
|
||
<canvas id="visitStageChart"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Department Performance Table -->
|
||
<div class="section-card animate-in delay-400">
|
||
<div class="section-header">
|
||
<div class="section-icon" style="background: linear-gradient(135deg, #dbeafe, #bfdbfe);">
|
||
<i data-lucide="building-2" class="w-6 h-6 text-blue-600"></i>
|
||
</div>
|
||
<div>
|
||
<h2 class="text-xl font-bold text-navy">{% trans "Department Performance" %}</h2>
|
||
<p class="text-sm text-slate">{% trans "Performance metrics by department" %}</p>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>{% trans "Department" %}</th>
|
||
<th class="text-center">{% trans "Complaints" %}</th>
|
||
<th class="text-center">{% trans "Actions" %}</th>
|
||
<th class="text-center">{% trans "Survey Avg" %}</th>
|
||
<th class="text-center">{% trans "Resolution Rate" %}</th>
|
||
<th class="text-center">{% trans "Status" %}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for dept in department_stats %}
|
||
<tr>
|
||
<td class="font-medium text-navy">{{ dept.name_ar }}</td>
|
||
<td class="text-center">{{ dept.complaints }}</td>
|
||
<td class="text-center">{{ dept.actions }}</td>
|
||
<td class="text-center">{{ dept.survey_avg }}</td>
|
||
<td class="text-center">
|
||
<div class="flex items-center justify-center gap-2">
|
||
<div class="progress-bar" style="width: 60px;">
|
||
<div class="progress-fill {% if dept.resolution_rate >= 80 %}bg-emerald-500{% elif dept.resolution_rate >= 60 %}bg-blue-500{% else %}bg-orange-500{% endif %}" style="width: {{ dept.resolution_rate }}%"></div>
|
||
</div>
|
||
<span class="text-sm">{{ dept.resolution_rate }}%</span>
|
||
</div>
|
||
</td>
|
||
<td class="text-center">
|
||
{% if dept.resolution_rate >= 80 %}
|
||
<span class="badge badge-green">{% trans "Excellent" %}</span>
|
||
{% elif dept.resolution_rate >= 60 %}
|
||
<span class="badge badge-blue">{% trans "Good" %}</span>
|
||
{% else %}
|
||
<span class="badge badge-orange">{% trans "Needs Work" %}</span>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% empty %}
|
||
<tr>
|
||
<td colspan="6" class="text-center py-8 text-slate">
|
||
{% trans "No department data available" %}
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ====== AI-POWERED ANALYTICS SECTION ====== -->
|
||
<div class="flex items-center gap-3 mb-6 mt-8 animate-in">
|
||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center">
|
||
<i data-lucide="brain" class="w-5 h-5 text-white"></i>
|
||
</div>
|
||
<div>
|
||
<h2 class="text-xl font-bold text-navy">{% trans "AI-Powered Insights" %}</h2>
|
||
<p class="text-sm text-slate">{% trans "Predictive analytics and intelligent recommendations" %}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 1. Executive Summary Card -->
|
||
{% if exec_summary %}
|
||
<div class="section-card mb-6 animate-in">
|
||
<div class="section-header">
|
||
<div class="section-icon" style="background: linear-gradient(135deg, #f3e8ff, #e9d5ff);">
|
||
<i data-lucide="file-text" class="w-6 h-6 text-purple-600"></i>
|
||
</div>
|
||
<div class="flex-1">
|
||
<h2 class="text-xl font-bold text-navy">{% trans "AI Executive Summary" %}</h2>
|
||
<p class="text-sm text-slate">{% trans "Auto-generated analysis of the past 30 days" %}</p>
|
||
</div>
|
||
<span class="badge {% if exec_summary.risk_level == 'high' %}badge-red{% elif exec_summary.risk_level == 'medium' %}badge-orange{% else %}badge-green{% endif %}">
|
||
{% if exec_summary.risk_level == 'high' %}{% trans "High Risk" %}{% elif exec_summary.risk_level == 'medium' %}{% trans "Medium Risk" %}{% else %}{% trans "Low Risk" %}{% endif %}
|
||
</span>
|
||
</div>
|
||
<div class="p-6">
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
<!-- English Summary -->
|
||
<div>
|
||
<h3 class="font-semibold text-navy mb-2 flex items-center gap-2">
|
||
<i data-lucide="globe" class="w-4 h-4"></i> {% trans "English Summary" %}
|
||
</h3>
|
||
<p class="text-slate leading-relaxed">{{ exec_summary.summary_en }}</p>
|
||
{% if exec_summary.key_findings_en %}
|
||
<ul class="mt-3 space-y-1">
|
||
{% for finding in exec_summary.key_findings_en %}
|
||
<li class="flex items-start gap-2 text-sm text-slate">
|
||
<i data-lucide="check-circle" class="w-4 h-4 text-emerald-500 mt-0.5 flex-shrink-0"></i>
|
||
<span>{{ finding }}</span>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
{% endif %}
|
||
</div>
|
||
<!-- Arabic Summary -->
|
||
<div dir="rtl">
|
||
<h3 class="font-semibold text-navy mb-2 flex items-center gap-2">
|
||
<i data-lucide="globe" class="w-4 h-4"></i> {% trans "الملخص العربي" %}
|
||
</h3>
|
||
<p class="text-slate leading-relaxed font-arabic">{{ exec_summary.summary_ar }}</p>
|
||
{% if exec_summary.key_findings_ar %}
|
||
<ul class="mt-3 space-y-1">
|
||
{% for finding in exec_summary.key_findings_ar %}
|
||
<li class="flex items-start gap-2 text-sm text-slate">
|
||
<i data-lucide="check-circle" class="w-4 h-4 text-emerald-500 mt-0.5 flex-shrink-0"></i>
|
||
<span>{{ finding }}</span>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% if exec_summary.recommendations_en %}
|
||
<div class="mt-4 pt-4 border-t border-slate-200">
|
||
<h4 class="font-semibold text-navy mb-2">{% trans "Recommended Actions" %}</h4>
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||
{% for rec in exec_summary.recommendations_en %}
|
||
<div class="flex items-start gap-2 p-3 bg-slate-50 rounded-lg">
|
||
<i data-lucide="arrow-right" class="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0"></i>
|
||
<span class="text-sm text-slate">{{ rec }}</span>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Visit Efficiency Insights -->
|
||
{% if visit_efficiency %}
|
||
<div class="section-card mb-6 animate-in">
|
||
<div class="section-header">
|
||
<div class="section-icon" style="background: linear-gradient(135deg, #d1fae5, #a7f3d0);">
|
||
<i data-lucide="route" class="w-6 h-6 text-emerald-600"></i>
|
||
</div>
|
||
<div class="flex-1">
|
||
<h2 class="text-xl font-bold text-navy">{% trans "Visit Efficiency Insights" %}</h2>
|
||
<p class="text-sm text-slate">{% trans "AI-powered patient flow analysis and bottleneck detection" %}</p>
|
||
</div>
|
||
{% if visit_efficiency.efficiency_score %}
|
||
<span class="badge {% if visit_efficiency.efficiency_score >= 80 %}badge-green{% elif visit_efficiency.efficiency_score >= 60 %}badge-orange{% else %}badge-red{% endif %}">
|
||
{% trans "Efficiency" %}: {{ visit_efficiency.efficiency_score }}/100
|
||
</span>
|
||
{% endif %}
|
||
{% if visit_efficiency.priority_type %}
|
||
<span class="badge badge-purple ml-2">
|
||
{% trans "Priority" %}: {{ visit_efficiency.priority_type }}
|
||
</span>
|
||
{% endif %}
|
||
</div>
|
||
<div class="p-6">
|
||
{% if visit_efficiency.summary_en %}
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-4">
|
||
<div>
|
||
<h3 class="font-semibold text-navy mb-2 flex items-center gap-2">
|
||
<i data-lucide="file-text" class="w-4 h-4"></i> {% trans "Operational Summary" %}
|
||
</h3>
|
||
<p class="text-slate leading-relaxed">{{ visit_efficiency.summary_en }}</p>
|
||
</div>
|
||
<div dir="rtl">
|
||
<h3 class="font-semibold text-navy mb-2 flex items-center gap-2">
|
||
<i data-lucide="file-text" class="w-4 h-4"></i> {% trans "الملخص التشغيلي" %}
|
||
</h3>
|
||
<p class="text-slate leading-relaxed font-arabic">{{ visit_efficiency.summary_ar }}</p>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if visit_efficiency.bottlenecks_en %}
|
||
<div class="mb-4">
|
||
<h4 class="font-semibold text-navy mb-3 flex items-center gap-2">
|
||
<i data-lucide="alert-triangle" class="w-4 h-4 text-amber-500"></i>
|
||
{% trans "Top Bottlenecks Identified" %}
|
||
</h4>
|
||
<div class="space-y-2">
|
||
{% for bn in visit_efficiency.bottlenecks_en %}
|
||
<div class="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||
<i data-lucide="chevron-right" class="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0"></i>
|
||
<span class="text-sm text-amber-800">{{ bn }}</span>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if visit_efficiency.recommendations_en %}
|
||
<div class="pt-4 border-t border-slate-200">
|
||
<h4 class="font-semibold text-navy mb-3 flex items-center gap-2">
|
||
<i data-lucide="zap" class="w-4 h-4 text-emerald-500"></i>
|
||
{% trans "Efficiency Recommendations" %}
|
||
</h4>
|
||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||
{% for rec in visit_efficiency.recommendations_en %}
|
||
<div class="flex items-start gap-2 p-3 bg-emerald-50 border border-emerald-200 rounded-lg">
|
||
<i data-lucide="arrow-right" class="w-4 h-4 text-emerald-600 mt-0.5 flex-shrink-0"></i>
|
||
<span class="text-sm text-emerald-800">{{ rec }}</span>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% if visit_efficiency.recommendations_ar %}
|
||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3 mt-3" dir="rtl">
|
||
{% for rec in visit_efficiency.recommendations_ar %}
|
||
<div class="flex items-start gap-2 p-3 bg-slate-50 border border-slate-200 rounded-lg">
|
||
<i data-lucide="arrow-left" class="w-4 h-4 text-slate-500 mt-0.5 flex-shrink-0"></i>
|
||
<span class="text-sm text-slate font-arabic">{{ rec }}</span>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- 2. Early Warning System -->
|
||
{% if early_warnings %}
|
||
<div class="section-card mb-6 animate-in delay-100">
|
||
<div class="section-header">
|
||
<div class="section-icon" style="background: linear-gradient(135deg, #fee2e2, #fecaca);">
|
||
<i data-lucide="alert-triangle" class="w-6 h-6 text-red-600"></i>
|
||
</div>
|
||
<div class="flex-1">
|
||
<h2 class="text-xl font-bold text-navy">{% trans "Early Warning System" %}</h2>
|
||
<p class="text-sm text-slate">{% trans "Departments showing risk signals across multiple channels" %}</p>
|
||
</div>
|
||
<span class="badge badge-red">{{ early_warnings|length }} {% trans "at risk" %}</span>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>{% trans "Department" %}</th>
|
||
<th class="text-center">{% trans "Risk Score" %}</th>
|
||
<th class="text-center">{% trans "Risk Level" %}</th>
|
||
<th class="text-center">{% trans "Active Signals" %}</th>
|
||
<th class="text-center">{% trans "Complaint Δ" %}</th>
|
||
<th class="text-center">{% trans "Survey Δ" %}</th>
|
||
<th class="text-center">{% trans "SLA Δ" %}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for dept in early_warnings %}
|
||
<tr>
|
||
<td class="font-medium text-navy">{{ dept.department_name }}</td>
|
||
<td class="text-center">
|
||
<div class="flex items-center justify-center gap-2">
|
||
<div class="progress-bar" style="width: 80px;">
|
||
<div class="progress-fill {% if dept.risk_score >= 70 %}bg-red-500{% elif dept.risk_score >= 50 %}bg-orange-500{% elif dept.risk_score >= 30 %}bg-blue-500{% else %}bg-slate-400{% endif %}" style="width: {{ dept.risk_score }}%"></div>
|
||
</div>
|
||
<span class="text-sm font-semibold">{{ dept.risk_score }}%</span>
|
||
</div>
|
||
</td>
|
||
<td class="text-center">
|
||
<span class="badge {% if dept.risk_level == 'critical' %}badge-red{% elif dept.risk_level == 'high' %}badge-orange{% elif dept.risk_level == 'medium' %}badge-blue{% else %}badge-green{% endif %}">
|
||
{{ dept.risk_level|title }}
|
||
</span>
|
||
</td>
|
||
<td class="text-center font-semibold">{{ dept.active_signals }}/5</td>
|
||
<td class="text-center {% if dept.complaint_volume_spike.change_pct > 20 %}text-red-600 font-semibold{% elif dept.complaint_volume_spike.change_pct < -10 %}text-green-600{% endif %}">
|
||
{% if dept.complaint_volume_spike.change_pct > 0 %}+{% endif %}{{ dept.complaint_volume_spike.change_pct }}%
|
||
</td>
|
||
<td class="text-center {% if dept.survey_score_decline.change_pct < -10 %}text-red-600 font-semibold{% elif dept.survey_score_decline.change_pct > 5 %}text-green-600{% endif %}">
|
||
{{ dept.survey_score_decline.change_pct }}%
|
||
</td>
|
||
<td class="text-center {% if dept.sla_breach_increase.change_pp > 10 %}text-red-600 font-semibold{% endif %}">
|
||
+{{ dept.sla_breach_increase.change_pp }}pp
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- 3. Predictive Complaint Volume -->
|
||
{% if complaint_forecast.labels %}
|
||
<div class="section-card mb-6 animate-in delay-200">
|
||
<div class="section-header">
|
||
<div class="section-icon" style="background: linear-gradient(135deg, #dbeafe, #bfdbfe);">
|
||
<i data-lucide="trending-up" class="w-6 h-6 text-blue-600"></i>
|
||
</div>
|
||
<div class="flex-1">
|
||
<h2 class="text-xl font-bold text-navy">{% trans "Predicted Complaint Volume" %}</h2>
|
||
<p class="text-sm text-slate">{% trans "30-day forecast with confidence bands" %}</p>
|
||
</div>
|
||
<div class="flex items-center gap-4">
|
||
<div class="text-center">
|
||
<p class="text-xs text-slate">{% trans "Predicted" %}</p>
|
||
<p class="text-lg font-bold text-blue-600">{{ complaint_forecast.total_predicted_30d }}</p>
|
||
</div>
|
||
<div class="text-center">
|
||
<p class="text-xs text-slate">{% trans "vs Recent" %}</p>
|
||
<p class="text-lg font-bold {% if complaint_forecast.change_pct > 10 %}text-red-600{% elif complaint_forecast.change_pct < -10 %}text-green-600{% else %}text-slate{% endif %}">
|
||
{% if complaint_forecast.change_pct > 0 %}+{% endif %}{{ complaint_forecast.change_pct }}%
|
||
</p>
|
||
</div>
|
||
<span class="badge {% if complaint_forecast.confidence_level == 'high' %}badge-green{% elif complaint_forecast.confidence_level == 'medium' %}badge-orange{% else %}badge-red{% endif %}">
|
||
{% if complaint_forecast.confidence_level == 'high' %}{% trans "High Confidence" %}{% elif complaint_forecast.confidence_level == 'medium' %}{% trans "Medium Confidence" %}{% else %}{% trans "Low Confidence" %}{% endif %}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="p-6">
|
||
<div class="chart-card">
|
||
<div class="p-4">
|
||
<canvas id="forecastChart" class="h-72 w-full"></canvas>
|
||
</div>
|
||
</div>
|
||
{% if complaint_forecast.day_of_week_pattern %}
|
||
<div class="mt-4 p-4 bg-slate-50 rounded-lg">
|
||
<h4 class="font-semibold text-navy mb-2 text-sm">{% trans "Day-of-Week Pattern Detected" %}</h4>
|
||
<div class="flex flex-wrap gap-2">
|
||
{% for day, pattern in complaint_forecast.day_of_week_pattern.items %}
|
||
<span class="px-3 py-1 rounded-full text-xs font-semibold {% if pattern == 'above_average' %}bg-red-100 text-red-700{% elif pattern == 'below_average' %}bg-green-100 text-green-700{% else %}bg-slate-200 text-slate-600{% endif %}">
|
||
{{ day }}: {{ pattern|title }}
|
||
</span>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- 4. SLA Breach Predictions -->
|
||
{% if sla_breach_predictions %}
|
||
<div class="section-card mb-6 animate-in delay-300">
|
||
<div class="section-header">
|
||
<div class="section-icon" style="background: linear-gradient(135deg, #fef3c7, #fde68a);">
|
||
<i data-lucide="clock-alert" class="w-6 h-6 text-orange-600"></i>
|
||
</div>
|
||
<div class="flex-1">
|
||
<h2 class="text-xl font-bold text-navy">{% trans "SLA Breach Risk" %}</h2>
|
||
<p class="text-sm text-slate">{% trans "Complaints at risk of breaching SLA deadline" %}</p>
|
||
</div>
|
||
<span class="badge badge-orange">{{ sla_breach_predictions|length }} {% trans "at risk" %}</span>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>{% trans "Complaint" %}</th>
|
||
<th class="text-center">{% trans "Breach Risk" %}</th>
|
||
<th class="text-center">{% trans "Severity" %}</th>
|
||
<th class="text-center">{% trans "Hours Left" %}</th>
|
||
<th class="text-center">{% trans "Department" %}</th>
|
||
<th>{% trans "Risk Factors" %}</th>
|
||
<th>{% trans "Recommendation" %}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for pred in sla_breach_predictions %}
|
||
<tr>
|
||
<td class="font-medium text-navy text-sm max-w-xs truncate" title="{{ pred.title }}">{{ pred.title }}</td>
|
||
<td class="text-center">
|
||
<div class="flex items-center justify-center gap-2">
|
||
<div class="progress-bar" style="width: 60px;">
|
||
<div class="progress-fill {% if pred.breach_probability >= 80 %}bg-red-500{% elif pred.breach_probability >= 60 %}bg-orange-500{% elif pred.breach_probability >= 40 %}bg-blue-500{% else %}bg-slate-400{% endif %}" style="width: {{ pred.breach_probability }}%"></div>
|
||
</div>
|
||
<span class="text-sm font-semibold">{{ pred.breach_probability }}%</span>
|
||
</div>
|
||
</td>
|
||
<td class="text-center">
|
||
<span class="badge {% if pred.severity == 'critical' %}badge-red{% elif pred.severity == 'high' %}badge-orange{% elif pred.severity == 'medium' %}badge-blue{% else %}badge-green{% endif %}">
|
||
{{ pred.severity|title }}
|
||
</span>
|
||
</td>
|
||
<td class="text-center {% if pred.hours_remaining < 4 %}text-red-600 font-bold{% elif pred.hours_remaining < 12 %}text-orange-600{% endif %}">
|
||
{% if pred.hours_remaining < 0 %}{% trans "EXPIRED" %}{% else %}{{ pred.hours_remaining }}h{% endif %}
|
||
</td>
|
||
<td class="text-sm">{{ pred.department|default:"—" }}</td>
|
||
<td class="text-sm max-w-xs">
|
||
<ul class="list-disc list-inside space-y-0.5 text-xs text-slate">
|
||
{% for factor in pred.risk_factors %}
|
||
<li>{{ factor }}</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</td>
|
||
<td class="text-sm font-medium text-navy">{{ pred.recommendation }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- 5. AI Action Recommendations -->
|
||
{% if action_recommendations %}
|
||
<div class="section-card mb-6 animate-in delay-400">
|
||
<div class="section-header">
|
||
<div class="section-icon" style="background: linear-gradient(135deg, #d1fae5, #a7f3d0);">
|
||
<i data-lucide="lightbulb" class="w-6 h-6 text-emerald-600"></i>
|
||
</div>
|
||
<div class="flex-1">
|
||
<h2 class="text-xl font-bold text-navy">{% trans "AI-Recommended Actions" %}</h2>
|
||
<p class="text-sm text-slate">{% trans "Systemic issues identified from complaint pattern analysis" %}</p>
|
||
</div>
|
||
{% if action_recommendations.0.source == "ai_generated" %}
|
||
<span class="badge badge-purple">{% trans "AI Generated" %}</span>
|
||
{% else %}
|
||
<span class="badge badge-blue">{% trans "Rule-Based" %}</span>
|
||
{% endif %}
|
||
</div>
|
||
<div class="p-6 space-y-4">
|
||
{% for rec in action_recommendations %}
|
||
<div class="border border-slate-200 rounded-xl p-4 hover:border-blue-300 transition">
|
||
<div class="flex items-start justify-between mb-2">
|
||
<div class="flex items-center gap-3">
|
||
<span class="badge {% if rec.priority == 'critical' %}badge-red{% elif rec.priority == 'high' %}badge-orange{% elif rec.priority == 'medium' %}badge-blue{% else %}badge-green{% endif %}">
|
||
{{ rec.priority|title }}
|
||
</span>
|
||
<span class="text-sm text-slate">{{ rec.category }}</span>
|
||
</div>
|
||
<span class="text-sm font-semibold text-slate">{{ rec.complaint_count }} {% trans "complaints" %}</span>
|
||
</div>
|
||
<h4 class="font-semibold text-navy mb-1">{{ rec.problem_summary_en }}</h4>
|
||
{% if rec.recommended_actions_en %}
|
||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 mt-3">
|
||
{% for action in rec.recommended_actions_en %}
|
||
<div class="flex items-start gap-2 p-2.5 bg-emerald-50 rounded-lg text-sm">
|
||
<i data-lucide="zap" class="w-4 h-4 text-emerald-500 mt-0.5 flex-shrink-0"></i>
|
||
<span>{{ action }}</span>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
{% if rec.expected_impact_en %}
|
||
<p class="text-xs text-slate mt-2 flex items-center gap-1">
|
||
<i data-lucide="target" class="w-3 h-3"></i>
|
||
{{ rec.expected_impact_en }}
|
||
</p>
|
||
{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
</div>
|
||
|
||
<script>
|
||
function refreshDashboard() {
|
||
const icon = document.querySelector('[data-lucide="refresh-cw"]');
|
||
icon.classList.add('animate-spin');
|
||
|
||
// Call API to trigger cache refresh
|
||
fetch('{% url "analytics:refresh_dashboard_cache" %}', {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-CSRFToken': '{{ csrf_token }}',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
showToast(data.message || 'Dashboard cache refresh triggered');
|
||
// Reload after a short delay to get fresh data
|
||
setTimeout(() => {
|
||
window.location.reload();
|
||
}, 2000);
|
||
})
|
||
.catch(err => {
|
||
console.error('Failed to trigger dashboard refresh:', err);
|
||
showToast('Failed to trigger dashboard refresh');
|
||
icon.classList.remove('animate-spin');
|
||
// Fallback: just reload the page
|
||
setTimeout(() => {
|
||
window.location.reload();
|
||
}, 500);
|
||
});
|
||
}
|
||
|
||
function refreshAiAnalytics() {
|
||
const btn = document.getElementById('refreshAiBtn');
|
||
const icon = btn.querySelector('[data-lucide="sparkles"]');
|
||
icon.classList.add('animate-spin');
|
||
btn.disabled = true;
|
||
btn.classList.add('opacity-60');
|
||
|
||
// POST to trigger refresh
|
||
fetch('{% url "analytics:refresh_ai_analytics" %}', {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-CSRFToken': '{{ csrf_token }}',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
// Show toast notification
|
||
showToast(data.message || 'AI analytics refresh triggered');
|
||
// Poll for completion
|
||
setTimeout(() => pollAiReady(), 10000);
|
||
})
|
||
.catch(err => {
|
||
console.error('Failed to trigger AI refresh:', err);
|
||
showToast('Failed to trigger AI refresh');
|
||
})
|
||
.finally(() => {
|
||
icon.classList.remove('animate-spin');
|
||
btn.disabled = false;
|
||
btn.classList.remove('opacity-60');
|
||
});
|
||
}
|
||
|
||
function pollAiReady(retries = 6) {
|
||
if (retries <= 0) return;
|
||
fetch('{% url "analytics:refresh_ai_analytics" %}')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.cached && data.cached.executive_summary) {
|
||
showToast('AI analytics updated — refresh the page to see new data', 'success');
|
||
} else {
|
||
setTimeout(() => pollAiReady(retries - 1), 10000);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function showToast(message, type = 'info') {
|
||
const toast = document.createElement('div');
|
||
toast.className = `fixed bottom-4 right-4 px-6 py-3 rounded-xl shadow-lg text-white text-sm z-50 transition-all ${type === 'success' ? 'bg-emerald-600' : 'bg-blue-600'}`;
|
||
toast.textContent = message;
|
||
document.body.appendChild(toast);
|
||
setTimeout(() => {
|
||
toast.style.opacity = '0';
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 5000);
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
lucide.createIcons();
|
||
|
||
// Initialize charts
|
||
initCharts();
|
||
});
|
||
|
||
function initCharts() {
|
||
// Check if Chart.js loaded from CDN
|
||
if (typeof Chart === 'undefined') {
|
||
console.error('Chart.js failed to load from CDN. Charts will not render.');
|
||
showChartError();
|
||
return;
|
||
}
|
||
|
||
Chart.defaults.color = '#64748b';
|
||
Chart.defaults.borderColor = '#e2e8f0';
|
||
|
||
// Helper: build labels and data from JSON passed by view
|
||
function buildChartData(jsonStr) {
|
||
try {
|
||
return JSON.parse(jsonStr || '[]');
|
||
} catch (e) {
|
||
console.error('Failed to parse chart data:', e);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 1. Complaint Status Chart (doughnut)
|
||
const statusCtx = document.getElementById('complaintStatusChart');
|
||
if (statusCtx) {
|
||
const statusData = [
|
||
{ label: 'Open', value: {{ kpis.open_complaints|default:0 }} },
|
||
{ label: 'In Progress', value: {{ kpis.in_progress_complaints|default:0 }} },
|
||
{ label: 'Resolved', value: {{ kpis.resolved_complaints|default:0 }} },
|
||
{ label: 'Closed', value: {{ kpis.closed_complaints|default:0 }} },
|
||
];
|
||
// Filter out zero values for cleaner chart
|
||
const filtered = statusData.filter(d => d.value > 0);
|
||
new Chart(statusCtx, {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels: filtered.map(d => d.label),
|
||
datasets: [{
|
||
data: filtered.map(d => d.value),
|
||
backgroundColor: ['#f59e0b', '#3b82f6', '#10b981', '#64748b'].slice(0, filtered.length),
|
||
borderColor: '#ffffff',
|
||
borderWidth: 2
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { position: 'bottom', labels: { padding: 15, usePointStyle: true } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 2. Complaint Sources Chart (pie) - dynamic from PXSource data
|
||
const sourcesCtx = document.getElementById('complaintSourcesChart');
|
||
if (sourcesCtx) {
|
||
const sourcesData = buildChartData('{{ complaint_sources|escapejs }}');
|
||
if (sourcesData.length > 0) {
|
||
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
||
new Chart(sourcesCtx, {
|
||
type: 'pie',
|
||
data: {
|
||
labels: sourcesData.map((s, i) => s.source__name_en || 'Unknown'),
|
||
datasets: [{
|
||
data: sourcesData.map(s => s.count),
|
||
backgroundColor: colors.slice(0, sourcesData.length),
|
||
borderColor: '#ffffff',
|
||
borderWidth: 2
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { position: 'bottom', labels: { padding: 10, usePointStyle: true, boxWidth: 10 } }
|
||
}
|
||
}
|
||
});
|
||
} else {
|
||
// Fallback to internal/external breakdown
|
||
new Chart(sourcesCtx, {
|
||
type: 'pie',
|
||
data: {
|
||
labels: ['Internal', 'External'],
|
||
datasets: [{
|
||
data: [{{ kpis.internal_complaints|default:0 }}, {{ kpis.external_complaints|default:0 }}],
|
||
backgroundColor: ['#3b82f6', '#10b981'],
|
||
borderColor: '#ffffff',
|
||
borderWidth: 2
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { position: 'bottom', labels: { padding: 10, usePointStyle: true, boxWidth: 10 } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 3. Severity Chart (bar)
|
||
const severityCtx = document.getElementById('severityChart');
|
||
if (severityCtx) {
|
||
new Chart(severityCtx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: ['Critical', 'High', 'Medium', 'Low'],
|
||
datasets: [{
|
||
label: 'Complaints',
|
||
data: [{{ kpis.critical_complaints|default:0 }}, {{ kpis.high_complaints|default:0 }}, {{ kpis.medium_complaints|default:0 }}, {{ kpis.low_complaints|default:0 }}],
|
||
backgroundColor: ['#ef4444', '#f97316', '#f59e0b', '#10b981'],
|
||
borderRadius: 6
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: { legend: { display: false } },
|
||
scales: {
|
||
y: { beginAtZero: true, grid: { color: '#e2e8f0' } },
|
||
x: { grid: { display: false } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 4. Action Status Chart (doughnut)
|
||
const actionStatusCtx = document.getElementById('actionStatusChart');
|
||
if (actionStatusCtx) {
|
||
const actionStatusData = [
|
||
{ label: 'Open', value: {{ kpis.open_actions|default:0 }} },
|
||
{ label: 'In Progress', value: {{ kpis.in_progress_actions|default:0 }} },
|
||
{ label: 'Pending Approval', value: {{ kpis.pending_actions|default:0 }} },
|
||
{ label: 'Closed', value: {{ kpis.closed_actions|default:0 }} },
|
||
];
|
||
const filteredActions = actionStatusData.filter(d => d.value > 0);
|
||
new Chart(actionStatusCtx, {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels: filteredActions.map(d => d.label),
|
||
datasets: [{
|
||
data: filteredActions.map(d => d.value),
|
||
backgroundColor: ['#3b82f6', '#f59e0b', '#8b5cf6', '#10b981'].slice(0, filteredActions.length),
|
||
borderColor: '#ffffff',
|
||
borderWidth: 2
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { position: 'bottom', labels: { padding: 15, usePointStyle: true } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 5. Action Categories Chart (bar) - dynamic labels
|
||
const actionCatCtx = document.getElementById('actionCategoriesChart');
|
||
if (actionCatCtx) {
|
||
const actionCatsData = buildChartData('{{ action_categories|escapejs }}');
|
||
const categoryLabels = {
|
||
'clinical_quality': 'Clinical Quality',
|
||
'patient_safety': 'Patient Safety',
|
||
'service_quality': 'Service Quality',
|
||
'staff_behavior': 'Staff Behavior',
|
||
'facility': 'Facility',
|
||
'process_improvement': 'Process',
|
||
'other': 'Other',
|
||
'training': 'Training',
|
||
'policy': 'Policy',
|
||
};
|
||
if (actionCatsData.length > 0) {
|
||
const catColors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||
new Chart(actionCatCtx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: actionCatsData.map(c => categoryLabels[c.category] || c.category),
|
||
datasets: [{
|
||
label: 'Actions',
|
||
data: actionCatsData.map(c => c.count),
|
||
backgroundColor: catColors.slice(0, actionCatsData.length),
|
||
borderRadius: 6
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: { legend: { display: false } },
|
||
scales: {
|
||
y: { beginAtZero: true, grid: { color: '#e2e8f0' } },
|
||
x: { grid: { display: false } }
|
||
}
|
||
}
|
||
});
|
||
} else {
|
||
// Fallback to explicit category KPIs
|
||
new Chart(actionCatCtx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: ['Clinical', 'Safety', 'Service', 'Facility', 'Other'],
|
||
datasets: [{
|
||
label: 'Actions',
|
||
data: [
|
||
{{ kpis.training_actions|default:0 }},
|
||
{{ kpis.process_actions|default:0 }},
|
||
{{ kpis.policy_actions|default:0 }},
|
||
{{ kpis.facility_actions|default:0 }},
|
||
{{ kpis.other_actions|default:0 }}
|
||
],
|
||
backgroundColor: '#3b82f6',
|
||
borderRadius: 6
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: { legend: { display: false } },
|
||
scales: {
|
||
y: { beginAtZero: true, grid: { color: '#e2e8f0' } },
|
||
x: { grid: { display: false } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 6. Survey Trend Chart (line) - using actual 6-month data from view
|
||
const surveyCtx = document.getElementById('surveyTrendChart');
|
||
if (surveyCtx) {
|
||
const trendLabels = JSON.parse('{{ survey_trend_labels|safe }}');
|
||
const trendValues = [
|
||
{{ kpis.survey_trend_1|default:0 }},
|
||
{{ kpis.survey_trend_2|default:0 }},
|
||
{{ kpis.survey_trend_3|default:0 }},
|
||
{{ kpis.survey_trend_4|default:0 }},
|
||
{{ kpis.survey_trend_5|default:0 }},
|
||
{{ kpis.survey_trend_6|default:0 }}
|
||
];
|
||
const maxVal = Math.max(...trendValues.filter(v => v > 0));
|
||
const minVal = Math.min(...trendValues.filter(v => v > 0));
|
||
const padding = ((maxVal - minVal) * 0.2) || 0.5;
|
||
|
||
new Chart(surveyCtx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: trendLabels.length > 0 ? trendLabels : ['M1', 'M2', 'M3', 'M4', 'M5', 'M6'],
|
||
datasets: [{
|
||
label: 'Survey Score',
|
||
data: trendValues,
|
||
borderColor: '#8b5cf6',
|
||
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
||
fill: true,
|
||
tension: 0.4,
|
||
pointRadius: 4,
|
||
pointBackgroundColor: '#8b5cf6'
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: { legend: { display: false } },
|
||
scales: {
|
||
y: {
|
||
beginAtZero: false,
|
||
min: Math.max(0, minVal - padding),
|
||
max: Math.min(5, maxVal + padding),
|
||
grid: { color: '#e2e8f0' }
|
||
},
|
||
x: { grid: { display: false } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 7. Visit Analytics Charts
|
||
const visitVolumeCtx = document.getElementById('visitVolumeChart');
|
||
if (visitVolumeCtx) {
|
||
const vd = {{ visit_type_data_json|default:'{}'|safe }};
|
||
new Chart(visitVolumeCtx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: [
|
||
'{% trans "Emergency (ED)" %}',
|
||
'{% trans "Inpatient (IP)" %}',
|
||
'{% trans "Outpatient (OP)" %}'
|
||
],
|
||
datasets: [{
|
||
label: '{% trans "Visits" %}',
|
||
data: [vd.ED.count || 0, vd.IP.count || 0, vd.OP.count || 0],
|
||
backgroundColor: ['#f59e0b', '#3b82f6', '#10b981'],
|
||
borderRadius: 8,
|
||
barThickness: 50
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
indexAxis: 'y',
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
callbacks: {
|
||
label: function(ctx) {
|
||
const types = ['ED', 'IP', 'OP'];
|
||
const d = vd[types[ctx.dataIndex]];
|
||
return [
|
||
ctx.formattedValue + ' {% trans "visits" %}',
|
||
'{% trans "Avg Duration" %}: ' + (d.avg_duration || '-'),
|
||
'{% trans "Completion" %}: ' + (d.completion_rate || 0) + '%'
|
||
];
|
||
}
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
x: { grid: { color: '#f1f5f9' }, ticks: { font: { size: 11 } } },
|
||
y: { grid: { display: false }, ticks: { font: { size: 12, weight: 600 } } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
const visitTrendCtx = document.getElementById('visitTrendChart');
|
||
if (visitTrendCtx) {
|
||
const trendMonths = JSON.parse('{{ visit_trend_months|default:"[]"|escapejs }}');
|
||
const trendED = JSON.parse('{{ visit_trend_ed|default:"[]"|escapejs }}');
|
||
const trendIP = JSON.parse('{{ visit_trend_ip|default:"[]"|escapejs }}');
|
||
const trendOP = JSON.parse('{{ visit_trend_op|default:"[]"|escapejs }}');
|
||
|
||
const shortMonths = trendMonths.map(m => {
|
||
const parts = m.split('-');
|
||
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||
return monthNames[parseInt(parts[1]) - 1] + ' ' + parts[0].slice(2);
|
||
});
|
||
|
||
new Chart(visitTrendCtx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: shortMonths.length > 0 ? shortMonths : ['No data'],
|
||
datasets: [
|
||
{
|
||
label: '{% trans "Emergency (ED)" %}',
|
||
data: trendED,
|
||
borderColor: '#f59e0b',
|
||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||
fill: true,
|
||
tension: 0.4,
|
||
pointRadius: 4,
|
||
pointBackgroundColor: '#f59e0b'
|
||
},
|
||
{
|
||
label: '{% trans "Inpatient (IP)" %}',
|
||
data: trendIP,
|
||
borderColor: '#3b82f6',
|
||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||
fill: true,
|
||
tension: 0.4,
|
||
pointRadius: 4,
|
||
pointBackgroundColor: '#3b82f6'
|
||
},
|
||
{
|
||
label: '{% trans "Outpatient (OP)" %}',
|
||
data: trendOP,
|
||
borderColor: '#10b981',
|
||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||
fill: true,
|
||
tension: 0.4,
|
||
pointRadius: 4,
|
||
pointBackgroundColor: '#10b981'
|
||
}
|
||
]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { position: 'top', labels: { usePointStyle: true, pointStyle: 'circle', padding: 16, font: { size: 11 } } }
|
||
},
|
||
scales: {
|
||
y: { beginAtZero: true, grid: { color: '#e2e8f0' }, ticks: { font: { size: 11 } } },
|
||
x: { grid: { display: false }, ticks: { font: { size: 11 } } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 7b. Visit Stage Duration Breakdown (stacked bar)
|
||
const stageCtx = document.getElementById('visitStageChart');
|
||
if (stageCtx) {
|
||
const sd = {{ visit_stage_data_json|default:'{}'|safe }};
|
||
if (sd.stages && sd.stages.length > 0) {
|
||
const stageColors = {
|
||
'Registration': '#3b82f6',
|
||
'Lab': '#8b5cf6',
|
||
'Radiology': '#f59e0b',
|
||
'Pharmacy': '#10b981',
|
||
'Doctor': '#06b6d4',
|
||
'Other': '#94a3b8'
|
||
};
|
||
|
||
function _fmtMin(m) {
|
||
if (m <= 0) return '-';
|
||
if (m < 60) return Math.round(m) + 'm';
|
||
const h = Math.floor(m / 60);
|
||
const mins = Math.round(m % 60);
|
||
return h + 'h' + (mins ? ' ' + mins + 'm' : '');
|
||
}
|
||
|
||
const datasets = sd.stages.map((stage, idx) => ({
|
||
label: stage,
|
||
data: [
|
||
sd.ED ? sd.ED[idx] : 0,
|
||
sd.IP ? sd.IP[idx] : 0,
|
||
sd.OP ? sd.OP[idx] : 0
|
||
],
|
||
backgroundColor: stageColors[stage] || '#94a3b8',
|
||
borderRadius: 4,
|
||
borderSkipped: false
|
||
}));
|
||
|
||
new Chart(stageCtx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: [
|
||
'{% trans "Emergency (ED)" %}',
|
||
'{% trans "Inpatient (IP)" %}',
|
||
'{% trans "Outpatient (OP)" %}'
|
||
],
|
||
datasets: datasets
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
indexAxis: 'y',
|
||
plugins: {
|
||
legend: {
|
||
position: 'top',
|
||
labels: { usePointStyle: true, pointStyle: 'rectRounded', padding: 14, font: { size: 11 } }
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
label: function(ctx) {
|
||
return ctx.dataset.label + ': ' + _fmtMin(ctx.raw);
|
||
},
|
||
footer: function(items) {
|
||
const total = items.reduce((s, i) => s + i.raw, 0);
|
||
return '{% trans "Total" %}: ' + _fmtMin(total);
|
||
}
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
stacked: true,
|
||
grid: { color: '#f1f5f9' },
|
||
ticks: {
|
||
font: { size: 11 },
|
||
callback: function(val) { return _fmtMin(val); }
|
||
},
|
||
title: { display: true, text: '{% trans "Average Minutes" %}', font: { size: 12, weight: 600 } }
|
||
},
|
||
y: { stacked: true, grid: { display: false }, ticks: { font: { size: 12, weight: 600 } } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 8. Complaint Volume Forecast Chart (line with confidence bands)
|
||
const forecastData = {{ complaint_forecast|default:'{}'|safe }};
|
||
if (forecastData.labels && forecastData.labels.length > 0) {
|
||
const forecastCtx = document.getElementById('forecastChart');
|
||
if (forecastCtx) {
|
||
// Format dates for display
|
||
const shortLabels = forecastData.labels.map(d => {
|
||
const parts = d.split('-');
|
||
return parts[1] + '/' + parts[2];
|
||
});
|
||
|
||
new Chart(forecastCtx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: shortLabels,
|
||
datasets: [
|
||
{
|
||
label: 'Upper Bound',
|
||
data: forecastData.upper_band,
|
||
borderColor: 'transparent',
|
||
backgroundColor: 'rgba(59, 130, 246, 0.08)',
|
||
fill: false,
|
||
pointRadius: 0,
|
||
tension: 0.4
|
||
},
|
||
{
|
||
label: 'Lower Bound',
|
||
data: forecastData.lower_band,
|
||
borderColor: 'transparent',
|
||
backgroundColor: 'rgba(59, 130, 246, 0.08)',
|
||
fill: '-1',
|
||
pointRadius: 0,
|
||
tension: 0.4
|
||
},
|
||
{
|
||
label: 'Predicted',
|
||
data: forecastData.predicted,
|
||
borderColor: '#3b82f6',
|
||
backgroundColor: 'transparent',
|
||
fill: false,
|
||
pointRadius: 3,
|
||
pointBackgroundColor: '#3b82f6',
|
||
tension: 0.4,
|
||
borderWidth: 2
|
||
}
|
||
]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
position: 'bottom',
|
||
labels: {
|
||
padding: 15,
|
||
usePointStyle: true,
|
||
filter: function(item) { return item.text !== 'Upper Bound' && item.text !== 'Lower Bound'; }
|
||
}
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
afterBody: function(items) {
|
||
const idx = items[0].dataIndex;
|
||
return 'Range: ' + forecastData.lower_band[idx] + ' – ' + forecastData.upper_band[idx];
|
||
}
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
y: { beginAtZero: true, grid: { color: '#e2e8f0' } },
|
||
x: { grid: { display: false } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 9. Cross-Module Yearly Trends — Area Charts
|
||
function renderYearQuarterChart(canvasId, dataJson, label, color) {
|
||
const data = buildChartData(dataJson);
|
||
if (!data || data.length === 0) return;
|
||
const ctx = document.getElementById(canvasId);
|
||
if (!ctx) return;
|
||
|
||
const labels = data.map(d => d.year + ' Q' + d.quarter);
|
||
const values = data.map(d => d.count);
|
||
const showDatalabels = typeof ChartDataLabels !== 'undefined';
|
||
|
||
new Chart(ctx, {
|
||
type: 'line',
|
||
plugins: showDatalabels ? [ChartDataLabels] : [],
|
||
data: {
|
||
labels: labels,
|
||
datasets: [{
|
||
label: label,
|
||
data: values,
|
||
backgroundColor: color.replace(')', ', 0.12)').replace('rgb', 'rgba'),
|
||
borderColor: color,
|
||
borderWidth: 2,
|
||
fill: true,
|
||
tension: 0.3,
|
||
pointRadius: 4,
|
||
pointBackgroundColor: color,
|
||
pointBorderColor: '#fff',
|
||
pointBorderWidth: 2,
|
||
pointHoverRadius: 6
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: {
|
||
intersect: false,
|
||
mode: 'index'
|
||
},
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
callbacks: {
|
||
title: function(items) {
|
||
return items[0].label;
|
||
},
|
||
label: function(item) {
|
||
return label + ': ' + item.raw;
|
||
}
|
||
}
|
||
},
|
||
datalabels: {
|
||
anchor: 'end',
|
||
align: 'top',
|
||
offset: 4,
|
||
color: color,
|
||
font: {
|
||
weight: 'bold',
|
||
size: 10
|
||
},
|
||
formatter: function(value) {
|
||
return value;
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: label
|
||
},
|
||
grid: { color: '#e2e8f0' }
|
||
},
|
||
x: {
|
||
grid: { display: false }
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
renderYearQuarterChart('complaintsByQuarterChart', '{{ complaints_by_quarter|escapejs }}', '{% trans "Complaints" %}', 'rgb(239, 68, 68)');
|
||
renderYearQuarterChart('inquiriesByQuarterChart', '{{ inquiries_by_quarter|escapejs }}', '{% trans "Inquiries" %}', 'rgb(245, 158, 11)');
|
||
renderYearQuarterChart('suggestionsByQuarterChart', '{{ suggestions_by_quarter|escapejs }}', '{% trans "Suggestions" %}', 'rgb(168, 85, 247)');
|
||
renderYearQuarterChart('observationsByQuarterChart', '{{ observations_by_quarter|escapejs }}', '{% trans "Observations" %}', 'rgb(34, 197, 94)');
|
||
renderYearQuarterChart('appreciationsByQuarterChart', '{{ appreciations_by_quarter|escapejs }}', '{% trans "Appreciations" %}', 'rgb(236, 72, 153)');
|
||
renderYearQuarterChart('visitsByQuarterChart', '{{ visits_by_quarter|escapejs }}', '{% trans "Patient Visits" %}', 'rgb(20, 184, 166)');
|
||
}
|
||
|
||
function updateYearQuarterChart() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const fromYear = document.getElementById('fromYear').value;
|
||
const toYear = document.getElementById('toYear').value;
|
||
if (fromYear) params.set('from_year', fromYear);
|
||
if (toYear) params.set('to_year', toYear);
|
||
window.location.search = params.toString();
|
||
}
|
||
|
||
function showChartError() {
|
||
const chartContainers = document.querySelectorAll('.chart-card canvas');
|
||
chartContainers.forEach(container => {
|
||
const parent = container.parentElement;
|
||
if (parent) {
|
||
container.style.display = 'none';
|
||
const errDiv = document.createElement('div');
|
||
errDiv.className = 'text-center py-8 text-slate';
|
||
errDiv.innerHTML = '<i data-lucide="alert-triangle" class="w-8 h-8 mx-auto mb-2 text-orange-400"></i><p class="text-sm">Chart library unavailable</p>';
|
||
parent.appendChild(errDiv);
|
||
}
|
||
});
|
||
lucide.createIcons();
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js" onerror="showChartError()"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2" onerror="console.warn('Datalabels plugin failed to load')"></script>
|
||
{% endblock %}
|