update regarding the complaint flow

This commit is contained in:
ismail 2026-06-30 13:12:48 +03:00
parent ae4afbcca9
commit 7f19b3283c
170 changed files with 31362 additions and 15637 deletions

68
.env.bak_qa Normal file
View File

@ -0,0 +1,68 @@
# Django Settings
DEBUG=True
SECRET_KEY=your-secret-key-here-change-in-production
ALLOWED_HOSTS=localhost,127.0.0.1
# Database
DATABASE_URL=postgres://px360:px360@localhost:5433/px360
# Celery
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
CELERY_TASK_ALWAYS_EAGER=False
# Email Configuration
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=smtp-mail.outlook.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=px360@hh.med.sa
EMAIL_HOST_PASSWORD=A-Za-z0-9!@#$%^&*
DEFAULT_FROM_EMAIL=px360@hh.med.sa
# AI Configuration (LiteLLM with OpenRouter)
OPENROUTER_API_KEY=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
AI_MODEL=openrouter/google/gemini-2.5-flash-lite
# AI_MODEL=z-ai/glm-4.5-air:free
AI_TEMPERATURE=0.3
AI_MAX_TOKENS=500
# Notification Channels
SMS_ENABLED=False
SMS_PROVIDER=mshastra
WHATSAPP_ENABLED=False
WHATSAPP_PROVIDER=console
EMAIL_ENABLED=True
EMAIL_PROVIDER=smtp
# Admin URL (change in production)
ADMIN_URL=admin/
# Integration APIs (Stubs - Replace with actual credentials)
HIS_API_URL=https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps
# HIS_API_URL=http://localhost:8000/api/simulator/generate-visit/
HIS_RATINGS_API_URL=https://his.alhammadi.med.sa/SSRCE/API/FetchDoctorRatingMAPI
HIS_API_USERNAME=AlhhSUNZHippo
# HIS_API_PASSWORD is set in config/settings/base.py (contains special characters)
MOH_API_URL=
MOH_API_KEY=
CHI_API_URL=
CHI_API_KEY=
EMAIL_API_ENABLED=false
SMS_API_ENABLED=false
EMAIL_API_URL=http://localhost:8000/api/simulator/send-email/
SMS_API_URL=http://localhost:8000/api/simulator/send-sms/
EMAIL_API_KEY=simulator-test-keyrom
SMS_API_KEY=simulator-test-key
# Dev SMS Recipient - all SMS redirected to this number in development
DEV_SMS_RECIPIENT=+966566703794
# Session Settings - prevent logout on server reload
SESSION_EXPIRE_AT_BROWSER_CLOSE=False
# Mshastra SMS API
MSHASTRA_USERNAME=M556999091
MSHASTRA_PASSWORD=4bpem6su
MSHASTRA_SENDER_ID=M556999091

4
.gitignore vendored
View File

@ -18,6 +18,10 @@ lib/
lib64/
parts/
sdist/
# Database backups
backups/*.dump
backups/*.tar.gz
var/
wheels/
pip-wheel-metadata/

View File

@ -0,0 +1,17 @@
# Fix: Hospital select not visible on /config/users/create/
## Problem
The `HospitalFieldMixin` in `apps/core/form_mixins.py:44` sets `hospital_field.widget = forms.HiddenInput()`, hiding the hospital field. This mixin is designed for forms where hospital is auto-set from user context, but for user creation we need a visible dropdown.
## Fix
Remove `HospitalFieldMixin` from `UserCreateForm` and `UserEditForm` in `apps/accounts/forms.py`. Keep `DepartmentFieldMixin` for department filtering.
The forms already define `hospital` as a `ModelChoiceField` with a `Select` widget — removing the mixin will let it render normally.
## Files to modify
- `apps/accounts/forms.py` — Remove `HospitalFieldMixin` from both form classes, adjust `__init__` to not pop `request`/`user` kwargs (since `HospitalFieldMixin` no longer handles that)
## Verification
- Navigate to `/config/users/create/` — hospital dropdown should be visible
- PX Admin sees all active hospitals
- Hospital Admin sees only their hospital (can pre-set it in the view)

238
apps/accounts/forms.py Normal file
View File

@ -0,0 +1,238 @@
"""
Accounts forms - User create/edit for config console
"""
from django import forms
from django.contrib.auth.models import Group
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import User
from apps.organizations.models import Hospital, Department
from apps.core.form_mixins import DepartmentFieldMixin
class UserCreateForm(DepartmentFieldMixin, forms.ModelForm):
"""Form for creating a new user in the config console"""
email = forms.EmailField(
label=_("Email"),
widget=forms.EmailInput(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
"placeholder": "user@example.com",
}),
)
first_name = forms.CharField(
label=_("First Name"),
widget=forms.TextInput(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
}),
)
last_name = forms.CharField(
label=_("Last Name"),
widget=forms.TextInput(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
}),
)
phone = forms.CharField(
label=_("Phone"),
required=False,
widget=forms.TextInput(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
"placeholder": "+966501234567",
}),
)
employee_id = forms.CharField(
label=_("Employee ID"),
required=False,
widget=forms.TextInput(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
}),
)
hospital = forms.ModelChoiceField(
queryset=Hospital.objects.filter(status="active"),
label=_("Hospital"),
required=False,
widget=forms.Select(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
}),
)
department = forms.ModelChoiceField(
queryset=Department.objects.none(),
label=_("Department"),
required=False,
widget=forms.Select(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
}),
)
groups = forms.ModelMultipleChoiceField(
queryset=Group.objects.all(),
label=_("Roles"),
required=False,
widget=forms.CheckboxSelectMultiple(attrs={
"class": "role-checkbox",
}),
)
password = forms.CharField(
label=_("Password"),
required=False,
widget=forms.PasswordInput(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
"placeholder": _("Leave blank to generate random password"),
}),
)
password_confirm = forms.CharField(
label=_("Confirm Password"),
required=False,
widget=forms.PasswordInput(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
}),
)
is_active = forms.BooleanField(
label=_("Active"),
initial=True,
required=False,
widget=forms.CheckboxInput(attrs={
"class": "w-5 h-5 text-navy border-slate-300 rounded focus:ring-navy",
}),
)
class Meta:
model = User
fields = [
"email", "first_name", "last_name", "phone", "employee_id",
"hospital", "department", "groups", "is_active",
]
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
self.user = kwargs.pop("user", None) or (self.request.user if self.request else None)
super().__init__(*args, **kwargs)
self.fields["groups"].queryset = Group.objects.all().order_by("name")
if self.user and self.user.is_hospital_admin() and self.user.hospital:
self.fields["hospital"].initial = self.user.hospital
self.fields["hospital"].queryset = Hospital.objects.filter(id=self.user.hospital.id)
def clean_email(self):
email = self.cleaned_data.get("email")
if User.objects.filter(email=email).exists():
raise forms.ValidationError(_("A user with this email already exists."))
return email
def clean(self):
cleaned_data = super().clean()
password = cleaned_data.get("password")
password_confirm = cleaned_data.get("password_confirm")
if password and password_confirm and password != password_confirm:
self.add_error("password_confirm", _("Passwords do not match."))
return cleaned_data
def save(self, commit=True):
import secrets
import string
user = super().save(commit=False)
password = self.cleaned_data.get("password")
if not password:
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
password = ''.join(secrets.choice(alphabet) for _ in range(16))
self._generated_password = password
user.set_password(password)
if commit:
user.save()
selected_groups = self.cleaned_data.get("groups")
if selected_groups:
user.groups.set(selected_groups)
return user
class UserEditForm(DepartmentFieldMixin, forms.ModelForm):
"""Form for editing an existing user in the config console"""
first_name = forms.CharField(
label=_("First Name"),
widget=forms.TextInput(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
}),
)
last_name = forms.CharField(
label=_("Last Name"),
widget=forms.TextInput(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
}),
)
phone = forms.CharField(
label=_("Phone"),
required=False,
widget=forms.TextInput(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
"placeholder": "+966501234567",
}),
)
employee_id = forms.CharField(
label=_("Employee ID"),
required=False,
widget=forms.TextInput(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
}),
)
hospital = forms.ModelChoiceField(
queryset=Hospital.objects.filter(status="active"),
label=_("Hospital"),
required=False,
widget=forms.Select(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
}),
)
department = forms.ModelChoiceField(
queryset=Department.objects.none(),
label=_("Department"),
required=False,
widget=forms.Select(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
}),
)
groups = forms.ModelMultipleChoiceField(
queryset=Group.objects.all(),
label=_("Roles"),
required=False,
widget=forms.CheckboxSelectMultiple(attrs={
"class": "role-checkbox",
}),
)
is_active = forms.BooleanField(
label=_("Active"),
initial=True,
required=False,
widget=forms.CheckboxInput(attrs={
"class": "w-5 h-5 text-navy border-slate-300 rounded focus:ring-navy",
}),
)
class Meta:
model = User
fields = [
"first_name", "last_name", "phone", "employee_id",
"hospital", "department", "groups", "is_active",
]
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
self.user = kwargs.pop("user", None) or (self.request.user if self.request else None)
super().__init__(*args, **kwargs)
self.fields["groups"].queryset = Group.objects.all().order_by("name")
if self.instance and self.instance.pk:
self.fields["groups"].initial = self.instance.groups.all()
if self.instance.hospital:
self.fields["hospital"].initial = self.instance.hospital
self.fields["hospital"].queryset = Hospital.objects.filter(id=self.instance.hospital.id)
def save(self, commit=True):
user = super().save(commit=False)
if commit:
user.save()
selected_groups = self.cleaned_data.get("groups")
if selected_groups is not None:
user.groups.set(selected_groups)
return user

View File

@ -0,0 +1,220 @@
"""
Server-side PDF generation service for KPI Reports.
Uses matplotlib for chart rendering and WeasyPrint for HTML-to-PDF conversion.
No browser/JS required works in Celery tasks and behind firewalls.
"""
import base64
import io
import logging
from datetime import datetime
from django.template.loader import render_to_string
logger = logging.getLogger(__name__)
def _render_trend_chart(monthly_data, target, threshold):
"""
Render the monthly performance trend chart as a base64-encoded PNG.
Args:
monthly_data: list of 12 KPIReportMonthlyData instances (or None)
target: target percentage (float)
threshold: threshold percentage (float)
Returns:
base64-encoded PNG string, or empty string on failure
"""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
logger.warning("matplotlib not available — skipping trend chart")
return ""
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
values = []
for m in monthly_data:
if m and m.percentage is not None:
values.append(float(m.percentage))
else:
values.append(None)
fig, ax = plt.subplots(figsize=(14, 6.5))
fig.patch.set_facecolor("white")
non_none = [(i, v) for i, v in enumerate(values) if v is not None]
if non_none:
x_indices = [i for i, _ in non_none]
y_values = [v for _, v in non_none]
ax.plot(range(12), values, color="#005696", linewidth=2, marker="o", markersize=5, zorder=3)
ax.scatter(x_indices, y_values, color="#005696", s=40, zorder=4)
below = [(i, v) for i, v in non_none if v < threshold]
if below:
bx = [i for i, _ in below]
by = [v for _, v in below]
ax.scatter(bx, by, color="#dc2626", s=60, zorder=5, edgecolors="white", linewidths=1)
ax.axhline(y=target, color="#16a34a", linestyle="--", linewidth=1.2, alpha=0.7, label=f"Target ({target:.0f}%)")
ax.axhline(y=threshold, color="#dc2626", linestyle="--", linewidth=1.2, alpha=0.7, label=f"Threshold ({threshold:.0f}%)")
ax.set_ylim(0, 105)
ax.set_xticks(range(12))
ax.set_xticklabels(months, fontsize=8)
ax.set_ylabel("Percentage (%)", fontsize=9)
ax.set_title("Monthly Performance Trend", fontsize=11, fontweight="bold", color="#005696", pad=12)
ax.legend(loc="lower right", fontsize=7, framealpha=0.9)
ax.grid(True, axis="y", alpha=0.3, linestyle="-")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
buf = io.BytesIO()
plt.savefig(buf, format="png", dpi=200, bbox_inches="tight")
plt.close()
buf.seek(0)
return base64.b64encode(buf.getvalue()).decode("utf-8")
def _render_source_chart(source_breakdowns):
"""
Render the complaints-by-source donut chart as a base64-encoded PNG.
Args:
source_breakdowns: QuerySet of KPIReportSourceBreakdown
Returns:
base64-encoded PNG string, or empty string on failure
"""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
logger.warning("matplotlib not available — skipping source chart")
return ""
sources = list(source_breakdowns)
if not sources:
return ""
labels = [s.source_name for s in sources]
sizes = [float(s.percentage) for s in sources]
counts = [s.complaint_count for s in sources]
colors = ["#005696", "#007bbd", "#3b82f6", "#f59e0b", "#10b981", "#8b5cf6", "#ef4444", "#6b7280"]
fig, ax = plt.subplots(figsize=(8, 7))
fig.patch.set_facecolor("white")
wedges, texts, autotexts = ax.pie(
sizes,
labels=None,
autopct=lambda pct: f"{pct:.0f}%" if pct > 5 else "",
colors=colors[: len(sources)],
startangle=90,
pctdistance=0.80,
wedgeprops=dict(width=0.4, edgecolor="white", linewidth=2),
)
for autotext in autotexts:
autotext.set_color("white")
autotext.set_fontsize(8)
autotext.set_fontweight("bold")
total_count = sum(counts)
ax.text(0, 0.05, f"{total_count}", ha="center", va="center", fontsize=20, fontweight="bold", color="#005696")
ax.text(0, -0.15, "Total", ha="center", va="center", fontsize=8, color="#64748b")
ax.legend(
wedges,
[f"{l} ({c})" for l, c in zip(labels, counts)],
loc="center left",
bbox_to_anchor=(1, 0.5),
fontsize=8,
frameon=False,
)
ax.set_title("Complaints by Source", fontsize=11, fontweight="bold", color="#005696", pad=12)
plt.tight_layout()
buf = io.BytesIO()
plt.savefig(buf, format="png", dpi=200, bbox_inches="tight")
plt.close()
buf.seek(0)
return base64.b64encode(buf.getvalue()).decode("utf-8")
def _get_logo_base64():
"""
Load the hospital logo, thumbnail it, and return as a base64 data URI.
Returns:
base64 data URI string, or empty string on failure
"""
try:
import io
from django.conf import settings
from PIL import Image as PILImage
logo_path = settings.BASE_DIR / "static" / "img" / "hh-logo.png"
logo_img = PILImage.open(logo_path)
logo_img.thumbnail((500, 500), PILImage.LANCZOS)
buf = io.BytesIO()
logo_img.save(buf, format="PNG", optimize=True)
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("utf-8")
except Exception as e:
logger.warning(f"Could not load logo for KPI PDF: {e}")
return ""
def generate_kpi_report_pdf(report):
"""
Generate a PDF for a KPI Report using WeasyPrint.
Args:
report: KPIReport instance with related data loaded
Returns:
PDF file contents as bytes
"""
from weasyprint import HTML
monthly_data_qs = report.monthly_data.filter(month__gt=0).order_by("month")
total_data = report.monthly_data.filter(month=0).first()
monthly_data_dict = {m.month: m for m in monthly_data_qs}
monthly_data = [monthly_data_dict.get(i) for i in range(1, 13)]
source_breakdowns = report.source_breakdowns.all()
department_breakdowns = report.department_breakdowns.all()
location_breakdowns = report.location_breakdowns.all()
target = float(report.target_percentage) if report.target_percentage else 95.0
threshold = float(report.threshold_percentage) if report.threshold_percentage else 90.0
trend_chart_b64 = _render_trend_chart(monthly_data, target, threshold)
source_chart_b64 = _render_source_chart(source_breakdowns)
ai_analysis = report.ai_analysis or {}
context = {
"report": report,
"monthly_data": monthly_data,
"total_data": total_data,
"source_breakdowns": source_breakdowns,
"department_breakdowns": department_breakdowns,
"location_breakdowns": location_breakdowns,
"trend_chart": trend_chart_b64,
"source_chart": source_chart_b64,
"ai_analysis": ai_analysis,
"logo_path": _get_logo_base64(),
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
}
html_string = render_to_string("analytics/kpi_report_weasyprint.html", context)
return HTML(string=html_string).write_pdf()

View File

@ -348,11 +348,14 @@ def kpi_report_regenerate(request, report_id):
@login_required
def kpi_report_pdf(request, report_id):
"""
Generate PDF version of KPI report
Generate and download a server-side PDF for a KPI report.
Returns HTML page with print-friendly styling and
html2pdf.js for client-side PDF generation.
Uses WeasyPrint + matplotlib for high-quality, consistent PDF output
without requiring browser JavaScript or external CDNs.
"""
from .kpi_pdf_service import generate_kpi_report_pdf
from django.http import HttpResponse
user = request.user
report = get_object_or_404(KPIReport.objects.select_related("hospital", "generated_by"), id=report_id)
@ -362,55 +365,17 @@ def kpi_report_pdf(request, report_id):
messages.error(request, _("You do not have permission to view this report."))
return redirect("analytics:kpi_report_list")
# Get monthly data (1-12)
monthly_data_qs = report.monthly_data.filter(month__gt=0).order_by("month")
total_data = report.monthly_data.filter(month=0).first()
try:
pdf_bytes = generate_kpi_report_pdf(report)
except Exception as e:
logger.error(f"Failed to generate KPI PDF for report {report_id}: {e}", exc_info=True)
messages.error(request, _("Failed to generate PDF. Please try again."))
return redirect("analytics:kpi_report_detail", report_id=report_id)
# Build monthly data array ensuring 12 months
monthly_data_dict = {m.month: m for m in monthly_data_qs}
monthly_data = [monthly_data_dict.get(i) for i in range(1, 13)]
# Get source breakdowns for pie chart
source_breakdowns = report.source_breakdowns.all()
source_chart_data = {
"labels": [s.source_name for s in source_breakdowns] or ["No Data"],
"data": [float(s.percentage) for s in source_breakdowns] or [100],
}
# Get department breakdowns
department_breakdowns = report.department_breakdowns.all()
# Get location breakdowns
location_breakdowns = report.location_breakdowns.all()
# Prepare trend chart data - ensure we have 12 values
trend_data_values = []
for m in monthly_data:
if m:
trend_data_values.append(float(m.percentage))
else:
trend_data_values.append(0.0)
trend_chart_data = {
"labels": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
"data": trend_data_values,
"target": float(report.target_percentage) if report.target_percentage else 95.0,
"threshold": float(report.threshold_percentage) if report.threshold_percentage else 90.0,
}
context = {
"report": report,
"monthly_data": monthly_data,
"total_data": total_data,
"source_breakdowns": source_breakdowns,
"department_breakdowns": department_breakdowns,
"location_breakdowns": location_breakdowns,
"source_chart_data_json": json.dumps(source_chart_data),
"trend_chart_data_json": json.dumps(trend_chart_data),
"is_pdf": True,
}
return render(request, "analytics/kpi_report_pdf.html", context)
response = HttpResponse(pdf_bytes, content_type="application/pdf")
filename = f"KPI_{report.kpi_id}_{report.year}_{report.month:02d}.pdf"
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
@login_required

View File

@ -123,7 +123,7 @@ class Command(BaseCommand):
"early_warnings": early_warnings,
"recommendations": recommendations,
"dashboard_url": f"{getattr(settings, 'SITE_URL', '')}/analytics/dashboard/",
"command_center_url": f"{getattr(settings, 'SITE_URL', '')}/analytics/command-center/",
"command_center_url": f"{getattr(settings, 'SITE_URL', '')}/",
}
html_content = render_to_string("emails/px_digest_weekly.html", context)

View File

@ -128,7 +128,7 @@ def _send_digest(task, period="weekly", days=7):
"early_warnings": early_warnings,
"recommendations": recommendations,
"dashboard_url": f"{getattr(settings, 'SITE_URL', '')}/analytics/dashboard/",
"command_center_url": f"{getattr(settings, 'SITE_URL', '')}/analytics/command-center/",
"command_center_url": f"{getattr(settings, 'SITE_URL', '')}/",
}
html_content = render_to_string("emails/px_digest_weekly.html", context)

View File

@ -7,8 +7,7 @@ from datetime import datetime
from django.contrib.auth.decorators import login_required
from django.core.cache import cache
from django.core.paginator import Paginator
from django.db.models import Avg, Count, F, Q, Value
from django.db.models.functions import Concat
from django.db.models import Avg, Count, F, Q
from django.http import JsonResponse
from django.shortcuts import render
@ -16,10 +15,8 @@ from apps.complaints.models import Complaint
from apps.organizations.models import Department, Hospital
from apps.px_action_center.models import PXAction
from apps.surveys.models import SurveyInstance
from apps.physicians.models import PhysicianMonthlyRating
from .models import KPI, KPIValue
from .services import UnifiedAnalyticsService, ExportService
from .models import KPI
from .services.ai_analytics import (
ExecutiveSummaryGenerator,
EarlyWarningSystem,
@ -923,498 +920,3 @@ def kpi_list(request):
}
return render(request, "analytics/kpi_list.html", context)
@block_source_user
@login_required
def command_center(request):
"""
PX Command Center - Unified Dashboard
Comprehensive dashboard showing all PX360 metrics:
- Complaints, Surveys, Actions KPIs
- Interactive charts with ApexCharts
- Department and Physician rankings
- Export to Excel/PDF
"""
user = request.user
# Get filter parameters
filters = {
"date_range": request.GET.get("date_range", "30d"),
"hospital": request.GET.get("hospital", ""),
"department": request.GET.get("department", ""),
"kpi_category": request.GET.get("kpi_category", ""),
"custom_start": request.GET.get("custom_start", ""),
"custom_end": request.GET.get("custom_end", ""),
}
# Get hospitals for filter
hospitals = Hospital.objects.filter(status="active")
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
# Get departments for filter
departments = Department.objects.filter(status="active")
if filters.get("hospital"):
departments = departments.filter(hospital_id=filters["hospital"])
elif not user.is_px_admin() and user.hospital:
departments = departments.filter(hospital=user.hospital)
# Get initial KPIs
custom_start = None
custom_end = None
if filters["custom_start"] and filters["custom_end"]:
custom_start = datetime.strptime(filters["custom_start"], "%Y-%m-%d")
custom_end = datetime.strptime(filters["custom_end"], "%Y-%m-%d")
kpis = UnifiedAnalyticsService.get_all_kpis(
user=user,
date_range=filters["date_range"],
hospital_id=filters["hospital"] if filters["hospital"] else None,
department_id=filters["department"] if filters["department"] else None,
custom_start=custom_start,
custom_end=custom_end,
)
# Initial AI data for server-side render
from .services.ai_analytics import (
ExecutiveSummaryGenerator,
EarlyWarningSystem,
ComplaintVolumeForecaster,
SLABreachPredictor,
ActionRecommendationEngine,
)
hospital_id = filters["hospital"] if filters["hospital"] else None
department_id = filters["department"] if filters["department"] else None
if not hospital_id and (user.is_px_admin() or user.is_executive()):
tenant = getattr(request, "tenant_hospital", None)
if not tenant:
tenant = getattr(user, "hospital", None)
if tenant:
hospital_id = str(tenant.id)
# Trigger async refresh
from .tasks import generate_executive_summary_task, generate_action_recommendations_task
generate_executive_summary_task.delay(
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id, period=filters["date_range"]
)
generate_action_recommendations_task.delay(
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id
)
# Read AI analytics from cache ONLY (never block request with LLM calls)
exec_summary = cache.get(ExecutiveSummaryGenerator._cache_key(hospital_id, department_id, filters["date_range"]))
if not exec_summary:
exec_summary = {
"summary_en": "Executive summary is being computed in the background...",
"summary_ar": "جاري حساب الملخص التنفيذي في الخلفية...",
"key_findings_en": [],
"key_findings_ar": [],
"recommendations_en": [],
"recommendations_ar": [],
"risk_level": "medium",
"_data": {},
}
early_warnings = cache.get(EarlyWarningSystem._cache_key(hospital_id, 5))
if early_warnings is None:
early_warnings = []
complaint_forecast = cache.get(ComplaintVolumeForecaster._cache_key(hospital_id, 30))
if not complaint_forecast:
complaint_forecast = ComplaintVolumeForecaster._insufficient_data_response(30)
sla_breach_predictions = cache.get(SLABreachPredictor._cache_key(hospital_id, 10))
if sla_breach_predictions is None:
sla_breach_predictions = []
action_recommendations = cache.get(ActionRecommendationEngine._cache_key(hospital_id, department_id, 5))
if not action_recommendations:
action_recommendations = ActionRecommendationEngine._no_data_response()
context = {
"filters": filters,
"departments": departments,
"kpis": kpis,
"exec_summary": exec_summary,
"early_warnings": early_warnings,
"complaint_forecast": complaint_forecast,
"sla_breach_predictions": sla_breach_predictions,
"action_recommendations": action_recommendations,
}
return render(request, "analytics/command_center.html", context)
@block_source_user
@login_required
def command_center_api(request):
"""
API endpoint for Command Center data
Returns JSON data for KPIs, charts, and tables based on filters.
Used by JavaScript to dynamically update dashboard.
"""
if request.method != "GET":
return JsonResponse({"error": "Only GET requests allowed"}, status=405)
user = request.user
# Get filter parameters
date_range = request.GET.get("date_range", "30d")
hospital_id = request.GET.get("hospital")
department_id = request.GET.get("department")
kpi_category = request.GET.get("kpi_category")
custom_start_str = request.GET.get("custom_start")
custom_end_str = request.GET.get("custom_end")
# Parse custom dates
custom_start = None
custom_end = None
if custom_start_str and custom_end_str:
try:
custom_start = datetime.strptime(custom_start_str, "%Y-%m-%d")
custom_end = datetime.strptime(custom_end_str, "%Y-%m-%d")
except ValueError:
pass
# Handle hospital_id (can be integer or UUID string)
hospital_id = hospital_id if hospital_id else None
# Handle department_id (UUID string)
department_id = department_id if department_id else None
if not hospital_id and (user.is_px_admin() or user.is_executive()):
tenant = getattr(request, "tenant_hospital", None)
if not tenant:
tenant = getattr(user, "hospital", None)
if tenant:
hospital_id = str(tenant.id)
# Get KPIs
kpis = UnifiedAnalyticsService.get_all_kpis(
user=user,
date_range=date_range,
hospital_id=hospital_id,
department_id=department_id,
kpi_category=kpi_category,
custom_start=custom_start,
custom_end=custom_end,
)
# Ensure numeric KPIs are proper Python types for JSON serialization
numeric_kpis = [
"total_complaints",
"open_complaints",
"overdue_complaints",
"high_severity_complaints",
"resolved_complaints",
"reopened_complaints",
"escalated_ovr_complaints",
"total_actions",
"open_actions",
"overdue_actions",
"escalated_actions",
"resolved_actions",
"total_surveys",
"negative_surveys",
"avg_survey_score",
"negative_social_mentions",
"low_call_ratings",
"total_sentiment_analyses",
]
for key in numeric_kpis:
if key in kpis:
value = kpis[key]
if value is None:
kpis[key] = 0.0 if key == "avg_survey_score" else 0
elif isinstance(value, (int, float)):
# Already a number - ensure floats for specific fields
if key == "avg_survey_score":
kpis[key] = float(value)
else:
# Try to convert to number
try:
kpis[key] = float(value)
except (ValueError, TypeError):
kpis[key] = 0.0 if key == "avg_survey_score" else 0
# Handle nested trend data
if "complaints_trend" in kpis and isinstance(kpis["complaints_trend"], dict):
trend = kpis["complaints_trend"]
trend["current"] = int(trend.get("current", 0))
trend["previous"] = int(trend.get("previous", 0))
trend["percentage_change"] = float(trend.get("percentage_change", 0))
# Get chart data
chart_types = [
"complaints_trend",
"complaints_by_category",
"survey_satisfaction_trend",
"survey_distribution",
"department_performance",
"physician_leaderboard",
]
charts = {}
for chart_type in chart_types:
charts[chart_type] = UnifiedAnalyticsService.get_chart_data(
user=user,
chart_type=chart_type,
date_range=date_range,
hospital_id=hospital_id,
department_id=department_id,
custom_start=custom_start,
custom_end=custom_end,
)
# Get table data
tables = {}
# Overdue complaints table
complaints_qs = Complaint.objects.filter(is_overdue=True)
if hospital_id:
complaints_qs = complaints_qs.filter(hospital_id=hospital_id)
if department_id:
complaints_qs = complaints_qs.filter(department_id=department_id)
# Apply role-based filtering
if not user.is_px_admin() and user.hospital:
complaints_qs = complaints_qs.filter(hospital=user.hospital)
if user.is_department_manager() and user.department:
complaints_qs = complaints_qs.filter(department=user.department)
tables["overdue_complaints"] = list(
complaints_qs.select_related("hospital", "department", "patient", "source")
.order_by("due_at")[:20]
.values(
"id",
"title",
"severity",
"due_at",
"complaint_source_type",
hospital_name=F("hospital__name"),
department_name=F("department__name"),
patient_full_name=Concat("patient__first_name", Value(" "), "patient__last_name"),
source_name=F("source__name_en"),
assigned_to_full_name=Concat("assigned_to__first_name", Value(" "), "assigned_to__last_name"),
)
)
# Physician leaderboard table
physician_data = charts.get("physician_leaderboard", {}).get("metadata", [])
tables["physician_leaderboard"] = [
{
"physician_id": p["physician_id"],
"name": p["name"],
"specialization": p["specialization"],
"department": p["department"],
"rating": float(p["rating"]) if p["rating"] is not None else 0.0,
"surveys": int(p["surveys"]) if p["surveys"] is not None else 0,
"positive": int(p["positive"]) if p["positive"] is not None else 0,
"neutral": int(p["neutral"]) if p["neutral"] is not None else 0,
"negative": int(p["negative"]) if p["negative"] is not None else 0,
}
for p in physician_data
]
# ============ AI-POWERED ANALYTICS ============
from .services.ai_analytics import (
ExecutiveSummaryGenerator,
EarlyWarningSystem,
ComplaintVolumeForecaster,
SLABreachPredictor,
ActionRecommendationEngine,
)
# Trigger async Celery tasks for background refresh
from .tasks import (
generate_executive_summary_task,
generate_action_recommendations_task,
)
generate_executive_summary_task.delay(
user_id=str(user.id),
hospital_id=hospital_id,
department_id=department_id,
period=date_range.replace("d", "") if date_range.endswith("d") else "30d",
)
generate_action_recommendations_task.delay(
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id
)
# AI features — read from cache ONLY (never block request with LLM calls)
exec_summary = cache.get(ExecutiveSummaryGenerator._cache_key(hospital_id, department_id, date_range))
if not exec_summary:
exec_summary = {
"summary_en": "Executive summary is being computed in the background...",
"summary_ar": "جاري حساب الملخص التنفيذي في الخلفية...",
"key_findings_en": [],
"key_findings_ar": [],
"recommendations_en": [],
"recommendations_ar": [],
"risk_level": "medium",
"_data": {},
}
early_warnings = cache.get(EarlyWarningSystem._cache_key(hospital_id, 5))
if early_warnings is None:
early_warnings = []
complaint_forecast = cache.get(ComplaintVolumeForecaster._cache_key(hospital_id, 30))
if not complaint_forecast:
complaint_forecast = ComplaintVolumeForecaster._insufficient_data_response(30)
sla_breach_predictions = cache.get(SLABreachPredictor._cache_key(hospital_id, 10))
if sla_breach_predictions is None:
sla_breach_predictions = []
action_recommendations = cache.get(ActionRecommendationEngine._cache_key(hospital_id, department_id, 5))
if not action_recommendations:
action_recommendations = ActionRecommendationEngine._no_data_response()
ai_data = {
"executive_summary": exec_summary,
"early_warnings": early_warnings,
"complaint_forecast": complaint_forecast,
"sla_breach_predictions": sla_breach_predictions,
"action_recommendations": action_recommendations,
}
return JsonResponse({"kpis": kpis, "charts": charts, "tables": tables, "ai": ai_data})
@block_source_user
@login_required
def export_command_center(request, export_format):
"""
Export Command Center data to Excel or PDF
Args:
export_format: 'excel' or 'pdf'
Returns:
HttpResponse with file download
"""
if export_format not in ["excel", "pdf"]:
return JsonResponse({"error": "Invalid export format"}, status=400)
user = request.user
# Get filter parameters
date_range = request.GET.get("date_range", "30d")
hospital_id = request.GET.get("hospital")
department_id = request.GET.get("department")
kpi_category = request.GET.get("kpi_category")
custom_start_str = request.GET.get("custom_start")
custom_end_str = request.GET.get("custom_end")
# Parse custom dates
custom_start = None
custom_end = None
if custom_start_str and custom_end_str:
try:
custom_start = datetime.strptime(custom_start_str, "%Y-%m-%d")
custom_end = datetime.strptime(custom_end_str, "%Y-%m-%d")
except ValueError:
pass
# Handle hospital_id and department_id (can be integer or UUID string)
hospital_id = hospital_id if hospital_id else None
department_id = department_id if department_id else None
# Get all data
kpis = UnifiedAnalyticsService.get_all_kpis(
user=user,
date_range=date_range,
hospital_id=hospital_id,
department_id=department_id,
kpi_category=kpi_category,
custom_start=custom_start,
custom_end=custom_end,
)
chart_types = [
"complaints_trend",
"complaints_by_category",
"survey_satisfaction_trend",
"survey_distribution",
"department_performance",
"physician_leaderboard",
]
charts = {}
for chart_type in chart_types:
charts[chart_type] = UnifiedAnalyticsService.get_chart_data(
user=user,
chart_type=chart_type,
date_range=date_range,
hospital_id=hospital_id,
department_id=department_id,
custom_start=custom_start,
custom_end=custom_end,
)
# Get table data
tables = {}
# Overdue complaints
complaints_qs = Complaint.objects.filter(is_overdue=True)
if hospital_id:
complaints_qs = complaints_qs.filter(hospital_id=hospital_id)
if department_id:
complaints_qs = complaints_qs.filter(department_id=department_id)
if not user.is_px_admin() and user.hospital:
complaints_qs = complaints_qs.filter(hospital=user.hospital)
if user.is_department_manager() and user.department:
complaints_qs = complaints_qs.filter(department=user.department)
tables["overdue_complaints"] = {
"headers": ["ID", "Title", "Patient", "Severity", "Hospital", "Department", "Due Date"],
"rows": list(
complaints_qs.select_related("hospital", "department", "patient")
.order_by("due_at")[:100]
.annotate(
patient_full_name=Concat("patient__first_name", Value(" "), "patient__last_name"),
hospital_name=F("hospital__name"),
department_name=F("department__name"),
)
.values_list("id", "title", "patient_full_name", "severity", "hospital_name", "department_name", "due_at")
),
}
# Physician leaderboard
physician_data = charts.get("physician_leaderboard", {}).get("metadata", [])
tables["physician_leaderboard"] = {
"headers": ["Name", "Specialization", "Department", "Rating", "Surveys", "Positive", "Neutral", "Negative"],
"rows": [
[
p["name"],
p["specialization"],
p["department"],
str(p["rating"]),
str(p["surveys"]),
str(p["positive"]),
str(p["neutral"]),
str(p["negative"]),
]
for p in physician_data
],
}
# Prepare export data
export_data = ExportService.prepare_dashboard_data(user=user, kpis=kpis, charts=charts, tables=tables)
# Export based on format
if export_format == "excel":
return ExportService.export_to_excel(export_data)
elif export_format == "pdf":
return ExportService.export_to_pdf(export_data)
return JsonResponse({"error": "Export failed"}, status=500)

View File

@ -1,4 +1,5 @@
from django.urls import path
from django.urls import path, reverse_lazy
from django.views.generic import RedirectView
from . import ui_views, kpi_views, ask_views
app_name = 'analytics'
@ -9,10 +10,8 @@ urlpatterns = [
path('kpis/', ui_views.kpi_list, name='kpi_list'),
path('ask-your-data/', ask_views.ask_your_data, name='ask_your_data'),
# Command Center - Unified Dashboard
path('command-center/', ui_views.command_center, name='command_center'),
path('api/command-center/', ui_views.command_center_api, name='command_center_api'),
path('api/command-center/export/<str:export_format>/', ui_views.export_command_center, name='command_center_export'),
# Command Center now lives at the dashboard app (homepage). Redirect old links.
path('command-center/', RedirectView.as_view(url=reverse_lazy('dashboard:command-center'), permanent=False), name='command_center'),
# AI Analytics API
path('api/ai-analytics/refresh/', ui_views.refresh_ai_analytics, name='refresh_ai_analytics'),

View File

@ -117,6 +117,16 @@ def appreciation_detail(request, pk):
categories = AppreciationCategory.objects.filter(is_active=True).order_by("order", "name_en")
# Users + departments for the shared "Send to Department" modal
send_to_users = User.objects.filter(is_active=True)
hospital_departments = []
if appreciation.hospital:
send_to_users = send_to_users.filter(hospital=appreciation.hospital)
hospital_departments = Department.objects.filter(
hospital=appreciation.hospital, status="active"
).order_by("name")
send_to_users = send_to_users.select_related("department").order_by("first_name", "last_name")
from django.contrib.contenttypes.models import ContentType
appreciation_ct = ContentType.objects.get_for_model(appreciation)
generic_notes = appreciation.notes.select_related("created_by").all()
@ -127,6 +137,8 @@ def appreciation_detail(request, pk):
"staff_list": staff_queryset,
"departments": departments,
"categories": categories,
"send_to_users": send_to_users,
"hospital_departments": hospital_departments,
"can_activate": appreciation.status == AppreciationStatus.DRAFT,
"can_send": appreciation.status in (AppreciationStatus.ACTIVATED, AppreciationStatus.AI_ANALYZED),
"is_recipient": False,
@ -292,6 +304,186 @@ def appreciation_send(request, pk):
return redirect("appreciation:appreciation_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def appreciation_send_to(request, pk):
"""Unified AJAX endpoint to send an appreciation to a person or department.
Mirrors the inquiry/complaint send-to flow used by the shared modal
(components/send_to_modal.html). Requires the appreciation to be activated
first; advances it to SENT on success.
"""
appreciation = get_object_or_404(Appreciation, pk=pk)
user = request.user
# Permission
if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_department_manager()
or user.is_px_management() or user.is_px_employee()
):
return JsonResponse(
{"success": False, "error": str(_("You don't have permission to send this appreciation."))},
status=403,
)
# Must be activated before it can be sent
if appreciation.status not in (AppreciationStatus.ACTIVATED, AppreciationStatus.AI_ANALYZED):
return JsonResponse(
{"success": False, "error": str(_("Activate this appreciation before sending it to a department."))},
status=400,
)
recipient_type = request.POST.get("recipient_type", "department")
note = request.POST.get("note", "").strip()
email_subject = request.POST.get("email_subject", "").strip()
email_body = request.POST.get("email_body", "").strip()
try:
from apps.notifications.services import NotificationService, get_email_header_html
if recipient_type == "person":
person_id = request.POST.get("person_id")
if not person_id:
return JsonResponse(
{"success": False, "error": str(_("Please select a person."))}, status=400
)
try:
person = User.objects.get(id=person_id)
except User.DoesNotExist:
return JsonResponse(
{"success": False, "error": str(_("User not found."))}, status=400
)
if person.email:
send_subject = email_subject or f"Appreciation Shared - {appreciation.reference_number}"
send_body = email_body or (
f"An appreciation ({appreciation.reference_number}) has been shared with you."
)
NotificationService.send_email(
email=person.email,
subject=send_subject,
message=send_body,
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Appreciation Shared With You</h2>
<p>An appreciation <strong>#{appreciation.reference_number}</strong> has been shared with you.</p>
<p><strong>Message:</strong> {appreciation.message_en or 'N/A'}</p>
{f'<p><strong>Note:</strong> {note}</p>' if note else ''}
<p><a href="https://{request.get_host()}/appreciation/detail/{appreciation.pk}/">View Appreciation</a></p>
</div>
</div>
""",
related_object=appreciation,
)
message = f"Appreciation sent to {person.get_full_name()}."
else: # department
department_id = request.POST.get("department_id")
if not department_id:
return JsonResponse(
{"success": False, "error": str(_("Please select a department."))}, status=400
)
try:
department = Department.objects.get(id=department_id, status="active")
except Department.DoesNotExist:
return JsonResponse(
{"success": False, "error": str(_("Department not found."))}, status=400
)
# Auto-target the department's champion and manager (no contact-person picker)
from apps.organizations.department_contacts import get_champion_and_manager
targets = get_champion_and_manager(department)
if not targets:
return JsonResponse(
{"success": False,
"error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned. Please assign one before sending."))},
status=400,
)
# Route to the chosen department
appreciation.department = department
appreciation.send_to_department = True
if note:
appreciation.custom_message = note
# Notify champion + manager (email + SMS)
notified = []
link = f"https://{request.get_host()}/appreciation/detail/{appreciation.pk}/"
for target in targets:
display_name = (
target["staff"].get_full_name() if target.get("staff")
else (target["user"].get_full_name() if target.get("user") else target["label"])
)
if target.get("email"):
send_subject = email_subject or f"Appreciation Sent to Department - {appreciation.reference_number}"
send_body = email_body or (
f"Appreciation #{appreciation.reference_number} has been sent to your department ({department.name})."
)
NotificationService.send_email(
email=target["email"],
subject=send_subject,
message=send_body + f"\n\n{link}",
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Appreciation Sent to Department</h2>
<p>Appreciation <strong>#{appreciation.reference_number}</strong> has been sent to your department <strong>({department.name})</strong>.</p>
<p><strong>Message:</strong> {appreciation.message_en or 'N/A'}</p>
{f'<p><strong>Note:</strong> {note}</p>' if note else ''}
<p><strong>Role:</strong> {target['label']}</p>
<p><a href="{link}">View Appreciation</a></p>
</div>
</div>
""",
related_object=appreciation,
)
if target.get("phone"):
try:
NotificationService.send_sms(
target["phone"],
f"PX360: Appreciation #{appreciation.reference_number} sent to {department.name}. {link}",
related_object=appreciation,
)
except Exception:
pass
notified.append(f"{display_name} ({target['label']})")
message = f"Appreciation sent to {department.name}{', '.join(notified)}."
# Advance to SENT (validates status is ACTIVATED/AI_ANALYZED)
appreciation.send()
StaffActivityService.log_from_request(
request,
activity_type="send",
description=f"Sent appreciation {appreciation.pk} to {recipient_type}",
content_object=appreciation,
module="appreciation",
)
AuditService.log_event(
event_type="appreciation_sent",
description=f"Appreciation {appreciation.pk} sent to {recipient_type}",
user=user,
content_object=appreciation,
metadata={"recipient_type": recipient_type},
)
return JsonResponse({"success": True, "message": message})
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Error in appreciation_send_to: {e}")
return JsonResponse(
{"success": False, "error": str(_("An error occurred while sending the appreciation."))},
status=500,
)
# ============================================================================
# ACKNOWLEDGE
# ============================================================================
@ -1086,3 +1278,13 @@ def public_appreciation_submit(request):
except Exception as e:
logger.exception("ERROR in public_appreciation_submit")
return JsonResponse({"success": False, "message": str(e)}, status=500)
@login_required
def appreciation_pdf(request, pk):
from apps.core.pdf_utils import generate_letterhead_pdf
obj = get_object_or_404(Appreciation, id=pk)
return generate_letterhead_pdf(
'appreciation/appreciation_pdf.html',
{'object': obj},
f'appreciation_{obj.reference_number}.pdf',
)

View File

@ -34,6 +34,7 @@ urlpatterns = [
path('detail/<uuid:pk>/', ui_views.appreciation_detail, name='appreciation_detail'),
path('detail/<uuid:pk>/activate/', ui_views.appreciation_activate, name='appreciation_activate'),
path('detail/<uuid:pk>/send/', ui_views.appreciation_send, name='appreciation_send'),
path('detail/<uuid:pk>/send-to/', ui_views.appreciation_send_to, name='appreciation_send_to'),
path('acknowledge/<uuid:pk>/', ui_views.appreciation_acknowledge, name='appreciation_acknowledge'),
path('leaderboard/', ui_views.leaderboard_view, name='leaderboard_view'),
path('badges/', ui_views.my_badges_view, name='my_badges_view'),
@ -62,4 +63,5 @@ urlpatterns = [
# Public submission (no auth required)
path('public/submit/', ui_views.public_appreciation_submit, name='public_appreciation_submit'),
path('detail/<uuid:pk>/pdf/', ui_views.appreciation_pdf, name='appreciation_pdf'),
]

View File

@ -0,0 +1,213 @@
"""
Backfill source (PXSource FK) for complaints that were imported without one.
Handles two import batches:
1. historical_excel_2022: metadata.source contains the Arabic source value
2. 2025_excel: metadata has no source re-reads the Excel file to extract it
Usage:
# Preview what would change
python manage.py backfill_complaint_sources --dry-run
# Execute the backfill
python manage.py backfill_complaint_sources
# Only process one batch
python manage.py backfill_complaint_sources --batch=historical_excel_2022
python manage.py backfill_complaint_sources --batch=2025_excel
"""
import logging
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.complaints.models import Complaint
from apps.complaints.management.commands.complaint_source_mapping import (
resolve_px_source,
get_complaint_source_type,
)
logger = logging.getLogger(__name__)
EXCEL_2025_PATH = "data/Complaints Report - 2025.xlsx"
# Header aliases for 2025 Excel column detection
HEADER_ALIASES_2025 = {
"complaint_num": ["رقم الشكوى"],
"source": ["جهة الشكوى"],
}
class Command(BaseCommand):
help = "Backfill PXSource for complaints imported without one."
def add_arguments(self, parser):
parser.add_argument("--dry-run", action="store_true", help="Preview without saving")
parser.add_argument(
"--batch",
choices=["historical_excel_2022", "2025_excel"],
default=None,
help="Only process a specific import batch",
)
parser.add_argument(
"--excel-file",
default=EXCEL_2025_PATH,
help=f"Path to 2025 Excel file (default: {EXCEL_2025_PATH})",
)
def handle(self, *args, **options):
self.dry_run = options["dry_run"]
self.excel_file = options["excel_file"]
batch_filter = options["batch"]
stats = {"total": 0, "updated": 0, "unmapped": 0, "no_source_value": 0, "skipped_2026": 0}
no_source_qs = Complaint.objects.filter(source__isnull=True)
if batch_filter:
no_source_qs = no_source_qs.filter(metadata__import_source=batch_filter)
self.stdout.write(f"\nFound {no_source_qs.count()} complaints with no source.")
if self.dry_run:
self.stdout.write(self.style.WARNING("DRY RUN — no changes will be saved.\n"))
# Build 2025 Excel lookup if needed
excel_lookup = {}
needs_excel = not batch_filter or batch_filter == "2025_excel"
if needs_excel:
self.stdout.write("Reading 2025 Excel for source column...")
excel_lookup = self._build_2025_excel_lookup()
self.stdout.write(f" Built lookup with {len(excel_lookup)} entries.\n")
for complaint in no_source_qs.iterator():
stats["total"] += 1
# Skip 2026 test data
if complaint.created_at.year == 2026:
stats["skipped_2026"] += 1
continue
import_source = complaint.metadata.get("import_source", "")
source_value = None
if import_source == "historical_excel_2022":
source_value = complaint.metadata.get("source")
elif import_source == "2025_excel":
complaint_num = complaint.metadata.get("complaint_num")
original_sheet = complaint.metadata.get("original_sheet", "")
lookup_key = (complaint_num, original_sheet)
source_value = excel_lookup.get(lookup_key)
else:
# Unknown import source — try metadata.source as fallback
source_value = complaint.metadata.get("source")
if not source_value:
stats["no_source_value"] += 1
continue
px_source = resolve_px_source(source_value)
if px_source is None:
stats["unmapped"] += 1
self.stdout.write(
self.style.WARNING(
f" UNMAPPED: Ref={complaint.reference_number} source_value=\"{source_value}\""
)
)
continue
if not self.dry_run:
with transaction.atomic():
complaint.source = px_source
complaint.complaint_source_type = get_complaint_source_type(px_source)
complaint.save(update_fields=["source", "complaint_source_type"])
# Store source in metadata for 2025_excel if missing
if import_source == "2025_excel" and "source" not in complaint.metadata:
complaint.metadata["source"] = source_value
complaint.save(update_fields=["metadata"])
stats["updated"] += 1
self._print_report(stats)
def _build_2025_excel_lookup(self):
"""Read all sheets from 2025 Excel and build (complaint_num, sheet) → source_value map."""
import openpyxl
try:
wb = openpyxl.load_workbook(self.excel_file, read_only=True, data_only=True)
except FileNotFoundError:
self.stdout.write(self.style.ERROR(f"Excel file not found: {self.excel_file}"))
return {}
lookup = {}
for sheet_name in wb.sheetnames:
if sheet_name == "DropDown":
continue
ws = wb[sheet_name]
col_map = self._detect_columns(ws)
if "complaint_num" not in col_map or "source" not in col_map:
self.stdout.write(self.style.WARNING(f" Sheet '{sheet_name}': could not detect columns, skipping"))
continue
complaint_col = col_map["complaint_num"]
source_col = col_map["source"]
for row in ws.iter_rows(min_row=3, values_only=True):
comp_num = row[complaint_col - 1] if complaint_col - 1 < len(row) else None
source_val = row[source_col - 1] if source_col - 1 < len(row) else None
if comp_num and source_val:
normalized_source = str(source_val).strip()
if normalized_source and not normalized_source.replace(".", "", 1).isdigit():
lookup[(comp_num, sheet_name)] = normalized_source
wb.close()
return lookup
def _detect_columns(self, ws):
"""Scan first 10 rows to find header row and map columns."""
mapping = {}
for r in range(1, 11):
row_values = {}
for c in range(1, 80):
val = ws.cell(r, c).value
if val:
if val not in row_values:
row_values[val] = c
if "رقم الشكوى" in row_values:
for field, aliases in HEADER_ALIASES_2025.items():
for alias in aliases:
if alias in row_values:
mapping[field] = row_values[alias]
break
break
return mapping
def _print_report(self, stats):
self.stdout.write(f"\n{'=' * 60}")
self.stdout.write(self.style.SUCCESS("Backfill Report"))
self.stdout.write(f"{'=' * 60}")
self.stdout.write(f"Total complaints scanned: {stats['total']}")
self.stdout.write(f"Updated: {stats['updated']}")
self.stdout.write(f"Unmapped source value: {stats['unmapped']}")
self.stdout.write(f"No source value found: {stats['no_source_value']}")
self.stdout.write(f"Skipped (2026 test data): {stats['skipped_2026']}")
if stats["updated"] > 0:
if self.dry_run:
self.stdout.write(self.style.WARNING("\nDry run complete — no changes saved."))
else:
self.stdout.write(self.style.SUCCESS(f"\nSuccessfully updated {stats['updated']} complaints."))
if stats["unmapped"] > 0:
self.stdout.write(self.style.WARNING(f"\n{stats['unmapped']} complaints had source values that could not be mapped."))

View File

@ -14,9 +14,9 @@ SOURCE_CODE_MAP = {
"المراجع": "PATIENT",
"ذوي المراجع": "FAMILY",
"مجلس الضمان الصحي": "CHI",
"شركة تأمين": "INSURANCE",
# English / code sources
"moh": "MOH",
"cchi": "CCHI",
"chi": "CHI",
"patients": "PATIENT",
"patient": "PATIENT",
@ -30,8 +30,7 @@ SOURCE_CODE_MAP = {
"social media": "SOCIAL-MEDIA",
"social-media": "SOCIAL-MEDIA",
"public form": "PUBL-FORM",
"insurance company": None,
"شركة تأمين": None,
"insurance company": "INSURANCE",
}
@ -70,13 +69,13 @@ def resolve_px_source(source_value: str) -> "PXSource | None":
return None
EXTERNAL_SOURCE_CODES = {"MOH", "CCHI", "CHI"}
EXTERNAL_SOURCE_CODES = {"MOH", "CHI", "INSURANCE"}
def get_complaint_source_type(px_source) -> str:
"""
Determine complaint_source_type based on PXSource.
Only MOH/CHI/CCHI are external; everything else is internal.
Only MOH/CHI/INSURANCE are external; everything else is internal.
"""
if px_source and px_source.code in EXTERNAL_SOURCE_CODES:
return "external"

View File

@ -0,0 +1,205 @@
"""
Deduplicate historical complaints created by repeated Excel imports.
The importers (import_historical_complaints / import_2025_complaints_basic)
build reference numbers of the form ``CMP-YYYY-MM-NNNN``. When re-run, the
existing dedup logic appended letter suffixes (-A, -B, ... -H) instead of
skipping, producing duplicate rows with byte-identical content.
A wrinkle: the same ``complaint_num`` (column 3) is occasionally reused across
genuinely distinct complaints, so a single base reference (e.g.
``CMP-2025-10-5112``) can map to several different complaints that were each
re-imported. To handle this safely, dedup is done by
**base reference + content signature**:
1. Group every complaint (base + suffixed) by its stripped base ref.
2. Within each group, cluster by (title, description, created_at).
3. In each cluster, keep ONE representative - preferring the no-suffix base
if it is in that cluster, else the lowest-suffix copy.
4. Delete the rest. ComplaintUpdate rows on the deleted copies are removed
too (they are re-import artifacts).
Usage:
# Dry run (default) - no writes
python manage.py dedup_historical_complaints
python manage.py dedup_historical_complaints --year 2025
python manage.py dedup_historical_complaints --hospital-code HH-N
# Actually delete (requires explicit --confirm)
python manage.py dedup_historical_complaints --confirm
"""
import re
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.complaints.models import Complaint, ComplaintUpdate
from apps.organizations.models import Hospital
# Anchored pattern: CMP-YYYY-MM-NNNN optionally followed by a single uppercase
# letter suffix. Base refs always end in 4 digits, so the suffixed branch is
# never matched on a legitimate base.
REF_RE = re.compile(r"^(?P<base>CMP-\d{4}-\d{2}-\d{4})(?:-(?P<suffix>[A-Z]))?$")
def _content_signature(c):
return (c.title or "", c.description or "", c.created_at)
def _rank(c):
"""Lower rank = preferred keeper. No-suffix base ranks first (0), then -A (1), -B (2)..."""
m = REF_RE.match(c.reference_number or "")
suffix = m.group("suffix") if m and m.group("suffix") else ""
return (0 if suffix == "" else ord(suffix), str(c.id))
class Command(BaseCommand):
help = "Deduplicate historical complaints that were re-imported with letter suffixes"
def add_arguments(self, parser):
parser.add_argument("--year", type=int, help="Scope to a single year (e.g. 2025)")
parser.add_argument(
"--hospital-code",
type=str,
default="HH-N",
help="Hospital code to scope (default: HH-N)",
)
parser.add_argument(
"--confirm",
action="store_true",
help="Actually perform the deletion. Without it, the command only reports.",
)
def handle(self, *args, **options):
year = options.get("year")
hospital_code = options["hospital_code"]
confirm = options["confirm"]
# Validate hospital (but don't hard-fail if it doesn't exist; just don't filter)
hospital = None
try:
hospital = Hospital.objects.get(code=hospital_code)
except Hospital.DoesNotExist:
self.stdout.write(self.style.WARNING(
f"Hospital code '{hospital_code}' not found - running across all hospitals."
))
qs = Complaint.objects.all()
if hospital:
qs = qs.filter(hospital=hospital)
if year:
qs = qs.filter(created_at__year=year)
# Pull every complaint whose reference matches the base-or-suffixed pattern,
# plus group them by their stripped base ref. We include the no-suffix base
# itself so that content-collision groups are handled correctly.
all_rows = list(
qs.only("id", "reference_number", "created_at", "title", "description")
.filter(reference_number__regex=r"^CMP-\d{4}-\d{2}-\d{4}(?:-[A-Z])?$")
)
from collections import defaultdict
by_base = defaultdict(list)
for c in all_rows:
m = REF_RE.match(c.reference_number or "")
if not m:
continue
by_base[m.group("base")].append(c)
candidate_suffixed = 0
for c in all_rows:
m = REF_RE.match(c.reference_number or "")
if m and m.group("suffix"):
candidate_suffixed += 1
self.stdout.write(self.style.SUCCESS("=" * 70))
if confirm:
self.stdout.write(self.style.SUCCESS("DEDUP - CONFIRM MODE (deletions will run)"))
else:
self.stdout.write(self.style.SUCCESS("DEDUP - DRY RUN (no writes)"))
self.stdout.write(self.style.SUCCESS("=" * 70))
scope = f"hospital={hospital_code}" + (f", year={year}" if year else ", all years")
self.stdout.write(f"Scope: {scope}")
self.stdout.write(f"Complaints matching reference pattern: {len(all_rows)}")
self.stdout.write(f" of which suffixed (-A/-B/...): {candidate_suffixed}")
self.stdout.write(f"Distinct base refs: {len(by_base)}")
self.stdout.write("")
# Content-aware clustering: within each base group, cluster by content.
# Keep one representative per cluster, delete the rest.
to_delete_ids = []
true_unique = 0
pure_dup_groups = 0 # base refs whose rows are all identical content
collision_groups = 0 # base refs with >1 distinct content
per_year_deleted = {}
collisions_detail = []
for base_ref, items in by_base.items():
clusters = defaultdict(list)
for c in items:
clusters[_content_signature(c)].append(c)
if len(clusters) == 1:
pure_dup_groups += 1
else:
collision_groups += 1
collisions_detail.append((base_ref, len(items), len(clusters)))
for _sig, members in clusters.items():
true_unique += 1
members_sorted = sorted(members, key=_rank)
for v in members_sorted[1:]:
y = v.created_at.year if v.created_at else 0
per_year_deleted[y] = per_year_deleted.get(y, 0) + 1
to_delete_ids.append(v.id)
# Count updates attached to the to-be-deleted complaints
updates_to_delete_ids = list(
ComplaintUpdate.objects.filter(
complaint_id__in=to_delete_ids
).values_list("id", flat=True)
) if to_delete_ids else []
# Report
self.stdout.write(self.style.SUCCESS("Plan summary"))
self.stdout.write("-" * 70)
self.stdout.write(f"Base refs - pure re-import dups: {pure_dup_groups}")
self.stdout.write(f"Base refs - content collisions (>1 text): {collision_groups}")
self.stdout.write(f"TRUE unique complaints to keep: {true_unique}")
self.stdout.write(f"Duplicate complaints to delete: {len(to_delete_ids)}")
self.stdout.write(f"ComplaintUpdate rows to delete: {len(updates_to_delete_ids)}")
self.stdout.write("")
self.stdout.write("Per-year complaints to delete:")
for y in sorted(per_year_deleted):
self.stdout.write(f" {y}: {per_year_deleted[y]}")
if collision_groups:
self.stdout.write("")
self.stdout.write(self.style.WARNING(
f"{collision_groups} base refs contain >1 distinct complaint "
f"(reference collision). One copy of EACH distinct text is kept."
))
for base_ref, n_rows, n_clusters in collisions_detail[:15]:
self.stdout.write(f" {base_ref}: {n_rows} rows -> {n_clusters} distinct complaints kept")
if not confirm:
self.stdout.write("")
self.stdout.write(self.style.WARNING("DRY RUN - no changes made. Re-run with --confirm to delete."))
return
# Execute
self.stdout.write("")
self.stdout.write(self.style.SUCCESS("Executing deletion in a single transaction..."))
with transaction.atomic():
deleted_updates, _ = ComplaintUpdate.objects.filter(
id__in=updates_to_delete_ids
).delete()
deleted_complaints, _ = Complaint.objects.filter(
id__in=to_delete_ids
).delete()
self.stdout.write(self.style.SUCCESS("Done."))
self.stdout.write(f"Deleted ComplaintUpdate rows: {deleted_updates}")
self.stdout.write(f"Deleted Complaint rows: {deleted_complaints}")

View File

@ -298,6 +298,7 @@ class Command(BaseCommand):
"import_source": "2025_excel",
"original_sheet": self.sheet_name,
"complaint_num": row_data.get("complaint_num"),
"source": row_data.get("source"),
},
sent_to_department=bool(dept),
sent_to_department_at=created_at if dept else None,

View File

@ -0,0 +1,25 @@
# Generated by Django 6.0.1 on 2026-06-21 17:26
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0021_inquiry_satisfaction_inquiry_satisfaction_set_at'),
('organizations', '0014_remove_department_manager_1st'),
]
operations = [
migrations.AddField(
model_name='complaint',
name='primary_dept_involved_removed',
field=models.ForeignKey(blank=True, help_text='Tracks the primary department whose involved record the user explicitly removed; ensure_involved_records() will skip auto-recreation while this matches complaint.department.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints_primary_dept_removed', to='organizations.department'),
),
migrations.AddField(
model_name='complaint',
name='primary_staff_involved_removed',
field=models.ForeignKey(blank=True, help_text='Tracks the primary staff whose involved record the user explicitly removed; ensure_involved_records() will skip auto-recreation while this matches complaint.staff.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints_primary_staff_removed', to='organizations.staff'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 6.0.1 on 2026-06-23 13:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0022_complaint_primary_dept_involved_removed_and_more'),
]
operations = [
migrations.AddField(
model_name='investigationquestion',
name='response',
field=models.ForeignKey(blank=True, help_text='When set, this question is specific to this staff response (per-staff questions).', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='complaints.investigationresponse'),
),
]

View File

@ -0,0 +1,54 @@
# Generated by Django 6.0.1 on 2026-06-25 14:57
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0023_investigation_question_response'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='InvestigationAttachment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='investigation_attachments/%Y/%m/%d/')),
('filename', models.CharField(max_length=500)),
('file_type', models.CharField(blank=True, max_length=100)),
('file_size', models.IntegerField(help_text='File size in bytes')),
('investigation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.championinvestigation')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='investigation_attachments', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Investigation Attachment',
'verbose_name_plural': 'Investigation Attachments',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='InvestigationResponseAttachment',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(upload_to='investigation_response_attachments/%Y/%m/%d/')),
('filename', models.CharField(max_length=500)),
('file_type', models.CharField(blank=True, max_length=100)),
('file_size', models.IntegerField(help_text='File size in bytes')),
('response', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.investigationresponse')),
],
options={
'verbose_name': 'Investigation Response Attachment',
'verbose_name_plural': 'Investigation Response Attachments',
'ordering': ['-created_at'],
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-06-27 21:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0024_investigation_attachments'),
]
operations = [
migrations.AddField(
model_name='investigationquestion',
name='question_type',
field=models.CharField(choices=[('text', 'Text'), ('yes_no', 'Yes/No')], default='text', max_length=10),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-27 22:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0025_investigation_question_type'),
]
operations = [
migrations.AddField(
model_name='championinvestigation',
name='otp_code',
field=models.CharField(blank=True, default='', max_length=10),
),
migrations.AddField(
model_name='championinvestigation',
name='otp_sent_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 6.0.1 on 2026-06-28 09:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0026_champion_investigation_otp'),
]
operations = [
migrations.AddField(
model_name='championinvestigation',
name='improvement_project_note',
field=models.TextField(blank=True, default=''),
),
migrations.AddField(
model_name='championinvestigation',
name='negligence_finding',
field=models.CharField(blank=True, default='', max_length=3),
),
migrations.AddField(
model_name='championinvestigation',
name='policy_issue_finding',
field=models.CharField(blank=True, default='', max_length=3),
),
migrations.AddField(
model_name='championinvestigation',
name='requires_improvement_project',
field=models.CharField(blank=True, default='', max_length=3),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-29 11:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0027_investigation_findings'),
]
operations = [
migrations.AddField(
model_name='complaint',
name='satisfaction_locked_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='complaint',
name='satisfaction_locked_by_patient',
field=models.BooleanField(db_index=True, default=False),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-06-30 08:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0028_complaint_satisfaction_lock'),
]
operations = [
migrations.AlterField(
model_name='championinvestigation',
name='status',
field=models.CharField(choices=[('questions_sent', 'Questions Sent'), ('answers_received', 'Answers Received'), ('reply_submitted', 'Reply Submitted'), ('direct_reply_in_progress', 'Direct Reply In Progress')], default='questions_sent', max_length=30),
),
]

View File

@ -272,6 +272,24 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
staff = models.ForeignKey(
"organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints"
)
primary_dept_involved_removed = models.ForeignKey(
"organizations.Department",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="complaints_primary_dept_removed",
help_text="Tracks the primary department whose involved record the user explicitly removed; "
"ensure_involved_records() will skip auto-recreation while this matches complaint.department.",
)
primary_staff_involved_removed = models.ForeignKey(
"organizations.Staff",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="complaints_primary_staff_removed",
help_text="Tracks the primary staff whose involved record the user explicitly removed; "
"ensure_involved_records() will skip auto-recreation while this matches complaint.staff.",
)
location_type = models.CharField(
max_length=20,
choices=LocationType.choices,
@ -571,6 +589,12 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
)
satisfaction_set_at = models.DateTimeField(null=True, blank=True, help_text="When satisfaction was last set")
# Patient lock — once the patient submits satisfaction via the public tracking
# page, the value becomes authoritative and can no longer be edited by anyone
# (neither patient nor PX-team).
satisfaction_locked_by_patient = models.BooleanField(default=False, db_index=True)
satisfaction_locked_at = models.DateTimeField(null=True, blank=True)
# External references
moh_reference = models.CharField(max_length=100, blank=True, help_text="Ministry of Health reference number")
moh_reference_date = models.DateField(null=True, blank=True, help_text="MOH reference date")
@ -3761,6 +3785,7 @@ class InvestigationStatus(models.TextChoices):
QUESTIONS_SENT = "questions_sent", "Questions Sent"
ANSWERS_RECEIVED = "answers_received", "Answers Received"
REPLY_SUBMITTED = "reply_submitted", "Reply Submitted"
DIRECT_REPLY_IN_PROGRESS = "direct_reply_in_progress", "Direct Reply In Progress"
class ChampionInvestigation(UUIDModel, TimeStampedModel):
@ -3791,11 +3816,17 @@ class ChampionInvestigation(UUIDModel, TimeStampedModel):
related_name="investigation",
)
status = models.CharField(
max_length=20,
max_length=30,
choices=InvestigationStatus.choices,
default=InvestigationStatus.QUESTIONS_SENT,
)
final_reply = models.TextField(blank=True)
otp_code = models.CharField(max_length=10, blank=True, default="")
otp_sent_at = models.DateTimeField(null=True, blank=True)
negligence_finding = models.CharField(max_length=3, blank=True, default="")
policy_issue_finding = models.CharField(max_length=3, blank=True, default="")
requires_improvement_project = models.CharField(max_length=3, blank=True, default="")
improvement_project_note = models.TextField(blank=True, default="")
class Meta:
verbose_name = "Champion Investigation"
@ -3816,7 +3847,20 @@ class InvestigationQuestion(UUIDModel, TimeStampedModel):
on_delete=models.CASCADE,
related_name="questions",
)
response = models.ForeignKey(
"InvestigationResponse",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="questions",
help_text="When set, this question is specific to this staff response (per-staff questions).",
)
question_text = models.TextField()
question_type = models.CharField(
max_length=10,
choices=[("text", "Text"), ("yes_no", "Yes/No")],
default="text",
)
order = models.PositiveIntegerField(default=0)
class Meta:
@ -3873,3 +3917,57 @@ class InvestigationAnswer(UUIDModel, TimeStampedModel):
def __str__(self):
return f"A: {self.answer_text[:80]}"
class InvestigationAttachment(UUIDModel, TimeStampedModel):
"""Context document attached by the champion when composing investigation questions."""
investigation = models.ForeignKey(
ChampionInvestigation,
on_delete=models.CASCADE,
related_name="attachments",
)
file = models.FileField(upload_to="investigation_attachments/%Y/%m/%d/")
filename = models.CharField(max_length=500)
file_type = models.CharField(max_length=100, blank=True)
file_size = models.IntegerField(help_text="File size in bytes")
uploaded_by = models.ForeignKey(
"accounts.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="investigation_attachments",
)
class Meta:
ordering = ["-created_at"]
verbose_name = "Investigation Attachment"
verbose_name_plural = "Investigation Attachments"
def __str__(self):
return f"{self.investigation} - {self.filename}"
class InvestigationResponseAttachment(UUIDModel, TimeStampedModel):
"""File attached by a staff member when submitting their investigation response."""
response = models.ForeignKey(
InvestigationResponse,
on_delete=models.CASCADE,
related_name="attachments",
)
file = models.FileField(upload_to="investigation_response_attachments/%Y/%m/%d/")
filename = models.CharField(max_length=500)
file_type = models.CharField(max_length=100, blank=True)
file_size = models.IntegerField(help_text="File size in bytes")
class Meta:
ordering = ["-created_at"]
verbose_name = "Investigation Response Attachment"
verbose_name_plural = "Investigation Response Attachments"
def __str__(self):
return f"{self.response} - {self.filename}"

View File

@ -980,24 +980,25 @@ This is an automated message from PX360 Complaint Management System."""
@staticmethod
def ensure_involved_records(complaint):
"""Ensure the complaint's primary department and staff exist as involved records.
"""Ensure the complaint's primary staff exists as an involved record.
Called lazily from the complaint detail view. Uses get_or_create so it's
idempotent only creates records that don't exist yet.
Note: Department involvement is NOT auto-created. The AI suggestion lives
on complaint.department itself; the user must explicitly confirm it via
the "Confirm" button in the Departments tab (confirm_ai_department_suggestion
view) or add a different department via the "Add" button. This prevents
AI guesses (often "Patient Experience" for vague complaints) from silently
becoming the primary involved department.
Skips recreation when the user has explicitly removed the primary staff
involvement (tracked via primary_staff_involved_removed). If complaint.staff
later changes to a different record, auto-recreation resumes.
"""
from apps.complaints.models import ComplaintInvolvedDepartment, ComplaintInvolvedStaff
from apps.complaints.models import ComplaintInvolvedStaff
if complaint.department_id:
ComplaintInvolvedDepartment.objects.get_or_create(
complaint=complaint,
department=complaint.department,
defaults={
"role": "primary",
"is_primary": True,
},
)
if complaint.staff_id:
if complaint.staff_id and complaint.primary_staff_involved_removed_id != complaint.staff_id:
ComplaintInvolvedStaff.objects.get_or_create(
complaint=complaint,
staff=complaint.staff,

View File

@ -1,18 +1,19 @@
"""
Complaint signals - Automatic SMS notifications on status changes
Complaint signals.
This module handles automatic SMS notifications to complainants when:
1. Complaint is created (confirmation)
2. Complaint status changes to resolved or closed
3. Auto-sync department from staff when staff is assigned
Heavy notification work (SMS/email to complainants and champions) has been moved
off these signals into Celery tasks (see ``apps.complaints.tasks``) so it never
blocks the request. The signals below now just dispatch those tasks.
The pre_save department-sync and the ComplaintUpdate logging remain inline
(they are cheap, in-process work).
"""
import logging
from django.db.models.signals import pre_save, post_save
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.contrib.sites.shortcuts import get_current_site
from .models import Complaint, ComplaintUpdate, ComplaintInvolvedDepartment
from .models import Complaint, ComplaintInvolvedDepartment, ComplaintUpdate
logger = logging.getLogger(__name__)
@ -22,11 +23,10 @@ def sync_department_from_staff(sender, instance, **kwargs):
"""
Automatically set complaint.department from staff.department when staff is assigned.
This ensures the department is always in sync with the assigned staff member,
regardless of how the complaint is saved (API, admin, forms, etc.).
Ensures the department stays in sync with the assigned staff member regardless
of how the complaint is saved (API, admin, forms, etc.).
"""
if instance.staff:
# If staff is assigned, set department from staff's department
staff_department = instance.staff.department
if staff_department and instance.department_id != staff_department.id:
instance.department = staff_department
@ -35,200 +35,66 @@ def sync_department_from_staff(sender, instance, **kwargs):
f"from staff '{instance.staff.name}'"
)
elif instance.pk:
# If staff is being removed (set to None), check if we should clear department
# Only clear if the department was originally from a staff member
# We keep the department if it was manually set
# Staff being removed (set to None); department kept as-is.
pass
@receiver(post_save, sender=Complaint)
def send_complaint_creation_sms(sender, instance, created, **kwargs):
"""
Send SMS notification when complaint is created.
Only sends for public complaints (those with contact_phone).
"""
"""Dispatch the complaint-received SMS task when a complaint is created."""
if not created:
return
# Only send SMS if phone number is provided
if not instance.contact_phone:
logger.info(f"Complaint #{instance.id} created but no phone number provided. Skipping SMS.")
return
# Send SMS notification
try:
from apps.notifications.services import NotificationService
# Get tracking URL
tracking_url = instance.get_tracking_url()
# Bilingual SMS messages
messages = {
'en': f"PX360: Your complaint #{instance.reference_number} has been received. Track: {tracking_url}",
'ar': f"PX360: تم استلام شكوتك #{instance.reference_number}. تتبع الشكوى: {tracking_url}"
}
# Default to English (can be enhanced to detect language)
sms_message = messages['en']
# Send SMS
notification_log = NotificationService.send_sms(
phone=instance.contact_phone,
message=sms_message,
related_object=instance,
metadata={
'notification_type': 'complaint_created',
'reference_number': instance.reference_number,
'tracking_url': tracking_url,
'language': 'en' # Default to English
}
)
logger.info(f"Creation SMS sent to {instance.contact_phone} for complaint #{instance.id}")
# Create complaint update to track SMS
ComplaintUpdate.objects.create(
complaint=instance,
update_type='communication',
message=f"SMS notification sent to complainant: Your complaint has been received",
metadata={
'notification_type': 'complaint_created',
'notification_log_id': str(notification_log.id) if notification_log else None
}
)
from .tasks import send_complaint_creation_sms_task
send_complaint_creation_sms_task.delay(str(instance.id))
except Exception as e:
# Log error but don't fail the complaint save
logger.error(f"Failed to send creation SMS for complaint #{instance.id}: {str(e)}")
logger.error(f"Failed to dispatch creation SMS for complaint #{instance.id}: {e}")
@receiver(post_save, sender=Complaint)
def send_complaint_status_change_sms(sender, instance, created, **kwargs):
"""
Send SMS notification when complaint status changes to resolved or closed.
Uses update_fields to detect actual status changes (not just re-saves).
"""
# Skip on creation (handled by creation signal)
"""Dispatch SMS/email when a complaint status changes to resolved or closed."""
# Skip on creation (handled by the creation signal)
if created:
return
# Check if this is a status change to resolved or closed
# Use update_fields to detect actual status changes
if not hasattr(instance, '_status_was'):
# Detect an actual status change using the in-memory previous-status set in Complaint.save()
if not hasattr(instance, "_status_was"):
return
old_status = instance._status_was
new_status = instance.status
# Only send SMS for resolved or closed status changes
if new_status not in ['resolved', 'closed']:
if new_status not in ("resolved", "closed"):
return
# Only send if status actually changed
if old_status == new_status:
return
# Only send if phone or email is provided
if not instance.contact_phone and not instance.contact_email:
logger.info(f"Complaint #{instance.id} status changed to {new_status} but no contact info. Skipping notification.")
logger.info(
f"Complaint #{instance.id} status changed to {new_status} but no contact info. Skipping notification."
)
return
# Send SMS + email notification
try:
from apps.notifications.services import NotificationService, get_email_header_html
from apps.core.utils import build_public_track_url
track_url = build_public_track_url("complaint", instance.reference_number)
status_label = "resolved" if new_status == "resolved" else "closed"
sms_message = f"PX360: Your complaint #{instance.reference_number} has been {status_label}. View response: {track_url}"
if instance.contact_phone:
notification_log = NotificationService.send_sms(
phone=instance.contact_phone,
message=sms_message,
related_object=instance,
metadata={
'notification_type': 'complaint_status_change',
'reference_number': instance.reference_number,
'old_status': old_status,
'new_status': new_status,
'language': 'en'
}
)
logger.info(f"Status change SMS sent to {instance.contact_phone} for complaint #{instance.id}: {old_status} -> {new_status}")
if instance.contact_email:
email_subject = f"PX360: Your complaint #{instance.reference_number} has been {status_label}"
email_body = (
f"Dear Valued Patient,\n\n"
f"Your complaint #{instance.reference_number} has been {status_label}.\n\n"
f"To view the full response, please visit:\n{track_url}\n\n"
f"Thank you for your feedback.\n\n"
f"Reference: {instance.reference_number}\n"
f"This is an automated message from PX 360."
)
NotificationService.send_email(
email=instance.contact_email,
subject=email_subject,
message=email_body,
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Complaint Update: {status_label.title()}</h2>
<p style="margin: 0 0 12px 0;">Dear Valued Patient,</p>
<p style="margin: 0 0 12px 0;">Your complaint <strong>#{instance.reference_number}</strong> has been <strong>{status_label}</strong>.</p>
<p style="margin: 0 0 12px 0;">To view the full response, please click the link below:</p>
<div style="text-align: center; margin: 20px 0;">
<a href="{track_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">View Response</a>
</div>
<p style="margin: 0 0 6px 0; color: #6b7280; font-size: 13px;">Reference: {instance.reference_number}</p>
</div>
</div>
""",
related_object=instance,
metadata={
'notification_type': 'complaint_status_change_email',
'reference_number': instance.reference_number,
}
)
logger.info(f"Status change email sent to {instance.contact_email} for complaint #{instance.id}")
ComplaintUpdate.objects.create(
complaint=instance,
update_type='communication',
message=f"Notification sent to complainant: Status changed to {new_status} (SMS: {bool(instance.contact_phone)}, Email: {bool(instance.contact_email)})",
metadata={
'notification_type': 'complaint_status_change',
'old_status': old_status,
'new_status': new_status,
}
)
from .tasks import send_complaint_status_change_task
send_complaint_status_change_task.delay(str(instance.id), old_status, new_status)
except Exception as e:
# Log error but don't fail the complaint save
logger.error(f"Failed to send status change SMS for complaint #{instance.id}: {str(e)}")
logger.error(f"Failed to dispatch status change notification for complaint #{instance.id}: {e}")
# Hook into ComplaintUpdate to track SMS sent manually via API
@receiver(post_save, sender=ComplaintUpdate)
def track_manual_sms(sender, instance, created, **kwargs):
"""
Track manually sent SMS notifications.
This ensures that SMS sent via API endpoints (like send_resolution_notification)
are also properly tracked.
"""
"""Track manually sent SMS notifications recorded as ComplaintUpdate rows."""
if not created:
return
# Check if this update was for a communication/notification
if instance.update_type == 'communication':
# Log tracking info
if instance.update_type == "communication":
logger.info(
f"Manual communication update created for complaint #{instance.complaint.id}: "
f"{instance.message[:50]}..."
@ -237,18 +103,16 @@ def track_manual_sms(sender, instance, created, **kwargs):
@receiver(post_save, sender=ComplaintInvolvedDepartment)
def notify_champion_on_department_assignment(sender, instance, created, **kwargs):
"""
Send email notification to department champion when a complaint is assigned to their department.
"""
"""Dispatch the champion-notification email when a complaint is sent to a department."""
if not created:
return
# Only notify when this department is actually being sent to (not just added to involvement list)
# Only notify when this department is actually being sent to (not just added)
if not instance.sent:
return
# Only notify if the department has a respondent (champion) with email
if not instance.department.champion or not instance.department.champion.user or not instance.department.champion.user.email:
# Only notify if the department champion has a user with an email
champion = instance.department.champion
champion_user = champion.user if champion else None
if not champion_user or not champion_user.email:
logger.info(
f"ComplaintInvolvedDepartment #{instance.id}: No respondent email configured for department "
f"'{instance.department.name}'. Skipping notification."
@ -256,63 +120,8 @@ def notify_champion_on_department_assignment(sender, instance, created, **kwargs
return
try:
from apps.notifications.services import NotificationService, get_email_header_html
from django.contrib.sites.models import Site
champion = instance.department.champion.user
complaint = instance.complaint
department = instance.department
# Build response URL
current_site = Site.objects.get_current()
domain = current_site.domain if current_site else "px360.tenhal.sa"
department_url = f"https://{domain}/organizations/departments/{department.pk}/"
# Send email
NotificationService.send_email(
recipient=champion.email,
subject=f"New Complaint Assigned - {complaint.reference_number}",
message=f"""A new complaint has been assigned to your department ({department.name}).
Complaint Reference: {complaint.reference_number}
Title: {complaint.title or 'No title'}
Patient: {complaint.patient_name if hasattr(complaint, 'patient_name') else 'N/A'}
Please review and respond through your department page:
{department_url}
Best regards,
PX360 Team""",
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
{get_email_header_html()}
<div style="padding: 30px;">
<h2 style="color: #005696; margin-top: 0;">New Complaint Assigned</h2>
<p>A new complaint has been assigned to your department <strong>{department.name}</strong>.</p>
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin: 15px 0;">
<p><strong>Reference:</strong> {complaint.reference_number}</p>
<p><strong>Title:</strong> {complaint.title or 'No title'}</p>
<p><strong>Patient:</strong> {complaint.patient_name if hasattr(complaint, 'patient_name') else 'N/A'}</p>
</div>
<p>Please review and respond through your department page:</p>
<a href="{department_url}" style="display: inline-block; padding: 12px 24px; background: #005696; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0;">View Department Page</a>
<p style="color: #94a3b8; font-size: 12px; margin-top: 20px;">Best regards,<br>PX360 Team</p>
</div>
</div>
""",
related_object=complaint,
metadata={
'notification_type': 'complaint_department_assigned',
'complaint_id': str(complaint.id),
'department_id': str(department.id),
'champion_email': champion.email,
}
)
logger.info(
f"Notification sent to champion {champion.email} for complaint "
f"#{complaint.reference_number} assigned to department {department.name}"
)
from .tasks import notify_champion_on_dept_assignment_task
notify_champion_on_dept_assignment_task.delay(str(instance.id))
except Exception as e:
logger.error(f"Failed to send champion notification for ComplaintInvolvedDepartment #{instance.id}: {str(e)}")
logger.error(f"Failed to dispatch champion notification for ComplaintInvolvedDepartment #{instance.id}: {e}")

View File

@ -3798,3 +3798,249 @@ def send_inquiry_dept_response_reminders():
"first_reminder_count": first_reminder_count,
"second_reminder_count": second_reminder_count,
}
# ─── Patient linking (backgrounded to avoid blocking submit on slow HIS) ───
@shared_task
def link_complaint_patient(complaint_id, national_id=None, phone=None):
"""Background patient linking for a complaint (local DB first, then HIS).
Replaces the synchronous ``find_or_link_patient`` call that used to block the
public/internal complaint submit when the HIS endpoint is slow or unreachable.
Idempotent: only fills the patient if it is still empty.
"""
from apps.complaints.models import Complaint
from apps.organizations.patient_lookup import find_or_link_patient
complaint = Complaint.objects.filter(pk=complaint_id).select_related("hospital").first()
if not complaint or complaint.patient_id:
return
patient = find_or_link_patient(national_id=national_id, phone=phone, hospital=complaint.hospital)
if patient:
Complaint.objects.filter(pk=complaint_id, patient__isnull=True).update(patient=patient)
@shared_task
def link_inquiry_patient(inquiry_id, phone=None):
"""Background patient linking for an inquiry (local DB first, then HIS). Idempotent."""
from apps.complaints.models import Inquiry
from apps.organizations.patient_lookup import find_or_link_patient
inquiry = Inquiry.objects.filter(pk=inquiry_id).select_related("hospital").first()
if not inquiry or inquiry.patient_id:
return
patient = find_or_link_patient(phone=phone, hospital=inquiry.hospital)
if patient:
Inquiry.objects.filter(pk=inquiry_id, patient__isnull=True).update(patient=patient)
# ─── Notification tasks (moved off the synchronous post_save signals) ───
@shared_task
def send_complaint_creation_sms_task(complaint_id):
"""Send the complaint-received SMS (backgrounded from send_complaint_creation_sms)."""
from apps.complaints.models import Complaint, ComplaintUpdate
from apps.notifications.services import NotificationService
instance = Complaint.objects.filter(pk=complaint_id).first()
if not instance or not instance.contact_phone:
return
try:
tracking_url = instance.get_tracking_url()
sms_message = (
f"PX360: Your complaint #{instance.reference_number} has been received. "
f"Track: {tracking_url}"
)
notification_log = NotificationService.send_sms(
phone=instance.contact_phone,
message=sms_message,
related_object=instance,
metadata={
"notification_type": "complaint_created",
"reference_number": instance.reference_number,
"tracking_url": tracking_url,
"language": "en",
},
)
logger.info(f"Creation SMS sent to {instance.contact_phone} for complaint #{instance.id}")
ComplaintUpdate.objects.create(
complaint=instance,
update_type="communication",
message="SMS notification sent to complainant: Your complaint has been received",
metadata={
"notification_type": "complaint_created",
"notification_log_id": str(notification_log.id) if notification_log else None,
},
)
except Exception as e:
logger.error(f"Failed to send creation SMS for complaint #{instance.id}: {e}")
@shared_task
def send_complaint_status_change_task(complaint_id, old_status, new_status):
"""Send SMS + email when a complaint moves to resolved/closed (off the signal)."""
from apps.complaints.models import Complaint, ComplaintUpdate
from apps.core.utils import build_public_track_url
from apps.notifications.services import NotificationService, get_email_header_html
instance = Complaint.objects.filter(pk=complaint_id).first()
if not instance:
return
if new_status not in ("resolved", "closed"):
return
if old_status == new_status:
return
if not instance.contact_phone and not instance.contact_email:
return
try:
track_url = build_public_track_url("complaint", instance.reference_number)
status_label = "resolved" if new_status == "resolved" else "closed"
sms_message = (
f"PX360: Your complaint #{instance.reference_number} has been {status_label}. "
f"View response: {track_url}"
)
if instance.contact_phone:
NotificationService.send_sms(
phone=instance.contact_phone,
message=sms_message,
related_object=instance,
metadata={
"notification_type": "complaint_status_change",
"reference_number": instance.reference_number,
"old_status": old_status,
"new_status": new_status,
"language": "en",
},
)
logger.info(f"Status change SMS sent for complaint #{instance.id}: {old_status} -> {new_status}")
if instance.contact_email:
email_subject = f"PX360: Your complaint #{instance.reference_number} has been {status_label}"
email_body = (
f"Dear Valued Patient,\n\n"
f"Your complaint #{instance.reference_number} has been {status_label}.\n\n"
f"To view the full response, please visit:\n{track_url}\n\n"
f"Thank you for your feedback.\n\n"
f"Reference: {instance.reference_number}\n"
f"This is an automated message from PX 360."
)
NotificationService.send_email(
email=instance.contact_email,
subject=email_subject,
message=email_body,
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Complaint Update: {status_label.title()}</h2>
<p style="margin: 0 0 12px 0;">Dear Valued Patient,</p>
<p style="margin: 0 0 12px 0;">Your complaint <strong>#{instance.reference_number}</strong> has been <strong>{status_label}</strong>.</p>
<p style="margin: 0 0 12px 0;">To view the full response, please click the link below:</p>
<div style="text-align: center; margin: 20px 0;">
<a href="{track_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">View Response</a>
</div>
<p style="margin: 0 0 6px 0; color: #6b7280; font-size: 13px;">Reference: {instance.reference_number}</p>
</div>
</div>
""",
related_object=instance,
metadata={
"notification_type": "complaint_status_change_email",
"reference_number": instance.reference_number,
},
)
logger.info(f"Status change email sent to {instance.contact_email} for complaint #{instance.id}")
ComplaintUpdate.objects.create(
complaint=instance,
update_type="communication",
message=(
f"Notification sent to complainant: Status changed to {new_status} "
f"(SMS: {bool(instance.contact_phone)}, Email: {bool(instance.contact_email)})"
),
metadata={
"notification_type": "complaint_status_change",
"old_status": old_status,
"new_status": new_status,
},
)
except Exception as e:
logger.error(f"Failed to send status change notification for complaint #{instance.id}: {e}")
@shared_task
def notify_champion_on_dept_assignment_task(involved_department_id):
"""Email the department champion when a complaint is sent to their department (off the signal)."""
from django.contrib.sites.models import Site
from apps.complaints.models import ComplaintInvolvedDepartment
from apps.notifications.services import NotificationService, get_email_header_html
instance = (
ComplaintInvolvedDepartment.objects.select_related("department__champion__user", "complaint")
.filter(pk=involved_department_id)
.first()
)
if not instance:
return
if not instance.sent:
return
champion = instance.department.champion
champion_user = champion.user if champion else None
if not champion_user or not champion_user.email:
return
try:
complaint = instance.complaint
department = instance.department
current_site = Site.objects.get_current()
domain = current_site.domain if current_site else "px360.tenhal.sa"
department_url = f"https://{domain}/organizations/departments/{department.pk}/"
NotificationService.send_email(
recipient=champion_user.email,
subject=f"New Complaint Assigned - {complaint.reference_number}",
message=f"""A new complaint has been assigned to your department ({department.name}).
Complaint Reference: {complaint.reference_number}
Title: {complaint.title or 'No title'}
Patient: {complaint.patient_name if hasattr(complaint, 'patient_name') else 'N/A'}
Please review and respond through your department page:
{department_url}
Best regards,
PX360 Team""",
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
{get_email_header_html()}
<div style="padding: 30px;">
<h2 style="color: #005696; margin-top: 0;">New Complaint Assigned</h2>
<p>A new complaint has been assigned to your department <strong>{department.name}</strong>.</p>
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin: 15px 0;">
<p><strong>Reference:</strong> {complaint.reference_number}</p>
<p><strong>Title:</strong> {complaint.title or 'No title'}</p>
<p><strong>Patient:</strong> {complaint.patient_name if hasattr(complaint, 'patient_name') else 'N/A'}</p>
</div>
<p>Please review and respond through your department page:</p>
<a href="{department_url}" style="display: inline-block; padding: 12px 24px; background: #005696; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0;">View Department Page</a>
<p style="color: #94a3b8; font-size: 12px; margin-top: 20px;">Best regards,<br>PX360 Team</p>
</div>
</div>
""",
related_object=complaint,
metadata={
"notification_type": "complaint_department_assigned",
"complaint_id": str(complaint.id),
"department_id": str(department.id),
"champion_email": champion_user.email,
},
)
logger.info(
f"Notification sent to champion {champion_user.email} for complaint "
f"#{complaint.reference_number} assigned to department {department.name}"
)
except Exception as e:
logger.error(f"Failed to send champion notification for ComplaintInvolvedDepartment #{instance.id}: {e}")

View File

@ -4,6 +4,8 @@ Complaints UI views - Server-rendered templates for complaints console
import logging
from .workflow_log import build_workflow_log
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
@ -461,7 +463,10 @@ def complaint_list(request):
hospitals = hospitals.filter(id=user.hospital.id)
departments = Department.objects.filter(status="active")
if not user.is_px_admin() and user.hospital:
if user.is_px_admin():
if selected_hospital:
departments = departments.filter(hospital=selected_hospital)
elif user.hospital:
departments = departments.filter(hospital=user.hospital)
# Get assignable users
@ -704,12 +709,31 @@ def complaint_detail(request, pk):
or user.is_px_employee()
),
"is_active_status": complaint.is_active_status,
"workflow_steps": {
"activated": complaint.activated_at is not None,
"sent_to_department": complaint.sent_to_department or complaint.forwarded_to_dept_at is not None,
"department_responded": (
complaint.explanations.filter(is_used=True).exists()
or complaint.involved_departments.filter(response_submitted=True).exists()
),
"resolved": complaint.status in ("resolved", "closed"),
"cancelled": complaint.status == "cancelled",
},
"can_collect_feedback": bool(
complaint.department
and complaint.is_active_status
and (
(complaint.department.champion_id and complaint.department.champion.user_id == user.id)
or complaint.department.manager_id == user.id
)
),
"ai_department_suggested": (
bool(complaint.department)
and not complaint.involved_departments.filter(department=complaint.department).exists()
),
"hospital_departments": hospital_departments,
"involved_department_form": ComplaintInvolvedDepartmentForm(complaint=complaint, user=user),
"workflow_log": build_workflow_log(complaint),
"involved_staff_form": ComplaintInvolvedStaffForm(complaint=complaint, user=user),
"explanation": explanation,
"explanations": explanations,
@ -758,7 +782,9 @@ def complaint_detail(request, pk):
context["notes"] = complaint.notes.select_related("created_by").all()
context["notes_count"] = context["notes"].count()
return render(request, "complaints/complaint_detail.html", context)
# Datastar variant for testing: /complaints/<pk>/?ds=1 renders complaint_detail_ds.html
template = "complaints/complaint_detail_ds.html" if request.GET.get("ds") else "complaints/complaint_detail.html"
return render(request, template, context)
@login_required
@ -874,6 +900,15 @@ def complaint_create(request):
complaint.save()
reference_number = complaint.reference_number
# Background patient linking (local DB first, then HIS) — avoids blocking the
# submit when HIS is slow/unreachable.
if not complaint.patient_id:
from .tasks import link_complaint_patient
link_complaint_patient.delay(
str(complaint.id), complaint.national_id, complaint.contact_phone
)
comm_req_id = request.POST.get("comm_req")
if comm_req_id:
try:
@ -935,6 +970,53 @@ def complaint_create(request):
return render(request, "complaints/complaint_form.html", context)
@login_required
@require_http_methods(["POST"])
def collect_feedback_start(request, pk):
"""Authenticated entry to 'Collect Feedback' for the dept champion or manager.
Bootstraps a ComplaintExplanation token for the acting user and redirects to
the compose page (same one the emailed token link opens), reusing the per-staff
question flow.
"""
import secrets
from .models import ComplaintExplanation
complaint = get_object_or_404(Complaint.objects.select_related("department"), pk=pk)
dept = complaint.department
user = request.user
if not dept:
messages.error(request, _("This complaint has no department assigned."))
return redirect("complaints:complaint_detail", pk=pk)
# Permission: department champion or manager only
is_champion = bool(dept.champion_id) and dept.champion.user_id == user.id
is_manager = dept.manager_id == user.id
if not (is_champion or is_manager):
messages.error(request, _("Only the department champion or manager can collect feedback."))
return redirect("complaints:complaint_detail", pk=pk)
# Resolve the Staff to attach the explanation to
staff = dept.champion if is_champion else getattr(dept.manager, "staff_profile", None) or dept.champion
if staff is None:
messages.error(request, _("No staff profile available to collect feedback."))
return redirect("complaints:complaint_detail", pk=pk)
token = secrets.token_urlsafe(32)
ComplaintExplanation.objects.update_or_create(
complaint=complaint,
staff=staff,
defaults={
"token": token,
"is_used": False,
"requested_by": user,
"submitted_via": "direct",
},
)
return redirect("complaints:champion_start_investigation", complaint_id=complaint.id, token=token)
@login_required
@require_http_methods(["POST"])
def complaint_send_to(request, pk):
@ -1028,28 +1110,17 @@ def complaint_send_to(request, pk):
"error": str(_("Department not found.")),
}, status=400)
if not department.champion and not department.manager:
# Auto-target the department's champion and manager (no contact-person picker)
from apps.organizations.department_contacts import get_champion_and_manager
import secrets
targets = get_champion_and_manager(department)
if not targets:
return JsonResponse({
"success": False,
"error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned.")),
"error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned. Please assign one before sending.")),
}, status=400)
contact_person_id = request.POST.get("contact_person_id")
if not contact_person_id:
return JsonResponse({
"success": False,
"error": str(_("Please select a contact person.")),
}, status=400)
contact_info = department.is_valid_contact_person(contact_person_id)
if not contact_info:
return JsonResponse({
"success": False,
"error": str(_("Selected person is not a role holder in this department.")),
}, status=400)
contact_person = contact_info["staff"]
now = timezone.now()
if complaint.department_id == department.pk:
@ -1082,14 +1153,64 @@ def complaint_send_to(request, pk):
complaint.explanation_requested = True
complaint.explanation_requested_at = complaint.explanation_requested_at or now
contact_email = contact_person.email or (contact_person.user.email if contact_person.user else None)
if complaint.department_id == department.pk and contact_email:
# Notify each recipient (champion + manager): token link + email + SMS
notified = []
host = request.get_host()
for target in targets:
email = target.get("email") or ""
phone = target.get("phone") or ""
staff = target.get("staff")
tuser = target.get("user")
label = target.get("label")
# Personal no-login explanation link (token) — create for any target with a Staff profile
link = None
effective_staff = staff
if effective_staff is None and tuser is not None:
effective_staff = getattr(tuser, "staff_profile", None)
if effective_staff is not None:
token = secrets.token_urlsafe(32)
exp_obj, _ = ComplaintExplanation.objects.update_or_create(
complaint=complaint,
staff=effective_staff,
defaults={
"token": token,
"is_used": False,
"requested_by": user,
"request_message": note,
"email_sent_at": timezone.now(),
"submitted_via": "email_link",
},
)
link = f"https://{host}/complaints/{complaint.id}/explain/{token}/"
# Save PX-team attachments against this explanation
from .models import ExplanationAttachment
for f in request.FILES.getlist("px_attachments"):
ExplanationAttachment.objects.create(
explanation=exp_obj,
file=f,
filename=f.name,
file_type=f.content_type,
file_size=f.size,
)
display_name = (
staff.get_full_name() if staff is not None
else (tuser.get_full_name() if tuser else label)
)
fallback_link = f"https://{host}/complaints/{complaint.pk}/"
review_link = link or fallback_link
if email:
send_subject = email_subject or f"Complaint Sent to Department - {complaint.reference_number}"
send_body = email_body or f"Complaint #{complaint.reference_number} has been sent to your department ({department.name})."
send_body = email_body or (
f"Complaint #{complaint.reference_number} has been sent to your department ({department.name})."
)
NotificationService.send_email(
email=contact_email,
email=email,
subject=send_subject,
message=send_body,
message=send_body + f"\n\n{review_link}",
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
@ -1098,15 +1219,39 @@ def complaint_send_to(request, pk):
<p>Complaint <strong>#{complaint.reference_number}</strong> has been sent to your department <strong>({department.name})</strong>.</p>
<p><strong>Title:</strong> {complaint.title or 'N/A'}</p>
{f'<p><strong>Note:</strong> {note}</p>' if note else ''}
<p><strong>Assigned to:</strong> {contact_person.get_full_name()} ({contact_info['role_label']})</p>
<p><a href="https://{request.get_host()}/organizations/departments/{department.pk}/">View Department Page</a></p>
<p><strong>Role:</strong> {label}</p>
<p><a href="{review_link}">Review / Respond</a></p>
</div>
</div>
""",
related_object=complaint,
)
if phone:
try:
NotificationService.send_sms(
phone,
f"PX360: Complaint #{complaint.reference_number} sent to {department.name}. Review: {review_link}",
related_object=complaint,
)
except Exception:
pass
message = f"Complaint sent to {department.name}{contact_person.get_full_name()} ({contact_info['role_label']})."
notified.append(f"{display_name} ({label})")
message = f"Complaint sent to {department.name}{', '.join(notified)}."
# Optionally record a selected staff member as involved (NOT notified;
# notification target remains champion/manager only).
staff_id = request.POST.get("staff_id", "").strip()
if staff_id:
from .models import ComplaintInvolvedStaff
staff_member = Staff.objects.filter(pk=staff_id, department=department).first()
if staff_member:
ComplaintInvolvedStaff.objects.get_or_create(
complaint=complaint,
staff=staff_member,
defaults={"added_by": user},
)
# Set forwarded timestamp and update tracking fields if active
now = timezone.now()
@ -1203,6 +1348,13 @@ def update_satisfaction(request, pk):
messages.error(request, _("You don't have permission to update satisfaction."))
return redirect("complaints:complaint_detail", pk=pk)
if complaint.satisfaction_locked_by_patient:
messages.error(
request,
_("Satisfaction is locked because the patient has submitted their feedback and cannot be changed."),
)
return redirect("complaints:complaint_detail", pk=pk)
satisfaction = request.POST.get("satisfaction", "")
valid_choices = ["satisfied", "neutral", "dissatisfied", "no_response"]
if satisfaction and satisfaction not in valid_choices:
@ -2692,6 +2844,12 @@ def inquiry_create(request):
inquiry.save()
# Background patient linking (local DB first, then HIS).
if not inquiry.patient_id:
from .tasks import link_inquiry_patient
link_inquiry_patient.delay(str(inquiry.id), inquiry.contact_phone)
comm_req_id = request.POST.get("comm_req")
if comm_req_id:
try:
@ -3583,24 +3741,15 @@ def inquiry_send_to(request, pk):
"error": str(_("Department not found.")),
}, status=400)
if not department.champion and not department.manager:
return JsonResponse({
"success": False,
"error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned.")),
}, status=400)
# Auto-target the department's champion and manager (no contact-person picker)
from apps.organizations.department_contacts import get_champion_and_manager
from apps.notifications.services import NotificationService
contact_person_id = request.POST.get("contact_person_id")
if not contact_person_id:
targets = get_champion_and_manager(department)
if not targets:
return JsonResponse({
"success": False,
"error": str(_("Please select a contact person.")),
}, status=400)
contact_info = department.is_valid_contact_person(contact_person_id)
if not contact_info:
return JsonResponse({
"success": False,
"error": str(_("Selected person is not a role holder in this department.")),
"error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned. Please assign one before sending.")),
}, status=400)
# Transfer to department
@ -3623,7 +3772,36 @@ def inquiry_send_to(request, pk):
inquiry.dept_response_second_reminder_sent_at = None
inquiry.dept_response_escalated_at = None
message = f"Inquiry sent to {department.get_localized_name()}{contact_info['name']} ({contact_info['role_label']})."
# Notify champion + manager (email + SMS) with the inquiry link
notified = []
link = f"https://{request.get_host()}/inquiries/{inquiry.pk}/"
for target in targets:
display_name = (
target["staff"].get_full_name() if target.get("staff")
else (target["user"].get_full_name() if target.get("user") else target["label"])
)
if target.get("email"):
try:
NotificationService.send_email(
email=target["email"],
subject=f"Inquiry Sent to Department - {inquiry.reference_number}",
message=f"Inquiry #{inquiry.reference_number} has been sent to your department ({department.name}).\n\n{link}",
related_object=inquiry,
)
except Exception:
pass
if target.get("phone"):
try:
NotificationService.send_sms(
target["phone"],
f"PX360: Inquiry #{inquiry.reference_number} sent to {department.name}. Review: {link}",
related_object=inquiry,
)
except Exception:
pass
notified.append(f"{display_name} ({target['label']})")
message = f"Inquiry sent to {department.get_localized_name()}{', '.join(notified)}."
# Set contact_status if open
if inquiry.status in ("open",):
@ -3640,16 +3818,6 @@ def inquiry_send_to(request, pk):
created_by=user,
)
# Send department notification if applicable
if recipient_type == "department":
try:
from apps.notifications.settings_service import NotificationServiceWithSettings
NotificationServiceWithSettings.send_inquiry_department_assigned(
department, inquiry, context_note_en=note, context_note_ar="",
)
except Exception as e:
logger.warning(f"Failed to send department notification: {e}")
return JsonResponse({
"success": True,
"message": message,
@ -3859,7 +4027,6 @@ def inquiry_review_dept_response(request, pk):
related_object=inquiry,
)
except Exception:
import logging
logging.getLogger(__name__).exception("Failed to send inquiry dept response rejection email")
InquiryUpdate.objects.create(
@ -4245,7 +4412,9 @@ def public_complaint_submit(request):
# Reference number generated by Complaint.save() (unified CMP-YYYYMM-HOSP-NNNN)
# Create complaint with location hierarchy and all form fields
# Create complaint with location hierarchy and all form fields.
# Patient linking is backgrounded (local DB first, then HIS) so a slow/unreachable
# HIS never blocks the public submission.
complaint = Complaint.objects.create(
patient=None,
hospital=hospital,
@ -4276,11 +4445,12 @@ def public_complaint_submit(request):
message="Complaint submitted via public form. AI analysis running in background.",
)
# Trigger AI analysis in the background using Celery
from .tasks import analyze_complaint_with_ai, notify_staff_new_item
# Trigger AI analysis + patient linking in the background using Celery
from .tasks import analyze_complaint_with_ai, link_complaint_patient, notify_staff_new_item
analyze_complaint_with_ai.delay(str(complaint.id))
notify_staff_new_item.delay("complaint", str(complaint.id))
link_complaint_patient.delay(str(complaint.id), national_id, mobile_number)
# If form was submitted via AJAX, return JSON
if request.headers.get("x-requested-with") == "XMLHttpRequest":
@ -4345,10 +4515,28 @@ def public_complaint_track(request):
- Show basic information (status, category, submission date, last update)
- Timeline of public updates (without exposing internal notes)
- SLA deadline information
- Patient satisfaction submission (locks the field once submitted).
The picker is available for 5 days after resolution/closure, matching the
unified tracker expiry in core/views.py.
"""
from datetime import timedelta
from django.utils import timezone
complaint = None
error_message = None
info_message = None
satisfaction_just_submitted = False
reference_number = request.GET.get("reference", "").strip()
tracking_expired = False
def _compute_tracking_expired(comp):
if not comp:
return False
expiry_base = comp.resolved_at or comp.closed_at
if not expiry_base:
return False
return timezone.now() > expiry_base + timedelta(days=5)
if request.method == "POST":
reference_number = request.POST.get("reference_number", "").strip()
@ -4370,6 +4558,36 @@ def public_complaint_track(request):
except Complaint.DoesNotExist:
error_message = _("No complaint found with this reference number. Please check and try again.")
tracking_expired = _compute_tracking_expired(complaint)
# Handle patient satisfaction submission (separate from reference search)
satisfaction_value = request.POST.get("satisfaction", "").strip()
if satisfaction_value and complaint is not None:
valid_choices = ["satisfied", "neutral", "dissatisfied"]
if satisfaction_value not in valid_choices:
error_message = _("Invalid satisfaction value.")
elif complaint.status not in ("resolved", "closed"):
error_message = _("Satisfaction can only be submitted after the complaint is resolved.")
elif tracking_expired:
error_message = _("This tracking link has expired. Satisfaction feedback can no longer be submitted.")
elif complaint.satisfaction_locked_by_patient:
info_message = _("You have already submitted your feedback. Thank you!")
else:
complaint.satisfaction = satisfaction_value
complaint.satisfaction_set_at = timezone.now()
complaint.satisfaction_locked_by_patient = True
complaint.satisfaction_locked_at = timezone.now()
complaint.save(
update_fields=[
"satisfaction",
"satisfaction_set_at",
"satisfaction_locked_by_patient",
"satisfaction_locked_at",
"updated_at",
]
)
satisfaction_just_submitted = True
elif reference_number:
# GET request with reference parameter
try:
@ -4385,6 +4603,8 @@ def public_complaint_track(request):
except Complaint.DoesNotExist:
error_message = _("No complaint found with this reference number. Please check and try again.")
tracking_expired = _compute_tracking_expired(complaint)
public_status = None
public_updates = []
if complaint:
@ -4418,6 +4638,9 @@ def public_complaint_track(request):
"public_status": public_status,
"public_updates": public_updates,
"error_message": error_message,
"info_message": info_message,
"satisfaction_just_submitted": satisfaction_just_submitted,
"tracking_expired": tracking_expired,
"reference_number": reference_number,
}
@ -4491,6 +4714,8 @@ def public_inquiry_submit(request):
from datetime import datetime
# Reference number generated by Inquiry.save() (unified INQ-YYYYMM-HOSP-NNNN)
# Patient linking is backgrounded (local DB first, then HIS) so a slow HIS
# never blocks the public inquiry submission.
inquiry = Inquiry.objects.create(
patient=None,
hospital=hospital,
@ -4508,10 +4733,11 @@ def public_inquiry_submit(request):
)
reference_number = inquiry.reference_number
from apps.complaints.tasks import analyze_inquiry_with_ai, notify_staff_new_item
from apps.complaints.tasks import analyze_inquiry_with_ai, link_inquiry_patient, notify_staff_new_item
analyze_inquiry_with_ai.delay(str(inquiry.id))
notify_staff_new_item.delay("inquiry", str(inquiry.id))
link_inquiry_patient.delay(str(inquiry.id), phone)
AuditService.log_event(
event_type="inquiry_created_public",
@ -5652,6 +5878,14 @@ def involved_department_remove(request, pk):
messages.error(request, _("You don't have permission to manage this complaint."))
return redirect("complaints:complaint_detail", pk=complaint.pk)
# If the user is removing the complaint's primary department involvement,
# record it so ensure_involved_records() does not auto-recreate it on the
# next detail-view render. Stored as the dept id; if complaint.department
# later changes to a different dept, auto-recreation resumes automatically.
if involved_dept.department_id == complaint.department_id and complaint.department_id is not None:
complaint.primary_dept_involved_removed_id = complaint.department_id
complaint.save(update_fields=["primary_dept_involved_removed"])
involved_dept.delete()
# Log the update
@ -5731,13 +5965,8 @@ def involved_department_response(request, pk):
involved_dept.response_notes_ar = response_notes_ar
involved_dept.response_submitted = True
involved_dept.response_submitted_at = timezone.now()
involved_dept.acceptance_status = "pending"
involved_dept.accepted_by = None
involved_dept.accepted_at = None
involved_dept.acceptance_notes = ""
involved_dept.manager_review_status = "pending"
involved_dept.manager_reviewed_by = None
involved_dept.manager_reviewed_at = None
involved_dept.acceptance_status = "acceptable"
involved_dept.accepted_at = timezone.now()
involved_dept.save()
ComplaintUpdate.objects.create(
@ -5769,40 +5998,6 @@ def involved_department_response(request, pk):
except Exception as e:
logger.warning(f"Failed to send complainant notification: {e}")
dept = involved_dept.department
if dept.manager and dept.manager.email:
try:
from apps.notifications.services import NotificationService, get_email_header_html
review_url = request.build_absolute_uri(
reverse("organizations:department_manager_review", kwargs={"pk": dept.pk, "idept_pk": involved_dept.pk})
)
NotificationService.send_email(
dept.manager.email,
subject=f"Champion Response Requires Your Review - {complaint.reference_number}",
message=(
f"A champion response for complaint {complaint.reference_number} "
f"from {involved_dept.department.name} requires your review and approval.\n\n"
f"Please review at: {review_url}"
),
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Champion Response Requires Your Review</h2>
<p>A champion from <strong>{involved_dept.department.name}</strong> has submitted a response
for complaint <strong>{complaint.reference_number}</strong>.</p>
<p>Your review and approval is required before it is forwarded to the PX team.</p>
<p style="margin-top: 16px;">
<a href="{review_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">Review Response</a>
</p>
</div>
</div>
""",
related_object=complaint,
)
except Exception as e:
logger.error(f"Failed to send manager review notification: {e}")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
redirect_url = reverse("organizations:department_detail", kwargs={"pk": user.department.pk}) if user.department else reverse("complaints:complaint_detail", kwargs={"pk": complaint.pk})
return JsonResponse({
@ -6064,6 +6259,14 @@ def involved_staff_remove(request, pk):
messages.error(request, _("You don't have permission to manage this complaint."))
return redirect("complaints:complaint_detail", pk=complaint.pk)
# If the user is removing the complaint's primary staff (accused) involvement,
# record it so ensure_involved_records() does not auto-recreate it on the
# next detail-view render. Stored as the staff id; if complaint.staff later
# changes to a different staff, auto-recreation resumes automatically.
if involved_staff.staff_id == complaint.staff_id and complaint.staff_id is not None:
complaint.primary_staff_involved_removed_id = complaint.staff_id
complaint.save(update_fields=["primary_staff_involved_removed"])
involved_staff.delete()
# Log the update

View File

@ -1,246 +0,0 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.translation import gettext as _
from django.views.decorators.http import require_http_methods
from apps.core.services import AuditService
from apps.organizations.models import Staff
from .models import Complaint, ComplaintExplanation, ComplaintStatus
from .services.complaint_service import ComplaintService, ComplaintServiceError
@login_required
@require_http_methods(["GET", "POST"])
def send_to_department_form(request, pk):
complaint = get_object_or_404(
Complaint.objects.prefetch_related(
"involved_staff__staff__department",
"involved_staff__staff__report_to",
"involved_departments__department__champion",
"involved_departments__department__manager",
),
pk=pk,
)
user = request.user
can_request = (
user.is_px_admin()
or user.is_hospital_admin()
or (user.is_department_manager() and complaint.department == user.department)
or (complaint.hospital == user.hospital)
)
if not can_request:
return HttpResponseForbidden(_("You don't have permission to request explanations."))
if not complaint.is_active_status:
messages.error(
request,
_(
"Cannot request explanation for complaint with status '{}'. Complaint must be Open, In Progress, or Partially Resolved."
).format(complaint.get_status_display()),
)
return redirect("complaints:complaint_detail", pk=complaint.pk)
# Must be activated (in_progress) before it can be sent to a department
if complaint.status == "open":
messages.error(request, _("Activate this complaint before sending it to a department."))
return redirect("complaints:complaint_detail", pk=complaint.pk)
involved_staff = complaint.involved_staff.select_related(
"staff", "staff__department", "staff__report_to"
).all()
department_groups = {}
ungrouped_staff = []
# Build department groups from involved_departments first
involved_departments = complaint.involved_departments.select_related(
"department__champion", "department__manager"
).all()
for dept_inv in involved_departments:
dept = dept_inv.department
if not dept:
continue
dept_key = str(dept.id)
if dept_key not in department_groups:
champion = dept.champion
champion_email = champion.email or (champion.user.email if champion and champion.user else None) if champion else None
dept_manager = dept.manager
department_groups[dept_key] = {
"department_id": dept_key,
"department_name": dept.get_localized_name(),
"champion": champion,
"champion_id": str(champion.id) if champion else None,
"champion_name": champion.get_full_name() if champion else None,
"champion_email": champion_email,
"dept_manager": dept_manager,
"dept_manager_id": str(dept_manager.id) if dept_manager else None,
"dept_manager_name": dept_manager.get_full_name() if dept_manager else None,
"dept_manager_email": dept_manager.email if dept_manager else None,
"staff_list": [],
}
# Add involved staff to their department groups
for staff_inv in involved_staff:
staff = staff_inv.staff
dept = staff.department
entry = {
"staff_inv": staff_inv,
"staff": staff,
"staff_id": str(staff.id),
"staff_name": staff.get_full_name(),
"staff_email": staff.email or (staff.user.email if staff.user else None),
"role": staff_inv.get_role_display(),
}
if dept:
dept_key = str(dept.id)
if dept_key in department_groups:
department_groups[dept_key]["staff_list"].append(entry)
else:
champion = dept.champion
champion_email = champion.email or (champion.user.email if champion and champion.user else None) if champion else None
dept_manager = dept.manager
department_groups[dept_key] = {
"department_id": dept_key,
"department_name": dept.get_localized_name(),
"champion": champion,
"champion_id": str(champion.id) if champion else None,
"champion_name": champion.get_full_name() if champion else None,
"champion_email": champion_email,
"dept_manager": dept_manager,
"dept_manager_id": str(dept_manager.id) if dept_manager else None,
"dept_manager_name": dept_manager.get_full_name() if dept_manager else None,
"dept_manager_email": dept_manager.email if dept_manager else None,
"staff_list": [entry],
}
else:
ungrouped_staff.append(entry)
if not department_groups and not involved_staff.exists():
if not involved_departments.exists():
messages.error(request, _("No departments are involved in this complaint. Please add a department first."))
return redirect("complaints:complaint_detail", pk=complaint.pk)
if request.method == "POST":
action = request.POST.get("action", "send")
selected_dept_ids = request.POST.getlist("selected_departments")
request_message = request.POST.get("request_message", "").strip()
if not selected_dept_ids:
messages.error(request, _("Please select at least one department."))
return render(
request,
"complaints/send_to_department_form.html",
{
"complaint": complaint,
"department_groups": department_groups,
"ungrouped_staff": ungrouped_staff,
},
)
if action == "preview":
preview_depts = []
for dept_id in selected_dept_ids:
dept_info = department_groups.get(dept_id)
if dept_info:
contact_person_id = request.POST.get(f"contact_person_{dept_id}", "")
if contact_person_id:
from apps.organizations.models import Department as DeptModel
dept_obj = DeptModel.objects.filter(pk=dept_id).first()
if dept_obj:
cinfo = dept_obj.is_valid_contact_person(contact_person_id)
if cinfo:
dept_info["contact_person_id"] = contact_person_id
dept_info["contact_person_name"] = cinfo["name"]
dept_info["contact_person_role"] = cinfo["role_label"]
dept_info["contact_person_email"] = cinfo["email"]
dept_info["contact_person_staff"] = cinfo["staff"]
preview_depts.append(dept_info)
contact_person_map = {
d["department_id"]: d.get("contact_person_id", "")
for d in preview_depts
if d.get("contact_person_id")
}
return render(
request,
"complaints/send_to_department_preview.html",
{
"complaint": complaint,
"preview_depts": preview_depts,
"selected_dept_ids": selected_dept_ids,
"request_message": request_message,
"contact_person_map": contact_person_map,
},
)
from django.contrib.sites.shortcuts import get_current_site
contact_person_map = {}
for dept_id in selected_dept_ids:
cp_id = request.POST.get(f"contact_person_{dept_id}", "")
if cp_id:
contact_person_map[dept_id] = cp_id
site = get_current_site(request)
results = ComplaintService.send_to_department(
complaint,
department_groups,
selected_dept_ids,
request_message,
request.user,
site.domain,
request=request,
contact_person_map=contact_person_map,
)
if not complaint.forwarded_to_dept_at:
complaint.forwarded_to_dept_at = timezone.now()
complaint.save(update_fields=["forwarded_to_dept_at"])
if results["champion_count"] == 0 and results["manager_count"] == 0:
if results["skipped_no_email"] > 0:
messages.warning(
request,
_(
"No explanation requests were sent. {} department(s) have champions without email addresses."
).format(results["skipped_no_email"]),
)
else:
messages.warning(
request, _("No explanation requests were sent. Please check department champion configuration.")
)
elif results["champion_count"] == 0 and results["manager_count"] > 0:
messages.warning(
request,
_(
"Only department manager notifications were sent ({}). Champion explanation requests could not be sent."
).format(results["manager_count"]),
)
else:
messages.success(
request,
_("Explanation requests sent! Champions: {}, Department managers notified: {}.").format(
results["champion_count"], results["manager_count"]
),
)
return redirect("complaints:complaint_detail", pk=complaint.pk)
return render(
request,
"complaints/send_to_department_form.html",
{
"complaint": complaint,
"department_groups": department_groups,
"ungrouped_staff": ungrouped_staff,
},
)

View File

@ -8,16 +8,20 @@ from .views import (
ComplaintViewSet,
InquiryViewSet,
complaint_explanation_form,
complaint_explanation_pdf,
complaint_review_pdf,
champion_start_investigation,
staff_search_for_investigation,
staff_investigation_form,
champion_review_answers,
generate_complaint_pdf,
inquiry_pdf,
api_locations,
api_sections,
api_subsections,
api_departments,
)
from . import ui_views, ui_views_explanation, ui_views_oncall, ui_views_templates
from . import ui_views, ui_views_oncall, ui_views_templates
app_name = "complaints"
@ -136,7 +140,10 @@ urlpatterns = [
path("public/api/hospitals/<int:hospital_id>/departments/", api_departments, name="api_departments"),
# Public Explanation Form (No Authentication Required)
path("<uuid:complaint_id>/explain/<str:token>/", complaint_explanation_form, name="complaint_explanation_form"),
path("<uuid:complaint_id>/explain/<str:token>/pdf/", complaint_explanation_pdf, name="complaint_explanation_pdf"),
path("<uuid:complaint_id>/investigate/review/<str:token>/pdf/", complaint_review_pdf, name="complaint_review_pdf"),
path("<uuid:complaint_id>/investigate/<str:token>/", champion_start_investigation, name="champion_start_investigation"),
path("<uuid:complaint_id>/investigate/<str:token>/search-staff/", staff_search_for_investigation, name="staff_search_for_investigation"),
path("<uuid:complaint_id>/investigate/respond/<str:token>/", staff_investigation_form, name="staff_investigation_form"),
path("<uuid:complaint_id>/investigate/review/<str:token>/", champion_review_answers, name="champion_review_answers"),
# Patient Complaint Portal (No Authentication Required)
@ -159,6 +166,7 @@ urlpatterns = [
),
# PDF Export
path("<uuid:pk>/pdf/", generate_complaint_pdf, name="complaint_pdf"),
path("inquiries/<uuid:pk>/pdf/", inquiry_pdf, name="inquiry_pdf"),
# Involved Departments Management
path("<uuid:complaint_pk>/departments/add/", ui_views.involved_department_add, name="involved_department_add"),
path(
@ -170,12 +178,10 @@ urlpatterns = [
path("departments/<uuid:pk>/remove/", ui_views.involved_department_remove, name="involved_department_remove"),
path("departments/<uuid:pk>/response/", ui_views.involved_department_response, name="involved_department_response"),
path("departments/<uuid:pk>/review-response/", ui_views.involved_department_review_response, name="involved_department_review_response"),
# Send to Department Form
path(
"<uuid:pk>/send-to-department/", ui_views_explanation.send_to_department_form, name="send_to_department_form"
),
# Unified Send To (Person or Department) - AJAX
path("<uuid:pk>/send-to/", ui_views.complaint_send_to, name="complaint_send_to"),
# Collect Feedback (champion/manager compose questions for staff)
path("<uuid:pk>/collect-feedback/", ui_views.collect_feedback_start, name="collect_feedback_start"),
# Involved Staff Management
path("<uuid:complaint_pk>/staff/add/", ui_views.involved_staff_add, name="involved_staff_add"),
path("staff/<uuid:pk>/edit/", ui_views.involved_staff_edit, name="involved_staff_edit"),

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,130 @@
"""
Workflow timeline builder for the complaint department-response flow.
Assembles a chronological list of events from ComplaintInvolvedDepartment,
ComplaintExplanation, ChampionInvestigation, InvestigationResponse,
InvestigationAnswer, and ComplaintUpdate so the template can render
a visual timeline of the entire workflow.
"""
from datetime import datetime
def build_workflow_log(complaint):
"""Return a chronologically sorted list of workflow events for a complaint."""
events = []
# 1. Sent to department(s)
for idept in complaint.involved_departments.select_related("department", "added_by").all():
ts = idept.forwarded_at or idept.sent_at
if ts:
events.append({
"type": "sent_to_dept",
"timestamp": ts,
"actor": idept.added_by.get_full_name() if idept.added_by else "PX Team",
"department": idept.department.get_localized_name() if hasattr(idept.department, 'get_localized_name') else (idept.department.name if idept.department else ""),
"is_primary": idept.is_primary,
})
# 2. Explanations (direct replies + investigations)
for exp in complaint.explanations.select_related("staff", "staff__department", "requested_by").all():
champion_name = exp.staff.get_full_name() if exp.staff else "Department"
# Email sent timestamp
if exp.email_sent_at:
note = getattr(exp, "request_message", "") or ""
events.append({
"type": "explanation_sent",
"timestamp": exp.email_sent_at,
"actor": "PX Team",
"recipient": champion_name,
"note": note,
"attachment_count": exp.attachments.count(),
})
# Investigations linked to this explanation
investigations = list(
exp.investigation.select_related("champion", "champion__department").all()
)
if investigations:
for inv in investigations:
inv_ts = inv.created_at
events.append({
"type": "investigation_started",
"timestamp": inv_ts,
"actor": inv.champion.get_full_name() if inv.champion else champion_name,
"status": inv.status,
})
# Per-staff: questions sent + responses
for resp in inv.responses.select_related("staff", "staff__department").all():
staff_name = resp.staff.get_full_name() if resp.staff else "Unknown"
# Questions sent to this staff
q_sent_ts = resp.email_sent_at or inv_ts
questions = list(resp.questions.order_by("order").all())
events.append({
"type": "questions_sent",
"timestamp": q_sent_ts,
"actor": inv.champion.get_full_name() if inv.champion else champion_name,
"staff": staff_name,
"question_count": len(questions),
"questions": [
{"text": q.question_text, "type": q.question_type}
for q in questions
],
})
# Staff responded
if resp.is_completed and resp.completed_at:
qa_pairs = []
for ans in resp.answers.select_related("question").all():
qa_pairs.append({
"question": ans.question.question_text,
"question_type": ans.question.question_type,
"answer": ans.answer_text,
})
events.append({
"type": "staff_responded",
"timestamp": resp.completed_at,
"actor": staff_name,
"investigator": inv.champion.get_full_name() if inv.champion else "Department Champion",
"qa_pairs": qa_pairs,
})
else:
events.append({
"type": "staff_pending",
"timestamp": q_sent_ts,
"actor": staff_name,
"question_count": len(questions),
})
# Final reply submitted
if inv.status == "reply_submitted" and exp.responded_at:
events.append({
"type": "reply_submitted",
"timestamp": exp.responded_at,
"actor": inv.champion.get_full_name() if inv.champion else champion_name,
"final_reply": inv.final_reply,
"negligence": inv.negligence_finding,
"policy_issue": inv.policy_issue_finding,
"improvement_project": inv.requires_improvement_project,
"improvement_note": inv.improvement_project_note,
"attachment_count": exp.attachments.count(),
})
else:
# Direct reply (no investigation)
if exp.is_used and exp.responded_at:
events.append({
"type": "direct_reply",
"timestamp": exp.responded_at,
"actor": champion_name,
"reply": exp.explanation,
"attachment_count": exp.attachments.count(),
})
# Sort by timestamp (None timestamps go last)
events.sort(key=lambda e: e.get("timestamp") or datetime.max)
return events

View File

@ -158,6 +158,10 @@ class AIService:
with httpx.Client(timeout=timeout) as client:
resp = client.post(url, headers=headers, json=payload)
if resp.status_code in (401, 402):
cls._notify_ai_failure(resp.status_code, resp.text)
resp.raise_for_status()
data = resp.json()
@ -165,6 +169,102 @@ class AIService:
logger.info(f"AI Response: length={len(content)}")
return content
@classmethod
def _notify_ai_failure(cls, status_code, error_body=""):
"""Notify PX Admins when AI service fails due to credits or auth issues.
Debounced via Django cache only fires once per hour.
"""
try:
from django.core.cache import cache
cache_key = "ai_service_failure_notified"
if cache.get(cache_key):
return
cache.set(cache_key, True, timeout=3600)
from apps.accounts.models import User
from apps.notifications.services import NotificationService
px_admins = User.objects.filter(groups__name="PX Admin", is_active=True)
if not px_admins.exists():
logger.warning("No PX Admin users found to notify about AI failure")
return
timestamp = timezone.now().strftime("%Y-%m-%d %H:%M:%S")
if status_code == 402:
subject = f"🚨 AI Service Unavailable — Credits Exhausted ({timestamp})"
severity = "Credits Exhausted"
action = (
"Top up credits at https://openrouter.ai/credits "
"or update OPENROUTER_API_KEY with a funded account."
)
impact = (
"AI complaint classification, survey analysis, observation analysis, "
"executive summaries, and analytics are all degraded. "
"New complaints will default to severity=medium, priority=medium until resolved."
)
else:
subject = f"🚨 AI Service Error — Authentication Failed ({timestamp})"
severity = "Authentication Failed"
action = (
"Check the OPENROUTER_API_KEY in settings or the hardcoded key in ai_service.py. "
"The API key may be invalid or revoked."
)
impact = "All AI-powered features are unavailable until the API key is fixed."
body_preview = error_body[:500] if error_body else "No response body"
plain_text = (
f"AI Service Failure — {severity}\n"
f"Timestamp: {timestamp}\n"
f"HTTP Status: {status_code}\n"
f"Response: {body_preview}\n\n"
f"Impact: {impact}\n\n"
f"Action required: {action}\n"
)
red = "#dc2626" if status_code == 402 else "#ea580c"
html_message = f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
<div style="background: {red}; padding: 16px 24px;">
<h2 style="color: white; margin: 0; font-size: 18px;">🚨 AI Service {severity}</h2>
<p style="color: rgba(255,255,255,0.8); margin: 4px 0 0; font-size: 13px;">{timestamp}</p>
</div>
<div style="padding: 20px;">
<p style="margin: 0 0 8px;"><strong>HTTP Status:</strong> {status_code}</p>
<div style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; margin-bottom: 16px;">
<pre style="white-space: pre-wrap; font-size: 12px; color: #374151; margin: 0;">{body_preview}</pre>
</div>
<div style="background: #fef3c7; border: 1px solid #fde68a; border-radius: 8px; padding: 12px; margin-bottom: 12px;">
<p style="font-size: 13px; color: #78350f; margin: 0;"><strong>Impact:</strong> {impact}</p>
</div>
<div style="background: #dbeafe; border: 1px solid #93c5fd; border-radius: 8px; padding: 12px;">
<p style="font-size: 13px; color: #1e3a5f; margin: 0;"><strong>Action required:</strong> {action}</p>
</div>
</div>
</div>
"""
for admin in px_admins:
try:
NotificationService.send_email(
email=admin.email,
subject=subject,
message=plain_text,
html_message=html_message,
user=admin,
notification_type="system",
)
except Exception:
logger.error(f"Failed to send AI failure notification to {admin.email}")
logger.warning(f"AI service failure notification sent to {px_admins.count()} PX Admins (status {status_code})")
except Exception as e:
logger.error(f"Failed to send AI failure notification: {e}")
@classmethod
def _get_model(cls) -> str:
"""Get AI model from settings"""

View File

@ -13,6 +13,8 @@ urlpatterns = [
path("sla/", config_views.sla_config_list, name="sla_config_list"),
path("routing/", config_views.routing_rules_list, name="routing_rules_list"),
path("users/", config_views.hospital_users_list, name="hospital_users_list"),
path("users/create/", config_views.user_create, name="user_create"),
path("users/<uuid:user_id>/edit/", config_views.user_edit, name="user_edit"),
path("users/<uuid:user_id>/reset-password/", config_views.reset_user_password, name="reset_user_password"),
path("users/<uuid:user_id>/toggle-active/", config_views.toggle_user_active, name="toggle_user_active"),
path("test/", config_views.test, name="test"),

View File

@ -199,6 +199,117 @@ def hospital_users_list(request):
return render(request, "config/hospital_users.html", context)
@admin_required
def user_create(request):
"""Create a new user from the config console"""
from apps.accounts.forms import UserCreateForm
if not request.user.is_px_admin() and not request.user.is_hospital_admin():
from django.http import HttpResponseForbidden
return HttpResponseForbidden(_("You don't have permission to create users."))
if request.method == "POST":
form = UserCreateForm(request.POST, request=request)
if form.is_valid():
user = form.save()
generated_password = getattr(form, '_generated_password', None)
staff_id = request.POST.get("staff_id")
if staff_id:
from apps.organizations.models import Staff
try:
staff = Staff.objects.get(pk=staff_id, user__isnull=True)
staff.user = user
staff.save(update_fields=["user"])
messages.success(request, _("User '{}' created and linked to staff '{}'.").format(
user.get_full_name(), staff.first_name + " " + staff.last_name
))
except Staff.DoesNotExist:
messages.success(request, _("User '{}' created successfully.").format(user.get_full_name()))
else:
messages.success(request, _("User '{}' created successfully.").format(user.get_full_name()))
try:
base_url = f"{request.scheme}://{request.get_host()}"
reset_token = PasswordResetTokenService.create_reset_token(user)
reset_url = PasswordResetTokenService.build_reset_url(base_url, reset_token)
if generated_password:
html_message = render_to_string(
"config/emails/user_created_email.html",
{"user": user, "reset_url": reset_url, "temp_password": generated_password},
request=request,
)
plain_message = (
f"Dear {user.get_full_name()},\n\n"
f"An account has been created for you on PX360.\n\n"
f"Your temporary password: {generated_password}\n\n"
f"For security, please set your own password using the link below:\n{reset_url}\n\n"
f"This link expires in 24 hours."
)
subject = _("Your PX360 Account Has Been Created")
else:
html_message = render_to_string(
"config/emails/reset_password_email.html",
{"user": user, "reset_url": reset_url},
request=request,
)
plain_message = (
f"Dear {user.get_full_name()},\n\n"
f"An account has been created for you on PX360.\n\n"
f"Use the link below to set your password:\n{reset_url}\n\n"
f"This link expires in 24 hours."
)
subject = _("Your PX360 Account Has Been Created")
NotificationService.send_email(
email=user.email,
subject=subject,
message=plain_message,
html_message=html_message,
user=user,
notification_type="system",
)
if generated_password:
messages.info(request, _("Credentials sent to {}.").format(user.email))
else:
messages.info(request, _("Password setup link sent to {}.").format(user.email))
except Exception as e:
messages.warning(request, _("User created but email sending failed: {}").format(str(e)))
return redirect("config:hospital_users_list")
else:
form = UserCreateForm(request=request)
context = {"form": form}
return render(request, "config/user_form.html", context)
@admin_required
def user_edit(request, user_id):
"""Edit an existing user from the config console"""
from apps.accounts.forms import UserEditForm
target_user = get_object_or_404(User, pk=user_id, is_superuser=False, is_provisional=False)
if not request.user.is_px_admin():
if request.tenant_hospital and target_user.hospital != request.tenant_hospital:
from django.http import HttpResponseForbidden
return HttpResponseForbidden(_("You can only edit users in your hospital."))
if request.method == "POST":
form = UserEditForm(request.POST, instance=target_user, request=request)
if form.is_valid():
form.save()
messages.success(request, _("User '{}' updated successfully.").format(target_user.get_full_name()))
return redirect("config:hospital_users_list")
else:
form = UserEditForm(instance=target_user, request=request)
context = {"form": form, "edit_user": target_user}
return render(request, "config/user_form.html", context)
@admin_required
def reset_user_password(request, user_id):
"""Reset a user's password and send them the new credentials via email."""

View File

@ -113,7 +113,7 @@ def hospital_context(request):
hospitals_list = list(
Hospital.objects.filter(status="active")
.order_by("name")
.values("id", "name", "display_name", "display_name_ar", "code")
.values("id", "name", "name_ar", "display_name", "display_name_ar", "code")
)
# Source user context

View File

@ -32,7 +32,7 @@ def px_admin_required(view_func):
def _wrapped_view(request, *args, **kwargs):
if not request.user.is_px_admin():
messages.error(request, _("Access denied. PX Admin privileges required."))
return redirect("analytics:command_center")
return redirect("dashboard:command-center")
return view_func(request, *args, **kwargs)
@ -54,7 +54,7 @@ def hospital_admin_required(view_func):
def _wrapped_view(request, *args, **kwargs):
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
messages.error(request, _("Access denied. Hospital Admin privileges required."))
return redirect("analytics:command_center")
return redirect("dashboard:command-center")
return view_func(request, *args, **kwargs)
@ -76,7 +76,7 @@ def admin_required(view_func):
def _wrapped_view(request, *args, **kwargs):
if not (request.user.is_px_admin() or request.user.is_hospital_admin() or request.user.is_department_manager()):
messages.error(request, _("Access denied. Admin privileges required."))
return redirect("analytics:command_center")
return redirect("dashboard:command-center")
return view_func(request, *args, **kwargs)
@ -103,7 +103,7 @@ def px_employee_required(view_func):
user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager() or user.is_px_management() or user.is_px_employee()
):
messages.error(request, _("Access denied. PX Management privileges required."))
return redirect("analytics:command_center")
return redirect("dashboard:command-center")
return view_func(request, *args, **kwargs)

39
apps/core/pdf_utils.py Normal file
View File

@ -0,0 +1,39 @@
"""
Shared PDF generation helper for any model using the hospital letterhead.
"""
import io
import base64
from django.conf import settings
from django.template.loader import render_to_string
from weasyprint import HTML
def _get_logo_data_uri():
"""Load the HH_P_ICON as a base64 data URI for embedding in PDF."""
try:
from PIL import Image as PILImage
logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png")
logo_img.thumbnail((600, 600), PILImage.LANCZOS)
buf = io.BytesIO()
logo_img.save(buf, format="PNG", optimize=True)
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception:
return None
def generate_letterhead_pdf(template_name, context, filename):
"""Render a template with the hospital letterhead and return an HttpResponse with the PDF."""
from django.http import HttpResponse
logo_path = _get_logo_data_uri()
full_context = {**context, "logo_path": logo_path}
html_string = render_to_string(template_name, full_context)
pdf_file = HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf()
response = HttpResponse(pdf_file, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response

View File

@ -215,7 +215,13 @@ def public_inquiry_submit(request):
section = Section.objects.filter(id=section_id).first() if section_id else None
area = Area.objects.filter(id=area_id).first() if area_id else None
# Auto-link patient by phone (local first, then HIS). Never blocks creation.
from apps.organizations.patient_lookup import find_or_link_patient
linked_patient = find_or_link_patient(phone=phone, hospital=hospital)
inquiry = Inquiry.objects.create(
patient=linked_patient,
hospital=hospital,
contact_name=name,
contact_email=email,
@ -295,7 +301,7 @@ def api_hospitals(request):
"""
from apps.organizations.models import Hospital
hospitals = Hospital.objects.all().order_by("name").values("id", "name")
hospitals = Hospital.objects.all().order_by("name").values("id", "name", "name_ar")
return JsonResponse({"success": True, "hospitals": list(hospitals)})
@ -527,6 +533,8 @@ def _track_complaint(reference):
"response": response,
"satisfaction": complaint.satisfaction or "",
"satisfaction_set_at": complaint.satisfaction_set_at.strftime("%Y-%m-%d %H:%M") if complaint.satisfaction_set_at else None,
"satisfaction_locked_by_patient": complaint.satisfaction_locked_by_patient,
"satisfaction_locked_at": complaint.satisfaction_locked_at.strftime("%Y-%m-%d %H:%M") if complaint.satisfaction_locked_at else None,
})
@ -790,8 +798,21 @@ def public_set_satisfaction(request):
if obj.status not in ("resolved", "closed"):
return JsonResponse({"success": False, "error": "Satisfaction can only be set for resolved items."}, status=400)
# Refuse if patient has already locked satisfaction (complaints only).
if hasattr(obj, "satisfaction_locked_by_patient") and obj.satisfaction_locked_by_patient:
return JsonResponse({"success": False, "error": "Satisfaction has already been submitted and locked."}, status=400)
obj.satisfaction = satisfaction
obj.satisfaction_set_at = timezone.now()
obj.save(update_fields=["satisfaction", "satisfaction_set_at", "updated_at"])
update_fields = ["satisfaction", "satisfaction_set_at", "updated_at"]
# Lock satisfaction on complaints (patient submission is authoritative).
if hasattr(obj, "satisfaction_locked_by_patient"):
obj.satisfaction_locked_by_patient = True
obj.satisfaction_locked_at = timezone.now()
update_fields += ["satisfaction_locked_by_patient", "satisfaction_locked_at"]
obj.save(update_fields=update_fields)
return JsonResponse({"success": True, "satisfaction": obj.satisfaction})

View File

@ -26,7 +26,7 @@
<select name="hospital" onchange="this.form.submit()" class="text-sm border-gray-300 rounded-lg py-2 px-3 focus:ring-indigo-500 focus:border-indigo-500">
<option value="">{% trans "All Hospitals" %}</option>
{% for h in available_hospitals %}
<option value="{{ h.id }}" {% if selected_hospital and selected_hospital.id == h.id %}selected{% endif %}>{{ h.name }}</option>
<option value="{{ h.id }}" {% if selected_hospital and selected_hospital.id == h.id %}selected{% endif %}>{{ h.get_localized_name }}</option>
{% endfor %}
</select>
{% endif %}

View File

@ -84,7 +84,7 @@
<select name="hospital" onchange="this.form.submit()" class="w-full text-sm border-gray-300 rounded-lg py-2 px-3">
<option value="">{% trans "All Hospitals" %}</option>
{% for hospital in available_hospitals %}
<option value="{{ hospital.id }}" {% if current_filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>{{ hospital.name }}</option>
<option value="{{ hospital.id }}" {% if current_filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>{{ hospital.get_localized_name }}</option>
{% endfor %}
</select>
</div>

View File

@ -20,6 +20,7 @@ urlpatterns = [
path("<uuid:pk>/assign/", views.feedback_assign, name="feedback_assign"),
path("<uuid:pk>/change-status/", views.feedback_change_status, name="feedback_change_status"),
path("<uuid:pk>/send-to-department/", views.feedback_send_to_department, name="feedback_send_to_department"),
path("<uuid:pk>/send-to/", views.feedback_send_to, name="feedback_send_to"),
path("<uuid:pk>/add-response/", views.feedback_add_response, name="feedback_add_response"),
# Toggle actions
path("<uuid:pk>/toggle-featured/", views.feedback_toggle_featured, name="feedback_toggle_featured"),

View File

@ -250,6 +250,9 @@ def feedback_detail(request, pk):
"timeline": timeline,
"attachments": attachments,
"assignable_users": assignable_users,
"departments": Department.objects.filter(
hospital=feedback.hospital, status="active"
).order_by("name"),
"status_choices": FeedbackStatus.choices,
"can_edit": (
user.is_px_admin()
@ -258,6 +261,12 @@ def feedback_detail(request, pk):
or user.is_px_employee()
),
"can_admin": user.is_px_admin() or user.is_hospital_admin(),
"can_send_to_department": (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
),
"linked_rcas": linked_rcas,
"content_type_id": feedback_ct.pk,
"object_id": feedback.pk,
@ -310,7 +319,13 @@ def feedback_create(request):
try:
hospital = Hospital.objects.get(id=hospital_id)
# Auto-link patient by phone (local first, then HIS). Never blocks creation.
from apps.organizations.patient_lookup import find_or_link_patient
linked_patient = find_or_link_patient(phone=contact_phone, hospital=hospital)
feedback = Feedback(
patient=linked_patient,
hospital=hospital,
feedback_type=FeedbackType.SUGGESTION,
title=title,
@ -958,7 +973,13 @@ def public_suggestion_submit(request):
"other": FeedbackCategory.OTHER,
}
# Auto-link patient by phone (local first, then HIS). Never blocks creation.
from apps.organizations.patient_lookup import find_or_link_patient
linked_patient = find_or_link_patient(phone=contact_phone, hospital=hospital)
feedback = Feedback(
patient=linked_patient,
hospital=hospital,
feedback_type=FeedbackType.SUGGESTION,
title=title,
@ -1110,3 +1131,166 @@ def feedback_send_to_department(request, pk):
messages.success(request, f"Suggestion sent to {dept.name}.")
return redirect("feedback:feedback_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def feedback_send_to(request, pk):
"""
Unified AJAX endpoint to send feedback/suggestion to a department.
Awareness-only: no response required from the department.
"""
import logging
from apps.notifications.services import NotificationService
logger = logging.getLogger(__name__)
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
user = request.user
if not (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
):
return JsonResponse(
{"success": False, "error": "You don't have permission to send this feedback."},
status=403,
)
recipient_type = request.POST.get("recipient_type", "department")
note = request.POST.get("note", "").strip()
try:
if recipient_type == "person":
person_id = request.POST.get("person_id")
if not person_id:
return JsonResponse(
{"success": False, "error": "Please select a person."},
status=400,
)
try:
person = User.objects.get(id=person_id)
except User.DoesNotExist:
return JsonResponse(
{"success": False, "error": "User not found."},
status=400,
)
feedback.assigned_to = person
feedback.assigned_at = timezone.now()
feedback.save(update_fields=["assigned_to", "assigned_at"])
if person.email:
try:
NotificationService.send_email(
email=person.email,
subject=f"Feedback Assigned - {feedback.title or 'Untitled'}",
message=(
f"Dear {person.get_full_name()},\n\n"
f"You have been assigned to the following feedback:\n\n"
f"Title: {feedback.title or 'Untitled'}\n"
f"Type: {feedback.get_feedback_type_display()}\n"
f"Message: {feedback.message[:500]}\n\n"
f"{'Additional note: ' + note if note else ''}"
),
related_object=feedback,
user=person,
)
except Exception as e:
logger.warning(f"Failed to send feedback assignment email: {e}")
response_msg = f"Feedback sent to {person.get_full_name()}."
else: # department
department_id = request.POST.get("department_id")
if not department_id:
return JsonResponse(
{"success": False, "error": "Please select a department."},
status=400,
)
try:
department = Department.objects.select_related("champion", "manager").get(
pk=department_id, status="active"
)
except Department.DoesNotExist:
return JsonResponse(
{"success": False, "error": "Department not found."},
status=400,
)
# Auto-target the department's champion and manager (no contact-person picker)
from apps.organizations.department_contacts import get_champion_and_manager
targets = get_champion_and_manager(department)
if not targets:
return JsonResponse(
{
"success": False,
"error": f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned. Please assign one before sending.",
},
status=400,
)
# Link department to feedback (awareness-only, no response required)
feedback.department = department
feedback.save(update_fields=["department", "updated_at"])
# Notify champion + manager (email + SMS)
notified = []
link = f"https://{request.get_host()}/suggestions/{feedback.pk}/"
for target in targets:
display_name = (
target["staff"].get_full_name() if target.get("staff")
else (target["user"].get_full_name() if target.get("user") else target["label"])
)
if target.get("email"):
try:
NotificationService.send_email(
email=target["email"],
subject=f"Feedback Notification - {feedback.title or 'Untitled'}",
message=(
f"The following feedback has been logged for your department ({department.name}):\n\n"
f"Title: {feedback.title or 'Untitled'}\n"
f"Type: {feedback.get_feedback_type_display()}\n"
f"Message: {feedback.message[:500]}\n\n"
f"This is for your awareness. No response is required.\n\n"
f"{'Additional note: ' + note if note else ''}\n\n{link}"
),
related_object=feedback,
)
except Exception as e:
logger.warning(f"Failed to send feedback department notification: {e}")
if target.get("phone"):
try:
NotificationService.send_sms(
target["phone"],
f"PX360: Feedback '{feedback.title or 'Untitled'}' logged for {department.name}. {link}",
related_object=feedback,
)
except Exception:
pass
notified.append(f"{display_name} ({target['label']})")
response_msg = f"Feedback sent to {department.get_localized_name()}{', '.join(notified)}."
FeedbackResponse.objects.create(
feedback=feedback,
response_type="note",
message=response_msg,
created_by=user,
is_internal=False,
)
return JsonResponse({"success": True, "message": response_msg})
except Exception as e:
logger.error(f"Error in feedback_send_to: {str(e)}")
return JsonResponse(
{"success": False, "error": "An error occurred while sending the feedback."},
status=500,
)

View File

@ -15,6 +15,78 @@ from django.utils import timezone
logger = logging.getLogger("apps.integrations")
def _notify_his_fetch_failure(result):
"""Send email notification to PX Admins when HIS survey fetch fails."""
try:
from apps.accounts.models import User
from apps.notifications.services import NotificationService
px_admins = User.objects.filter(groups__name="PX Admin", is_active=True)
if not px_admins.exists():
logger.warning("No PX Admin users found to notify about HIS fetch failure")
return
error_list = "\n".join(f"{e}" for e in result.get("errors", []))
details = result.get("details", [])
failed_clients = [d for d in details if d.get("errors")]
client_summary = "\n".join(
f"{d['config']}: {'; '.join(d['errors'])}" for d in failed_clients
) if failed_clients else "See errors above."
timestamp = timezone.now().strftime("%Y-%m-%d %H:%M:%S")
subject = f"⚠️ HIS Survey Fetch Failed — PX360 ({timestamp})"
plain_text = (
f"HIS Survey Fetch Task — Failure Report\n"
f"Timestamp: {timestamp}\n\n"
f"Clients processed: {result.get('clients_processed', 0)}\n"
f"Total errors: {len(result.get('errors', []))}\n\n"
f"Errors:\n{error_list or 'None'}\n\n"
f"Failed clients:\n{client_summary}\n\n"
f"Action required: Check HIS API credentials, network connectivity, "
f"and IntegrationConfig status.\n"
)
html_message = f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
<div style="background: #dc2626; padding: 16px 24px;">
<h2 style="color: white; margin: 0; font-size: 18px;"> HIS Survey Fetch Failed</h2>
<p style="color: rgba(255,255,255,0.8); margin: 4px 0 0; font-size: 13px;">{timestamp}</p>
</div>
<div style="padding: 20px;">
<p style="margin: 0 0 12px;"><strong>Clients processed:</strong> {result.get('clients_processed', 0)}</p>
<p style="margin: 0 0 12px;"><strong>Total errors:</strong> {len(result.get('errors', []))}</p>
<p style="margin: 0 0 8px; font-weight: bold;">Errors:</p>
<div style="background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 12px; margin-bottom: 16px;">
<pre style="white-space: pre-wrap; font-size: 13px; color: #991b1b; margin: 0;">{error_list or 'None'}</pre>
</div>
<div style="background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 12px; margin-bottom: 16px;">
<p style="font-size: 13px; color: #92400e; margin: 0;">
<strong>Action required:</strong> Check HIS API credentials, network connectivity,
and IntegrationConfig status in the PX360 admin panel.
</p>
</div>
</div>
</div>
"""
for admin in px_admins:
try:
NotificationService.send_email(
email=admin.email,
subject=subject,
message=plain_text,
html_message=html_message,
user=admin,
notification_type="system",
)
except Exception:
logger.error(f"Failed to send HIS failure notification to {admin.email}")
except Exception as e:
logger.error(f"Failed to send HIS fetch failure notification: {e}")
# =============================================================================
# HIS Survey Fetching Tasks
# =============================================================================
@ -170,6 +242,9 @@ def fetch_his_surveys(from_date_str=None, to_date_str=None):
logger.error(error_msg, exc_info=True)
result["errors"].append(error_msg)
if result["errors"]:
_notify_his_fetch_failure(result)
return result

View File

@ -301,7 +301,7 @@ def notification_settings_update(request, hospital_id=None):
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return JsonResponse({"success": False, "error": "No hospital assigned"}, status=400)
messages.error(request, "No hospital assigned. Please contact your administrator.")
return redirect("analytics:command_center")
return redirect("dashboard:command-center")
settings = HospitalNotificationSettings.get_for_hospital(hospital.id)
@ -364,7 +364,7 @@ def update_quiet_hours(request, hospital_id=None):
hospital = _get_notification_hospital(request, hospital_id)
if not hospital:
messages.error(request, "No hospital assigned. Please contact your administrator.")
return redirect("analytics:command_center")
return redirect("dashboard:command-center")
settings = HospitalNotificationSettings.get_for_hospital(hospital.id)
@ -389,7 +389,7 @@ def test_notification(request, hospital_id=None):
hospital = _get_notification_hospital(request, hospital_id)
if not hospital:
messages.error(request, "No hospital assigned. Please contact your administrator.")
return redirect("analytics:command_center")
return redirect("dashboard:command-center")
settings = HospitalNotificationSettings.get_for_hospital(hospital.id)
channel = request.POST.get("channel", "email")

View File

@ -17,6 +17,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from apps.core.models import SoftDeleteModel, TimeStampedModel, UUIDModel
from apps.organizations.models import LocationType
@ -33,19 +34,19 @@ def generate_tracking_code():
class ObservationSeverity(models.TextChoices):
"""Observation severity choices."""
LOW = "low", "Low"
MEDIUM = "medium", "Medium"
HIGH = "high", "High"
CRITICAL = "critical", "Critical"
LOW = "low", _("Low")
MEDIUM = "medium", _("Medium")
HIGH = "high", _("High")
CRITICAL = "critical", _("Critical")
class ObservationStatus(models.TextChoices):
"""Observation status choices."""
OPEN = "open", "Open"
IN_PROGRESS = "in_progress", "In Progress"
RESOLVED = "resolved", "Resolved"
CLOSED = "closed", "Closed"
OPEN = "open", _("Open")
IN_PROGRESS = "in_progress", _("In Progress")
RESOLVED = "resolved", _("Resolved")
CLOSED = "closed", _("Closed")
VALID_OBSERVATION_TRANSITIONS = {

View File

@ -94,4 +94,5 @@ urlpatterns = [
# ==========================================================================
path("<uuid:pk>/delete/", views.observation_soft_delete, name="observation_soft_delete"),
path("<uuid:pk>/restore/", views.observation_restore, name="observation_restore"),
path("<uuid:pk>/pdf/", views.observation_pdf, name="observation_pdf"),
]

View File

@ -1518,24 +1518,15 @@ def observation_send_to(request, pk):
"error": str(_("Department not found.")),
}, status=400)
if not department.champion and not department.manager:
return JsonResponse({
"success": False,
"error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned.")),
}, status=400)
# Auto-target the department's champion and manager (no contact-person picker)
from apps.organizations.department_contacts import get_champion_and_manager
from apps.notifications.services import NotificationService
contact_person_id = request.POST.get("contact_person_id")
if not contact_person_id:
targets = get_champion_and_manager(department)
if not targets:
return JsonResponse({
"success": False,
"error": str(_("Please select a contact person.")),
}, status=400)
contact_info = department.is_valid_contact_person(contact_person_id)
if not contact_info:
return JsonResponse({
"success": False,
"error": str(_("Selected person is not a role holder in this department.")),
"error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned. Please assign one before sending.")),
}, status=400)
# Send to department
@ -1553,7 +1544,36 @@ def observation_send_to(request, pk):
observation.dept_response_second_reminder_sent_at = None
observation.dept_response_escalated_at = None
message = f"Observation sent to {department.get_localized_name()}{contact_info['name']} ({contact_info['role_label']})."
# Notify champion + manager (email + SMS) with the observation link
notified = []
link = f"https://{request.get_host()}/observations/{observation.pk}/"
for target in targets:
display_name = (
target["staff"].get_full_name() if target.get("staff")
else (target["user"].get_full_name() if target.get("user") else target["label"])
)
if target.get("email"):
try:
NotificationService.send_email(
email=target["email"],
subject=f"Observation Sent to Department - {observation.reference_number}",
message=f"Observation #{observation.reference_number} has been sent to your department ({department.name}).\n\n{link}",
related_object=observation,
)
except Exception:
pass
if target.get("phone"):
try:
NotificationService.send_sms(
target["phone"],
f"PX360: Observation #{observation.reference_number} sent to {department.name}. Review: {link}",
related_object=observation,
)
except Exception:
pass
notified.append(f"{display_name} ({target['label']})")
message = f"Observation sent to {department.get_localized_name()}{', '.join(notified)}."
old_status_before_send = observation.status
observation.contact_status = "contacted"
@ -1569,16 +1589,6 @@ def observation_send_to(request, pk):
is_internal=True,
)
# Send department notification if applicable
if recipient_type == "department":
try:
from apps.notifications.settings_service import NotificationServiceWithSettings
NotificationServiceWithSettings.send_observation_department_assigned(
department, observation, context_note_en=note, context_note_ar="",
)
except Exception as e:
logger.warning(f"Failed to send department notification: {e}")
return JsonResponse({
"success": True,
"message": message,
@ -2174,3 +2184,13 @@ Generate JSON with "summary_en" and "summary_ar"."""
return render(request, "observations/response_success_token.html", {"observation": observation})
return render(request, "observations/response_form_token.html", {"observation": observation})
@login_required
def observation_pdf(request, pk):
from apps.core.pdf_utils import generate_letterhead_pdf
obj = get_object_or_404(Observation, id=pk)
return generate_letterhead_pdf(
'observations/observation_pdf.html',
{'object': obj},
f'observation_{obj.tracking_code}.pdf',
)

View File

@ -0,0 +1,80 @@
"""
Department contact resolution helpers.
Used by the "Send to Department" flow across modules to auto-target a
department's champion and manager (instead of asking the user to pick a
contact person). Each target is returned with the contact channels available.
"""
import logging
logger = logging.getLogger(__name__)
def _staff_contact(staff):
"""Return (email, phone) for a Staff instance, with user fallbacks."""
email = staff.email or ""
phone = staff.phone or ""
user_id = getattr(staff, "user_id", None)
if user_id:
user = getattr(staff, "user", None)
if user:
email = email or (user.email or "")
phone = phone or (getattr(user, "phone", "") or "")
return email, phone
def get_champion_and_manager(department):
"""Return a list of contact targets for a department's champion and manager.
Each item is a dict:
{
"label": "Champion" | "Manager",
"email": str, # may be ""
"phone": str, # may be ""
"staff": <Staff>|None, # present for the champion
"user": <User>|None, # present for the manager
}
Only targets that actually exist (the role FK is set) are included.
De-duplicates the case where the champion and manager are the same person.
"""
targets = []
seen_emails = set()
# Champion (a Staff)
champion = getattr(department, "champion", None)
if champion is not None:
email, phone = _staff_contact(champion)
email = email or (getattr(department, "champion_email", "") or "")
key = (email or "").lower()
if key not in seen_emails:
seen_emails.add(key)
targets.append(
{"label": "Champion", "email": email, "phone": phone, "staff": champion, "user": None}
)
# Manager (a User)
manager = getattr(department, "manager", None)
if manager is not None:
email = manager.email or ""
phone = getattr(manager, "phone", "") or ""
# If the manager also has a linked Staff profile, prefer its contact details.
staff_profile = getattr(manager, "staff_profile", None)
if staff_profile:
s_email, s_phone = _staff_contact(staff_profile)
email = email or s_email
phone = phone or s_phone
key = (email or "").lower()
if key and key not in seen_emails:
seen_emails.add(key)
targets.append(
{"label": "Manager", "email": email, "phone": phone, "staff": None, "user": manager}
)
return targets
def has_contact_target(department):
"""True if the department has at least a champion or a manager to notify."""
return bool(get_champion_and_manager(department))

View File

@ -0,0 +1,175 @@
"""
Populate the `name_ar` (Arabic name) field on Department rows from a
hard-coded English -> Arabic mapping.
The mapping covers every department name present in the database. Keys are
normalized (case-insensitive, trailing "department" stripped, whitespace
collapsed) so that "Surgery", "Surgery Department" and "surgery department"
all resolve to the same Arabic value.
Arabic terms are cross-referenced with
apps/complaints/management/commands/arabic_dept_mapping.py to keep the
project's Arabic vocabulary consistent.
Usage:
python manage.py set_department_arabic_names # fill empties
python manage.py set_department_arabic_names --dry-run # preview only
python manage.py set_department_arabic_names --overwrite # re-translate all
python manage.py set_department_arabic_names --hospital-code HH-N
"""
import re
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.organizations.models import Department
ENGLISH_TO_ARABIC = {
"laboratory": "قسم المختبر",
"icu": "وحدة العناية المركزة",
"security": "قسم الأمن",
"maintenance": "قسم الصيانة",
"housekeeping": "قسم النظافة",
"medical reports": "قسم التقارير الطبية",
"reception": "الاستقبال",
"administration": "الإدارة",
"surgery": "قسم الجراحة",
"cardiology": "قسم القلب",
"orthopedics": "قسم جراحة العظام",
"orthopedic": "قسم جراحة العظام",
"nursing": "قسم التمريض",
"pharmacy": "قسم الصيدلية",
"pediatrics": "قسم الأطفال",
"pediatric": "قسم الأطفال",
"emergency": "قسم الطوارئ",
"anesthesia": "قسم التخدير",
"biomedical": "قسم الهندسة الطبية الحيوية",
"contact center": "قسم مركز الاتصال",
"critical care": "قسم العناية المركزة",
"dental": "قسم الأسنان",
"dermatology": "قسم الجلدية",
"emergency administrative": "إدارة الطوارئ",
"emergency medicine": "قسم طب الطوارئ",
"executive administration": "الإدارة التنفيذية",
"facility management & maintenance": "قسم إدارة المرافق والصيانة",
"financial collection & claims": "قسم المالية والتحصيل والمطالبات",
"food services": "قسم خدمات الطعام",
"hr": "قسم الموارد البشرية",
"housekeeping & hospitality": "قسم النظافة والضيافة",
"ivf": "قسم أطفال الأنابيب",
"infection control": "قسم مكافحة العدوى",
"information technology": "قسم تقنية المعلومات",
"inpatient": "قسم التنويم",
"internal medicine": "قسم الباطنية",
"laundry": "قسم الغسيل",
"medical administration": "الإدارة الطبية",
"medical ancillary services": "قسم الخدمات الطبية المساندة",
"medical approvals": "قسم الموافقات الطبية",
"medical records": "قسم السجلات الطبية",
"oncology": "قسم الأورام",
"obstetrics & gynecology": "قسم النساء والولادة",
"operating rooms": "قسم غرف العمليات",
"operating rooms (or)": "قسم غرف العمليات",
"ophthalmology": "قسم العيون",
"outpatient": "قسم العيادات الخارجية",
"patient affairs": "قسم علاقات المرضى",
"patient experience": "قسم تجربة المريض",
"radiology": "قسم الأشعة",
"support services": "قسم الخدمات المساندة",
}
_DEPT_SUFFIX_RE = re.compile(r"\s+department$", re.IGNORECASE)
_WS_RE = re.compile(r"\s+")
def _normalize_en(value: str) -> str:
"""Normalize an English department name for mapping lookup."""
if not value:
return ""
value = _DEPT_SUFFIX_RE.sub("", str(value).strip())
value = _WS_RE.sub(" ", value).strip()
return value.lower()
class Command(BaseCommand):
help = "Populate Department.name_ar from a hard-coded English -> Arabic mapping"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview changes without writing to the database",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Re-translate even departments that already have name_ar set",
)
parser.add_argument(
"--hospital-code",
type=str,
help="Limit to a single hospital code (default: all hospitals)",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
overwrite = options["overwrite"]
hospital_code = options["hospital_code"]
qs = Department.objects.select_related("hospital").order_by("hospital__code", "name_en", "name")
if hospital_code:
qs = qs.filter(hospital__code=hospital_code)
if not overwrite:
qs = qs.filter(name_ar="")
self.stdout.write(self.style.MIGRATE_HEADING(
f"Department Arabic names (hospital={hospital_code or 'ALL'}, "
f"overwrite={overwrite}, dry_run={dry_run})"
))
updated = 0
skipped = 0
unmapped = []
with transaction.atomic():
for dept in qs.iterator():
key = _normalize_en(dept.name_en) or _normalize_en(dept.name)
arabic = ENGLISH_TO_ARABIC.get(key)
if not arabic:
unmapped.append((dept, key))
skipped += 1
continue
if dept.name_ar == arabic:
skipped += 1
continue
label = f"[{dept.hospital.code if dept.hospital else '-'}] {dept.code}: " \
f"{dept.name_en or dept.name!r}"
if dry_run:
self.stdout.write(f" ~ {label} -> {arabic}")
else:
dept.name_ar = arabic
dept.save(update_fields=["name_ar"])
self.stdout.write(self.style.SUCCESS(f" + {label} -> {arabic}"))
updated += 1
self.stdout.write("")
self.stdout.write(self.style.SUCCESS(
f"Done: {updated} {'would be ' if dry_run else ''}updated, {skipped} skipped"
))
if unmapped:
self.stdout.write(self.style.WARNING(
f"\n{len(unmapped)} department(s) had no mapping (left unchanged):"
))
for dept, key in unmapped:
self.stdout.write(self.style.WARNING(
f" - [{dept.hospital.code if dept.hospital else '-'}] {dept.code}: "
f"{dept.name_en or dept.name!r} (key={key!r})"
))
self.stdout.write(self.style.WARNING(
"\nAdd the missing keys to ENGLISH_TO_ARABIC and re-run."
))

View File

@ -132,7 +132,7 @@ class Hospital(UUIDModel, TimeStampedModel):
verbose_name_plural = "Hospitals"
def __str__(self):
return self.get_display_name()
return self.get_localized_name()
def get_localized_name(self):
from django.utils.translation import get_language

View File

@ -0,0 +1,106 @@
"""
Patient auto-link service.
Given a national ID and/or phone number, try to find an existing Patient first
locally, then via HIS and return it so the calling record (complaint, inquiry,
suggestion) can link to it. Never creates a Patient from typed-only data; only
links an existing local patient or creates one from real HIS demographics.
This function never raises: on any failure (HIS unreachable, config missing,
unexpected error) it returns None, so record creation always proceeds with
patient = NULL rather than failing.
"""
import logging
logger = logging.getLogger(__name__)
def find_or_link_patient(national_id=None, phone=None, hospital=None):
"""Return a Patient that matches the given identifiers, or None.
Lookup order:
1. Local Patient table by national_id_hash (exact), then by phone (exact).
2. HIS if not found locally, query HIS by SSN/MobileNo; if a match is
returned, materialize it as a local Patient via HISAdapter and link it.
3. None if neither local nor HIS yields a match (or HIS is unavailable).
Args:
national_id (str|None): Patient national ID / Iqama.
phone (str|None): Patient phone number.
hospital: Optional Hospital instance (used when creating from HIS data).
Returns:
Patient instance or None.
"""
national_id = (national_id or "").strip()
phone = (phone or "").strip()
if not national_id and not phone:
return None
# 1. Local lookup
patient = _lookup_local(national_id, phone)
if patient is not None:
return patient
# 2. HIS lookup (only if integration is configured and reachable)
patient = _lookup_his(national_id, phone, hospital)
return patient # may be None
def _lookup_local(national_id, phone):
from apps.core.encryption import compute_national_id_hash
from apps.organizations.models import Patient
if national_id:
nid_hash = compute_national_id_hash(national_id)
if nid_hash:
patient = Patient.objects.filter(national_id_hash=nid_hash, status="active").first()
if patient:
return patient
if phone:
patient = Patient.objects.filter(phone=phone, status="active").first()
if patient:
return patient
return None
def _lookup_his(national_id, phone, hospital):
"""Query HIS and, on a hit, materialize a local Patient from the demographics."""
try:
from apps.integrations.models import IntegrationConfig
from apps.integrations.services.his_adapter import HISAdapter
from apps.integrations.services.his_client import HISClient
except Exception:
# Integrations app unavailable — not configured.
return None
config = IntegrationConfig.objects.filter(source_system__in=["his", "other"]).first()
if not config:
return None
try:
client = HISClient(config)
patients = client.fetch_patient_by_identifier(ssn=national_id or None, mobile_no=phone or None)
except Exception as e:
logger.info("HIS patient lookup failed (network/config): %s", e)
return None
if not patients:
return None
# Use the first HIS match to get-or-create a local Patient.
his_patient = patients[0]
try:
his_hospital = HISAdapter.get_or_create_hospital(his_patient)
target_hospital = hospital or his_hospital
if target_hospital is None:
return None
return HISAdapter.get_or_create_patient(his_patient, target_hospital)
except Exception as e:
logger.warning("HIS patient materialization failed: %s", e)
return None

View File

@ -2036,6 +2036,32 @@ def department_detail(request, pk):
).select_related("complaint").order_by("-forwarded_at")
for pc in pending_complaint_dept_responses:
is_rejected = pc.acceptance_status == "not_acceptable"
needs_resubmit = is_rejected or pc.manager_review_status == "rejected"
explanation_url = "#"
explanation = ComplaintExplanation.objects.filter(
complaint_id=pc.complaint_id,
staff__department=department,
).first()
if not explanation and department.champion:
import secrets as _secrets
explanation = ComplaintExplanation.objects.create(
complaint=pc.complaint,
staff=department.champion,
token=_secrets.token_urlsafe(32),
explanation="",
is_used=False,
)
if explanation:
if explanation.is_used and needs_resubmit:
import secrets
explanation.is_used = False
explanation.token = secrets.token_urlsafe(32)
explanation.save(update_fields=["is_used", "token"])
if not explanation.is_used:
explanation_url = reverse(
"complaints:complaint_explanation_form",
kwargs={"complaint_id": pc.complaint_id, "token": explanation.token},
)
pending_actions.append({
"type": "complaint_department_response",
"type_label": _("Complaint Response (Re-submit)") if is_rejected else _("Complaint Response"),
@ -2044,6 +2070,7 @@ def department_detail(request, pk):
"sla_due_at": None,
"is_overdue": False,
"url": "#",
"explanation_url": explanation_url,
"badge_color": "red" if is_rejected else "orange",
"item_id": str(pc.pk),
"item_type": "complaint_involved_department",
@ -2560,6 +2587,40 @@ def department_complaint_detail(request, pk, cpk):
complaint_ct = ContentType.objects.get_for_model(Complaint)
notes = complaint.notes.select_related("created_by").order_by("-created_at")
explanation_url = None
if can_respond and involved_dept:
from apps.complaints.models import ComplaintExplanation
explanation = ComplaintExplanation.objects.filter(
complaint=complaint,
staff__department=department,
).first()
if not explanation and department.champion:
import secrets as _secrets
explanation = ComplaintExplanation.objects.create(
complaint=complaint,
staff=department.champion,
token=_secrets.token_urlsafe(32),
explanation="",
is_used=False,
)
if explanation:
needs_resubmit = (
involved_dept.acceptance_status == "not_acceptable"
or involved_dept.manager_review_status == "rejected"
)
if explanation.is_used and needs_resubmit:
import secrets
explanation.is_used = False
explanation.token = secrets.token_urlsafe(32)
explanation.save(update_fields=["is_used", "token"])
if not explanation.is_used:
explanation_url = reverse(
"complaints:complaint_explanation_form",
kwargs={"complaint_id": complaint.id, "token": explanation.token},
)
from apps.complaints.workflow_log import build_workflow_log
context = {
"department": department,
"complaint": complaint,
@ -2569,9 +2630,11 @@ def department_complaint_detail(request, pk, cpk):
"can_respond": can_respond,
"can_manager_review": can_manager_review,
"involved_dept": involved_dept,
"explanation_url": explanation_url,
"notes": notes,
"content_type_id": complaint_ct.pk,
"object_id": complaint.pk,
"workflow_log": build_workflow_log(complaint),
}
return render(request, "organizations/department_complaint_detail.html", context)
@ -3973,6 +4036,19 @@ def department_manager_review(request, pk, idept_pk):
return redirect("organizations:department_detail", pk=department.pk)
complaint = involved_dept.complaint
# Ensure default manager review questions exist for this hospital
defaults = [
("Is there negligence resulting from carelessness?", "هل يوجد تقصير ناتج عن إهمال؟", "yes_no", 1),
("Is it resulting from policies or regulations?", "هل هو ناتج عن سياسات أو تنظيمات؟", "yes_no", 2),
("Radical action taken for this issue", "الإجراء المتخذ لهذا الموضوع جذرياً", "textarea", 3),
]
for text_en, text_ar, qtype, order in defaults:
ManagerReviewQuestion.objects.get_or_create(
hospital=department.hospital, text_en=text_en,
defaults={"text_ar": text_ar, "question_type": qtype, "order": order, "is_active": True},
)
questions = ManagerReviewQuestion.objects.filter(
hospital=department.hospital, is_active=True,
).order_by("order", "created_at")
@ -4140,11 +4216,36 @@ def department_manager_review(request, pk, idept_pk):
return redirect("organizations:department_detail", pk=department.pk)
# Fetch investigation Q&A if one exists for this involved department
investigation = involved_dept.investigations.prefetch_related(
"questions", "responses__staff", "responses__answers__question"
).first()
investigation_staff_data = []
if investigation:
inv_questions = list(investigation.questions.all().order_by("order"))
for resp in investigation.responses.all():
qa_pairs = []
for q in inv_questions:
answer_text = ""
for ans in resp.answers.all():
if ans.question_id == q.id:
answer_text = ans.answer_text
break
qa_pairs.append({"question": q, "answer": answer_text})
investigation_staff_data.append({
"staff": resp.staff,
"is_completed": resp.is_completed,
"completed_at": resp.completed_at,
"qa_pairs": qa_pairs,
})
context = {
"department": department,
"involved_dept": involved_dept,
"complaint": complaint,
"questions": questions,
"investigation": investigation,
"investigation_staff_data": investigation_staff_data,
}
return render(request, "organizations/department_manager_review.html", context)

View File

@ -13,6 +13,7 @@ from .views import (
StaffViewSet,
api_areas_by_hospital,
api_department_contacts,
api_department_staff,
api_departments_by_category,
api_main_section_list,
api_sections_by_department,
@ -147,6 +148,7 @@ urlpatterns = [
path("dropdowns/areas/", api_areas_by_hospital, name="api_areas_by_hospital"),
path("dropdowns/staff-by-department/<uuid:department_id>/", api_staff_by_department, name="api_staff_by_department"),
path("dropdowns/department-contacts/<uuid:department_id>/", api_department_contacts, name="api_department_contacts"),
path("dropdowns/department-staff/<uuid:department_id>/", api_department_staff, name="api_department_staff"),
# Staff Hierarchy API (for D3 visualization)
path("api/staff/hierarchy/", api_staff_hierarchy, name="api_staff_hierarchy"),
path(

View File

@ -905,6 +905,54 @@ def api_department_contacts(request, department_id):
return JsonResponse(serializable_holders, safe=False)
@api_view(["GET"])
@permission_classes([])
def api_department_staff(request, department_id):
"""Return all active Staff in the given department.
Broader than `api_department_contacts` (which only returns the 6 role-holder
slots). For each staff member, `role_label` is the role-holder slot label if
they occupy one, otherwise their `job_title` (falling back to "Member").
Output shape matches `api_department_contacts` so the same client-side code
can consume either endpoint.
"""
dept = Department.objects.filter(pk=department_id, status="active").first()
if not dept:
return JsonResponse({"error": "Department not found"}, status=404)
role_field_by_staff_pk = {}
for field_name, role_label in dept.ROLE_FIELDS:
holder = getattr(dept, field_name, None)
if holder is not None:
role_field_by_staff_pk[str(holder.pk)] = (field_name, role_label)
staff_qs = (
Staff.objects.select_related("user")
.filter(department=dept, hospital_id=dept.hospital_id, user__is_active=True)
.order_by("first_name", "last_name")
)
result = []
for staff in staff_qs:
role_field, role_label = role_field_by_staff_pk.get(str(staff.pk), (None, staff.job_title or "Member"))
result.append(
{
"staff": {
"id": str(staff.id),
"name": staff.get_full_name(),
"email": staff.email,
"employee_id": staff.employee_id,
},
"staff_id": str(staff.id),
"name": staff.get_full_name(),
"email": staff.email or (staff.user.email if staff.user else None),
"role_field": role_field,
"role_label": role_label,
}
)
return JsonResponse(result, safe=False)
@api_view(["GET"])
@permission_classes([])
def ajax_departments(request):
@ -1163,7 +1211,7 @@ def api_staff_search(request):
URL: /api/staffs/search/?q=<query>&active=true
"""
q = request.query_params.get("q", "").strip()
queryset = Staff.objects.filter(status="active")
queryset = Staff.objects.select_related("department", "user").filter(status="active")
# Filter by user's hospital if not admin
user = request.user
@ -1180,7 +1228,49 @@ def api_staff_search(request):
)
queryset = queryset[:20]
data = [
{"id": s.id, "first_name": s.first_name, "last_name": s.last_name, "employee_id": s.employee_id}
{
"id": s.id,
"first_name": s.first_name,
"last_name": s.last_name,
"employee_id": s.employee_id,
"department": {"id": s.department_id, "name": s.department.name if s.department else None},
"has_contact": bool(
s.email
or s.phone
or (s.user_id and s.user and (s.user.email or getattr(s.user, "phone", "")))
),
}
for s in queryset
]
return Response(data)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def api_staff_for_user_create(request, staff_id):
"""
Return detailed staff info for pre-filling the user creation form.
Only returns staff without an existing user account.
"""
try:
staff = Staff.objects.select_related("hospital", "department").get(
pk=staff_id, user__isnull=True, status="active"
)
except Staff.DoesNotExist:
return Response({"error": "Staff not found or already has a user account."}, status=404)
data = {
"id": str(staff.id),
"first_name": staff.first_name,
"last_name": staff.last_name,
"email": staff.email or "",
"phone": staff.phone or "",
"employee_id": staff.employee_id or "",
"hospital": str(staff.hospital_id) if staff.hospital else "",
"hospital_name": staff.hospital.name if staff.hospital else "",
"department": str(staff.department_id) if staff.department else "",
"department_name": staff.department.name if staff.department else "",
"job_title": staff.job_title or "",
"staff_type": staff.staff_type or "",
}
return Response(data)

View File

@ -103,7 +103,7 @@ def my_tasks(request):
"title": "Submit your explanation",
"description": c.title or "",
"reference": c.reference_number,
"url": f"/complaints/explanation/{e.token}/" if e.token else reverse("complaints:complaint_detail", kwargs={"pk": c.id}),
"url": reverse("complaints:complaint_explanation_form", kwargs={"complaint_id": c.id, "token": e.token}) if e.token else reverse("complaints:complaint_detail", kwargs={"pk": c.id}),
"due_date": None,
"is_overdue": False,
})
@ -158,6 +158,36 @@ def my_tasks(request):
).select_related("complaint", "department")
for d in pending_responses:
c = d.complaint
explanation_url = None
from apps.complaints.models import ComplaintExplanation
explanation = ComplaintExplanation.objects.filter(
complaint_id=c.id,
staff__department=d.department,
).first()
if not explanation and d.department.champion:
import secrets as _secrets
explanation = ComplaintExplanation.objects.create(
complaint=c,
staff=d.department.champion,
token=_secrets.token_urlsafe(32),
explanation="",
is_used=False,
)
if explanation:
needs_resubmit = (
d.acceptance_status == "not_acceptable"
or d.manager_review_status == "rejected"
)
if explanation.is_used and needs_resubmit:
import secrets
explanation.is_used = False
explanation.token = secrets.token_urlsafe(32)
explanation.save(update_fields=["is_used", "token"])
if not explanation.is_used:
explanation_url = reverse(
"complaints:complaint_explanation_form",
kwargs={"complaint_id": c.id, "token": explanation.token},
)
tasks.append({
"type": "dept_response",
"type_label": "Dept Response",
@ -174,6 +204,7 @@ def my_tasks(request):
"modal_url": reverse("complaints:involved_department_response", kwargs={"pk": d.pk}),
"modal_ref": c.reference_number,
"modal_subject": d.department.name,
"explanation_url": explanation_url,
})
# 5. Complaint response approval (department manager needs to approve)

View File

@ -18,6 +18,11 @@ class DataSource(models.TextChoices):
PX_ACTIONS = 'px_actions', 'PX Actions'
SURVEYS = 'surveys', 'Surveys'
PHYSICIANS = 'physicians', 'Physician Ratings'
STAFF = 'staff', 'Staff'
DEPARTMENT = 'department', 'Departments'
SUGGESTION = 'suggestion', 'Suggestions'
APPRECIATION = 'appreciation', 'Appreciations'
PATIENT = 'patient', 'Patients'
class ReportFrequency(models.TextChoices):

View File

@ -133,6 +133,74 @@ class ReportBuilderService:
"neutral_count": {"label": "Neutral", "field": "neutral_count", "type": "number"},
"negative_count": {"label": "Negative", "field": "negative_count", "type": "number"},
},
"staff": {
"id": {"label": "ID", "field": "id", "type": "string"},
"employee_id": {"label": "Employee ID", "field": "employee_id", "type": "string"},
"first_name": {"label": "First Name", "field": "first_name", "type": "string"},
"last_name": {"label": "Last Name", "field": "last_name", "type": "string"},
"name": {"label": "Full Name", "field": "get_full_name", "type": "string"},
"staff_type": {"label": "Staff Type", "field": "staff_type", "type": "choice"},
"job_title": {"label": "Job Title", "field": "job_title", "type": "string"},
"department_type": {"label": "Department Category", "field": "department_type", "type": "choice"},
"email": {"label": "Email", "field": "email", "type": "string"},
"phone": {"label": "Phone", "field": "phone", "type": "string"},
"hospital": {"label": "Hospital", "field": "hospital__name", "type": "string"},
"department": {"label": "Department", "field": "department__name", "type": "string"},
"status": {"label": "Status", "field": "status", "type": "choice"},
"created_at": {"label": "Created Date", "field": "created_at", "type": "datetime"},
},
"department": {
"id": {"label": "ID", "field": "id", "type": "string"},
"name": {"label": "Name", "field": "name", "type": "string"},
"name_en": {"label": "Name (English)", "field": "name_en", "type": "string"},
"code": {"label": "Code", "field": "code", "type": "string"},
"category": {"label": "Category", "field": "category", "type": "choice"},
"hospital": {"label": "Hospital", "field": "hospital__name", "type": "string"},
"manager": {"label": "Manager", "field": "manager__get_full_name", "type": "string"},
"status": {"label": "Status", "field": "status", "type": "choice"},
"created_at": {"label": "Created Date", "field": "created_at", "type": "datetime"},
},
"suggestion": {
"id": {"label": "ID", "field": "id", "type": "string"},
"reference_number": {"label": "Reference Number", "field": "reference_number", "type": "string"},
"title": {"label": "Title", "field": "title", "type": "string"},
"message": {"label": "Message", "field": "message", "type": "text"},
"status": {"label": "Status", "field": "status", "type": "choice"},
"category": {"label": "Category", "field": "category", "type": "choice"},
"sentiment": {"label": "Sentiment", "field": "sentiment", "type": "choice"},
"hospital": {"label": "Hospital", "field": "hospital__name", "type": "string"},
"department": {"label": "Department", "field": "department__name", "type": "string"},
"patient_name": {"label": "Patient Name", "field": "patient__first_name", "type": "string"},
"is_anonymous": {"label": "Is Anonymous", "field": "is_anonymous", "type": "boolean"},
"contact_name": {"label": "Contact Name", "field": "contact_name", "type": "string"},
"created_at": {"label": "Created Date", "field": "created_at", "type": "datetime"},
},
"appreciation": {
"id": {"label": "ID", "field": "id", "type": "string"},
"reference_number": {"label": "Reference Number", "field": "reference_number", "type": "string"},
"category": {"label": "Category", "field": "category__name_en", "type": "string"},
"status": {"label": "Status", "field": "status", "type": "choice"},
"visibility": {"label": "Visibility", "field": "visibility", "type": "choice"},
"sender": {"label": "Sender", "field": "sender__get_full_name", "type": "string"},
"hospital": {"label": "Hospital", "field": "hospital__name", "type": "string"},
"department": {"label": "Department", "field": "department__name", "type": "string"},
"created_at": {"label": "Created Date", "field": "created_at", "type": "datetime"},
"activated_at": {"label": "Activated Date", "field": "activated_at", "type": "datetime"},
},
"patient": {
"id": {"label": "ID", "field": "id", "type": "string"},
"mrn": {"label": "MRN", "field": "mrn", "type": "string"},
"first_name": {"label": "First Name", "field": "first_name", "type": "string"},
"last_name": {"label": "Last Name", "field": "last_name", "type": "string"},
"name": {"label": "Full Name", "field": "get_full_name", "type": "string"},
"gender": {"label": "Gender", "field": "gender", "type": "choice"},
"national_id": {"label": "National ID", "field": "national_id", "type": "string"},
"phone": {"label": "Phone", "field": "phone", "type": "string"},
"email": {"label": "Email", "field": "email", "type": "string"},
"primary_hospital": {"label": "Primary Hospital", "field": "primary_hospital__name", "type": "string"},
"status": {"label": "Status", "field": "status", "type": "choice"},
"created_at": {"label": "Created Date", "field": "created_at", "type": "datetime"},
},
}
# Filter options for each data source
@ -187,6 +255,39 @@ class ReportBuilderService:
{"name": "hospital", "label": "Hospital", "type": "select"},
{"name": "department", "label": "Department", "type": "select"},
],
"staff": [
{"name": "date_range", "label": "Date Range", "type": "daterange"},
{"name": "staff_type", "label": "Staff Type", "type": "multiselect"},
{"name": "status", "label": "Status", "type": "multiselect"},
{"name": "hospital", "label": "Hospital", "type": "select"},
{"name": "department", "label": "Department", "type": "select"},
],
"department": [
{"name": "category", "label": "Category", "type": "multiselect"},
{"name": "status", "label": "Status", "type": "multiselect"},
{"name": "hospital", "label": "Hospital", "type": "select"},
],
"suggestion": [
{"name": "date_range", "label": "Date Range", "type": "daterange"},
{"name": "status", "label": "Status", "type": "multiselect"},
{"name": "category", "label": "Category", "type": "multiselect"},
{"name": "sentiment", "label": "Sentiment", "type": "multiselect"},
{"name": "hospital", "label": "Hospital", "type": "select"},
{"name": "department", "label": "Department", "type": "select"},
],
"appreciation": [
{"name": "date_range", "label": "Date Range", "type": "daterange"},
{"name": "status", "label": "Status", "type": "multiselect"},
{"name": "visibility", "label": "Visibility", "type": "multiselect"},
{"name": "hospital", "label": "Hospital", "type": "select"},
{"name": "department", "label": "Department", "type": "select"},
],
"patient": [
{"name": "date_range", "label": "Date Range", "type": "daterange"},
{"name": "gender", "label": "Gender", "type": "multiselect"},
{"name": "status", "label": "Status", "type": "multiselect"},
{"name": "hospital", "label": "Hospital", "type": "select"},
],
}
@classmethod
@ -197,6 +298,9 @@ class ReportBuilderService:
from apps.px_action_center.models import PXAction
from apps.surveys.models import SurveyInstance
from apps.physicians.models import PhysicianMonthlyRating
from apps.organizations.models import Staff, Department, Patient
from apps.feedback.models import Feedback, FeedbackType
from apps.appreciation.models import Appreciation
querysets = {
"complaints": Complaint.objects.all(),
@ -205,6 +309,11 @@ class ReportBuilderService:
"px_actions": PXAction.objects.all(),
"surveys": SurveyInstance.objects.all(),
"physicians": PhysicianMonthlyRating.objects.all(),
"staff": Staff.objects.all(),
"department": Department.objects.all(),
"suggestion": Feedback.objects.filter(feedback_type=FeedbackType.SUGGESTION),
"appreciation": Appreciation.objects.all(),
"patient": Patient.objects.all(),
}
queryset = querysets.get(data_source)
@ -219,6 +328,8 @@ class ReportBuilderService:
queryset = queryset.filter(assigned_department__hospital=hospital)
elif data_source == "surveys":
queryset = queryset.filter(journey__hospital=hospital)
elif data_source == "patient":
queryset = queryset.filter(primary_hospital=hospital)
else:
queryset = queryset.filter(hospital=hospital)
@ -252,6 +363,9 @@ class ReportBuilderService:
# Hospital filter
if "hospital" in filters and filters["hospital"]:
if data_source == "patient":
queryset = queryset.filter(primary_hospital_id=filters["hospital"])
else:
queryset = queryset.filter(hospital_id=filters["hospital"])
# Department filter
@ -260,6 +374,8 @@ class ReportBuilderService:
queryset = queryset.filter(assigned_department_id=filters["department"])
elif data_source == "surveys":
queryset = queryset.filter(journey__department_id=filters["department"])
elif data_source in ("department", "patient"):
pass
else:
queryset = queryset.filter(department_id=filters["department"])
@ -320,6 +436,47 @@ class ReportBuilderService:
if "patient_type" in filters and filters["patient_type"]:
queryset = queryset.filter(journey__patient_type=filters["patient_type"])
# Staff type filter (for staff source)
if "staff_type" in filters and filters["staff_type"]:
if isinstance(filters["staff_type"], list):
queryset = queryset.filter(staff_type__in=filters["staff_type"])
else:
queryset = queryset.filter(staff_type=filters["staff_type"])
# Category filter (for department, suggestion sources)
if "category" in filters and filters["category"]:
if data_source == "department":
if isinstance(filters["category"], list):
queryset = queryset.filter(category__in=filters["category"])
else:
queryset = queryset.filter(category=filters["category"])
elif data_source == "suggestion":
if isinstance(filters["category"], list):
queryset = queryset.filter(category__in=filters["category"])
else:
queryset = queryset.filter(category=filters["category"])
# Sentiment filter (for suggestion source)
if "sentiment" in filters and filters["sentiment"]:
if isinstance(filters["sentiment"], list):
queryset = queryset.filter(sentiment__in=filters["sentiment"])
else:
queryset = queryset.filter(sentiment=filters["sentiment"])
# Visibility filter (for appreciation source)
if "visibility" in filters and filters["visibility"]:
if isinstance(filters["visibility"], list):
queryset = queryset.filter(visibility__in=filters["visibility"])
else:
queryset = queryset.filter(visibility=filters["visibility"])
# Gender filter (for patient source)
if "gender" in filters and filters["gender"]:
if isinstance(filters["gender"], list):
queryset = queryset.filter(gender__in=filters["gender"])
else:
queryset = queryset.filter(gender=filters["gender"])
return queryset
@classmethod
@ -508,6 +665,36 @@ class ReportBuilderService:
summary["new_count"] = queryset.filter(status="new").count()
summary["resolved_count"] = queryset.filter(status="resolved").count()
elif data_source == "staff":
summary["active_count"] = queryset.filter(status="active").count()
summary["by_staff_type"] = list(
queryset.values("staff_type").annotate(count=Count("id")).order_by("-count")
)
elif data_source == "department":
summary["active_count"] = queryset.filter(status="active").count()
summary["by_category"] = list(
queryset.values("category").annotate(count=Count("id")).order_by("-count")
)
elif data_source == "suggestion":
summary["open_count"] = queryset.exclude(status="closed").count()
summary["closed_count"] = queryset.filter(status="closed").count()
summary["by_sentiment"] = list(
queryset.exclude(sentiment="").values("sentiment").annotate(count=Count("id")).order_by("-count")
)
elif data_source == "appreciation":
summary["sent_count"] = queryset.filter(status="sent").count()
summary["acknowledged_count"] = queryset.filter(status="acknowledged").count()
summary["draft_count"] = queryset.filter(status="draft").count()
elif data_source == "patient":
summary["active_count"] = queryset.filter(status="active").count()
summary["by_gender"] = list(
queryset.exclude(gender="").values("gender").annotate(count=Count("id")).order_by("-count")
)
return summary

View File

@ -16,6 +16,7 @@ from django.utils import timezone
from django.core.paginator import Paginator
from apps.organizations.models import Department, Hospital
from apps.core.models import StatusChoices
from .models import SavedReport, GeneratedReport, ReportTemplate, DataSource, ReportFormat
from .services import ReportBuilderService, ReportExportService
@ -403,6 +404,35 @@ def filter_options_api(request):
elif data_source == "physicians":
options["journey_type"] = ["inpatient", "outpatient", "emergency"]
elif data_source == "staff":
from apps.organizations.models import Staff
options["staff_type"] = [choice[0] for choice in Staff.StaffType.choices]
options["status"] = [choice[0] for choice in StatusChoices.choices]
elif data_source == "department":
from apps.organizations.models import DepartmentCategory
options["category"] = [choice[0] for choice in DepartmentCategory.choices]
options["status"] = [choice[0] for choice in StatusChoices.choices]
elif data_source == "suggestion":
from apps.feedback.models import FeedbackStatus, FeedbackCategory, SentimentChoices
options["status"] = [choice[0] for choice in FeedbackStatus.choices]
options["category"] = [choice[0] for choice in FeedbackCategory.choices]
options["sentiment"] = [choice[0] for choice in SentimentChoices.choices]
elif data_source == "appreciation":
from apps.appreciation.models import AppreciationStatus, AppreciationVisibility
options["status"] = [choice[0] for choice in AppreciationStatus.choices]
options["visibility"] = [choice[0] for choice in AppreciationVisibility.choices]
elif data_source == "patient":
options["gender"] = ["male", "female", "other"]
options["status"] = [choice[0] for choice in StatusChoices.choices]
# Hospital options
hospitals = Hospital.objects.filter(status="active")
if not request.user.is_px_admin() and request.user.hospital:

0
backups/.gitkeep Normal file
View File

Binary file not shown.

View File

@ -127,11 +127,11 @@ app.conf.beat_schedule = {
"task": "apps.analytics.tasks.calculate_daily_kpis",
"schedule": crontab(hour=1, minute=0),
},
# Fetch doctor ratings from HIS on the 1st of each month at 1 AM (before aggregation)
"fetch-his-doctor-ratings-monthly": {
"task": "apps.physicians.tasks.fetch_his_doctor_ratings_monthly",
"schedule": crontab(hour=1, minute=0, day_of_month=1),
},
# # Fetch doctor ratings from HIS on the 1st of each month at 1 AM (before aggregation)
# "fetch-his-doctor-ratings-monthly": {
# "task": "apps.physicians.tasks.fetch_his_doctor_ratings_monthly",
# "schedule": crontab(hour=1, minute=0, day_of_month=1),
# },
# Fetch doctor ratings from HIS daily at 1:30 AM (yesterday's ratings)
"fetch-his-doctor-ratings-daily": {
"task": "apps.physicians.tasks.fetch_his_doctor_ratings_daily",
@ -184,15 +184,15 @@ app.conf.beat_schedule = {
"schedule": crontab(minute="*/15"),
},
# Schedule observation monthly follow-ups daily at 6 AM
"schedule-observation-monthly-followups": {
"task": "apps.observations.tasks.schedule_monthly_followups",
"schedule": crontab(hour=6, minute=0),
},
# Process observation monthly follow-ups daily at 7 AM
"process-observation-monthly-followups": {
"task": "apps.observations.tasks.process_monthly_followups",
"schedule": crontab(hour=7, minute=0),
},
# "schedule-observation-monthly-followups": {
# "task": "apps.observations.tasks.schedule_monthly_followups",
# "schedule": crontab(hour=6, minute=0),
# },
# # Process observation monthly follow-ups daily at 7 AM
# "process-observation-monthly-followups": {
# "task": "apps.observations.tasks.process_monthly_followups",
# "schedule": crontab(hour=7, minute=0),
# },
# Analyze feedback sentiment every 30 minutes
"analyze-feedback-sentiment": {
"task": "apps.feedback.tasks.process_pending_sentiment_analysis",
@ -214,56 +214,56 @@ app.conf.beat_schedule = {
"schedule": crontab(minute=0), # Every hour
},
# Generate AI executive summary daily at 6 AM (kept for backward compat)
"generate-daily-executive-summary": {
"task": "apps.analytics.tasks.generate_executive_summary_task",
"schedule": crontab(hour=6, minute=0),
},
# Generate AI action recommendations daily at 6:30 AM (kept for backward compat)
"generate-daily-action-recommendations": {
"task": "apps.analytics.tasks.generate_action_recommendations_task",
"schedule": crontab(hour=6, minute=30),
},
# Send weekly PX digest email every Monday at 8 AM
"send-weekly-px-digest": {
"task": "apps.analytics.tasks_digest.send_weekly_digest_task",
"schedule": crontab(hour=8, minute=0, day_of_week=1), # Monday
},
# Send monthly PX digest email on 1st of each month at 8 AM
"send-monthly-px-digest": {
"task": "apps.analytics.tasks_digest.send_monthly_digest_task",
"schedule": crontab(hour=8, minute=0, day_of_month=1),
},
# "generate-daily-executive-summary": {
# "task": "apps.analytics.tasks.generate_executive_summary_task",
# "schedule": crontab(hour=6, minute=0),
# },
# # Generate AI action recommendations daily at 6:30 AM (kept for backward compat)
# "generate-daily-action-recommendations": {
# "task": "apps.analytics.tasks.generate_action_recommendations_task",
# "schedule": crontab(hour=6, minute=30),
# },
# # Send weekly PX digest email every Monday at 8 AM
# "send-weekly-px-digest": {
# "task": "apps.analytics.tasks_digest.send_weekly_digest_task",
# "schedule": crontab(hour=8, minute=0, day_of_week=1), # Monday
# },
# # Send monthly PX digest email on 1st of each month at 8 AM
# "send-monthly-px-digest": {
# "task": "apps.analytics.tasks_digest.send_monthly_digest_task",
# "schedule": crontab(hour=8, minute=0, day_of_month=1),
# },
# Executive Summary Tasks
# Calculate daily metrics at 1 AM
"calculate-daily-executive-metrics": {
"task": "apps.executive_summary.tasks.calculate_daily_metrics",
"schedule": crontab(hour=1, minute=0),
},
# Generate predictive insights every 6 hours
"generate-predictive-insights": {
"task": "apps.executive_summary.tasks.generate_predictive_insights",
"schedule": crontab(hour="*/6"), # Every 6 hours
},
# Generate AI recommendations daily at 3 AM
"generate-ai-recommendations": {
"task": "apps.executive_summary.tasks.generate_ai_recommendations",
"schedule": crontab(hour=3, minute=0),
},
# "calculate-daily-executive-metrics": {
# "task": "apps.executive_summary.tasks.calculate_daily_metrics",
# "schedule": crontab(hour=1, minute=0),
# },
# # Generate predictive insights every 6 hours
# "generate-predictive-insights": {
# "task": "apps.executive_summary.tasks.generate_predictive_insights",
# "schedule": crontab(hour="*/6"), # Every 6 hours
# },
# # Generate AI recommendations daily at 3 AM
# "generate-ai-recommendations": {
# "task": "apps.executive_summary.tasks.generate_ai_recommendations",
# "schedule": crontab(hour=3, minute=0),
# },
# Generate weekly executive summary every Monday at 6 AM
"generate-weekly-executive-summary": {
"task": "apps.executive_summary.tasks.generate_weekly_executive_summary",
"schedule": crontab(hour=6, minute=0, day_of_week=1), # Monday
},
# "generate-weekly-executive-summary": {
# "task": "apps.executive_summary.tasks.generate_weekly_executive_summary",
# "schedule": crontab(hour=6, minute=0, day_of_week=1), # Monday
# },
# Generate monthly executive summary on 1st at 7 AM
"generate-monthly-executive-summary": {
"task": "apps.executive_summary.tasks.generate_monthly_executive_summary",
"schedule": crontab(hour=7, minute=0, day_of_month=1),
},
# "generate-monthly-executive-summary": {
# "task": "apps.executive_summary.tasks.generate_monthly_executive_summary",
# "schedule": crontab(hour=7, minute=0, day_of_month=1),
# },
# Send executive PDF reports every Monday at 9 AM
"send-executive-pdf-report": {
"task": "apps.executive_summary.tasks.send_executive_pdf_report",
"schedule": crontab(hour=9, minute=0, day_of_week=1), # Monday
},
# "send-executive-pdf-report": {
# "task": "apps.executive_summary.tasks.send_executive_pdf_report",
# "schedule": crontab(hour=9, minute=0, day_of_week=1), # Monday
# },
}

View File

@ -45,6 +45,7 @@ THIRD_PARTY_APPS = [
"django_filters",
"drf_spectacular",
"django_celery_beat",
"django_ai_po",
]
LOCAL_APPS = [
@ -185,6 +186,21 @@ LOCALE_PATHS = [
BASE_DIR / "locale",
]
# AI-powered translation configuration
DJANGO_AI_PO = {
"MODEL": "openrouter/deepseek/deepseek-v4-flash",
"API_KEY": env("OPENROUTER_API_KEY"),
"TEMPERATURE": 0.2,
"BATCH_SIZE": 5,
"WORKERS": 2,
"LANGUAGES": {
"ar": {
"validators": ["arabic"],
"prompt_extra": "Use Saudi Arabian healthcare terminology...",
},
},
}
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/
STATIC_URL = "/static/"
@ -516,3 +532,22 @@ OPENROUTER_API_KEY = env(
OPENROUTER_MODEL = env("OPENROUTER_MODEL", default="google/gemma-3-27b-it:free")
ANALYSIS_BATCH_SIZE = env.int("ANALYSIS_BATCH_SIZE", default=2)
ANALYSIS_ENABLED = env.bool("ANALYSIS_ENABLED", default=True)
# Bugsink / Sentry error tracking
SENTRY_DSN = env("SENTRY_DSN", default=None)
if SENTRY_DSN:
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.celery import CeleryIntegration
sentry_sdk.init(
dsn=SENTRY_DSN,
integrations=[
DjangoIntegration(),
CeleryIntegration(),
],
traces_sample_rate=0.1,
send_default_pii=False,
environment="development" if DEBUG else "production",
)

View File

@ -8,7 +8,7 @@ from django.contrib import admin
from django.urls import include, path
from django.conf.urls.i18n import i18n_patterns
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
from apps.organizations.views import api_patient_search, api_staff_search
from apps.organizations.views import api_patient_search, api_staff_search, api_staff_for_user_create
urlpatterns = [
# Language switching
@ -56,6 +56,7 @@ urlpatterns = [
path("api/simulator/", include("apps.simulator.urls", namespace="api_simulator")),
path("api/patients/search/", api_patient_search, name="api_patient_search"),
path("api/staffs/search/", api_staff_search, name="api_staff_search"),
path("api/staffs/<uuid:staff_id>/for-user-creation/", api_staff_for_user_create, name="api_staff_for_user_create"),
# External API (X-API-Key authenticated)
path("api/v1/external/", include("apps.integrations.urls_external")),
# OpenAPI/Swagger documentation

View File

@ -37,7 +37,10 @@ services:
container_name: px360_web
environment:
- DATABASE_URL=postgresql://px360:px360@db:5432/px360
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
- DJANGO_SETTINGS_MODULE=config.settings.dev
- SENTRY_DSN=http://52653adf0f0b4618bfab78806637fe7d@bugsink:8000/1
command: >
sh -c "python manage.py migrate &&
python manage.py collectstatic --noinput || true &&
@ -69,8 +72,11 @@ services:
container_name: px360_celery
environment:
- DATABASE_URL=postgresql://px360:px360@db:5432/px360
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
- DJANGO_SETTINGS_MODULE=config.settings.dev
command: celery -A config worker -l info
- SENTRY_DSN=http://52653adf0f0b4618bfab78806637fe7d@bugsink:8000/1
command: celery -A config worker -l info --autoreload
volumes:
- .:/app
env_file:
@ -88,7 +94,10 @@ services:
container_name: px360_celery_beat
environment:
- DATABASE_URL=postgresql://px360:px360@db:5432/px360
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
- DJANGO_SETTINGS_MODULE=config.settings.dev
- SENTRY_DSN=http://52653adf0f0b4618bfab78806637fe7d@bugsink:8000/1
command: celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
volumes:
- .:/app
@ -99,6 +108,43 @@ services:
- redis
- web
bugsink:
image: bugsink/bugsink:latest
container_name: px360_bugsink
ports:
- "9000:8000"
volumes:
- bugsink_data:/data
environment:
- SECRET_KEY=bugsink-prod-secret-key-3f8a2c5e9b1d4f7a6c0e2d5b8f1a4c7e
restart: unless-stopped
openobserve:
image: openobserve/openobserve:latest
container_name: px360_openobserve
ports:
- "5080:5080"
volumes:
- openobserve_data:/data
environment:
- ZO_ROOT_USER_EMAIL=root@example.com
- ZO_ROOT_USER_PASSWORD=Complexpass#123
- ZO_DATA_DIR=/data
restart: unless-stopped
vector:
image: timberio/vector:latest-debian
container_name: px360_vector
command: ["--config", "/etc/vector/vector.toml"]
volumes:
- ./vector.toml:/etc/vector/vector.toml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
depends_on:
- openobserve
restart: unless-stopped
volumes:
postgres_data:
media_volume:
bugsink_data:
openobserve_data:

View File

@ -92,28 +92,15 @@ class ExportService:
"""Generates PDF file and returns HttpResponse"""
```
#### 3. UI Views (`apps/analytics/ui_views.py`)
#### 3. Dashboard Views (`apps/dashboard/views.py`)
Django views for rendering the dashboard and handling API requests.
The canonical Command Center is `CommandCenterView` (class-based) in the dashboard app, rendered server-side with context data. Live updates are served by `command_center_api` at `/api/`.
**Key Views:**
```python
@login_required
def command_center(request)
"""Main dashboard view - renders the HTML template"""
@login_required
def command_center_api(request)
"""API endpoint - returns JSON data for dynamic updates"""
@login_required
def export_command_center(request, export_format)
"""Handles Excel and PDF export requests"""
```
> **Note:** The legacy analytics-based Command Center (`apps/analytics/ui_views.py` functions `command_center`, `command_center_api`, `export_command_center`) has been removed. The `/analytics/command-center/` URL now redirects to `/`. Excel/PDF export was removed with it.
### Frontend Components
#### 1. Command Center Template (`templates/analytics/command_center.html`)
#### 1. Command Center Template (`templates/dashboard/command_center.html`)
The main dashboard template includes:
- Filter panel with collapsible form
@ -142,18 +129,14 @@ updateTables(tableData) // Updates data tables
handleDateRangeChange() // Shows/hides custom date range
updateFilters() // Updates currentFilters object
resetFilters() // Resets all filters to defaults
// Export functions
exportDashboard(format) // Initiates Excel/PDF export
```
## URL Structure
```
/analytics/command-center/ # Main dashboard
/analytics/api/command-center/ # API endpoint for data
/analytics/api/command-center/export/excel/ # Excel export
/analytics/api/command-center/export/pdf/ # PDF export
/ # Main dashboard (dashboard:command-center)
/api/ # API endpoint for live data (dashboard:command_center_api)
/analytics/command-center/ # Backwards-compatible redirect to /
```
## Data Flow
@ -191,17 +174,8 @@ JavaScript updates UI components
```
### 3. Export Request
```
User clicks Export → Excel/PDF
exportDashboard(format) called
AJAX request to export_command_center
ExportService generates file
Browser downloads file
```
> **Removed:** Excel/PDF export was provided by the legacy analytics Command Center (`export_command_center`) and is no longer available. If export is needed, it must be re-implemented under the dashboard app.
## Role-Based Access Control

View File

@ -8,7 +8,7 @@ The PX Command Center is your one-stop dashboard for viewing all Patient Experie
Navigate to:
```
https://your-domain.com/analytics/command-center/
https://your-domain.com/
```
## Dashboard Overview

View File

@ -179,13 +179,13 @@ test.describe('Champion/Manager workflow', () => {
cid = seed.cid;
observe(M, 'B-seed', 'INFO', `complaint ${cid}`, { role: CHAMP });
// 1. PX sends via send_to_department_form (generates champion ComplaintExplanation token)
// 1. PX sends via the unified send-to endpoint (auto-targets champion+manager, generates champion token)
await login(page, PXT);
await postForm(page, `${BASE_URL}/complaints/${cid}/activate/`, {}); // activate first
const send = await postForm(page, `${BASE_URL}/complaints/${cid}/send-to-department/`, {
selected_departments: seed.deptId, request_message: 'E2E investigate please', action: 'send',
const send = await postForm(page, `${BASE_URL}/complaints/${cid}/send-to/`, {
recipient_type: 'department', department_id: seed.deptId, note: 'E2E collect feedback please',
});
observe(M, 'B-send-dept-form', send.status() < 400 ? 'PASS' : 'FAIL', `send-to-department HTTP ${send.status()}`, { role: PXT, http: send.status() });
observe(M, 'B-send-dept-form', send.status() < 400 ? 'PASS' : 'FAIL', `send-to HTTP ${send.status()}`, { role: PXT, http: send.status() });
const s0 = workflowState(cid);
const champToken = s0.explanation_token;
@ -202,9 +202,9 @@ test.describe('Champion/Manager workflow', () => {
const tb1 = await bodyHasTraceback(page);
if (tb1) { observe(M, 'B-investigate-open', 'FAIL', `traceback: ${tb1}`); return; }
// add a question
const q = page.locator('input[name="questions[]"]').first();
if (await q.count()) await q.fill('E2E investigation question?');
// add a question (per-staff question field: questions__<staffId>[])
const q = page.locator(`input[name="questions__${seed.staffId}[]"]`).first();
if (await q.count()) await q.fill('E2E feedback question?');
// select the accused staff checkbox matching our staff id
const staffBox = page.locator(`input[name="accused_staff[]"][value="${seed.staffId}"]`).first();
if (await staffBox.count()) await staffBox.check();

View File

@ -72,7 +72,7 @@ test.describe('Reports + KPIs / Analytics', () => {
});
// 3. Command Center
await visitPage(page, '/analytics/command-center/', 'command-center', ADMIN, async (p) => {
await visitPage(page, '/', 'command-center', ADMIN, async (p) => {
const body = (await p.textContent('body')) || '';
const hasCards = await p.locator('.card, .rounded-2xl, .bg-white, [class*="stat"]').count();
observe(M, 'command-center-cards', hasCards > 0 ? 'PASS' : 'WARN', `overview cards: ${hasCards}`, { role: ADMIN });
@ -124,16 +124,6 @@ test.describe('Reports + KPIs / Analytics', () => {
// 9. Report Templates
await visitPage(page, '/reports/templates/', 'report-templates', ADMIN);
// 10. Dashboard CSV export (command center)
try {
const exportResp = await page.context().request.get(`${BASE_URL}/analytics/api/command-center/export/excel/`);
const ct = exportResp.headers()['content-type'] || '';
const exportOk = exportResp.status() === 200 && (ct.includes('csv') || ct.includes('excel') || ct.includes('spreadsheet') || ct.includes('octet'));
observe(M, 'command-center-export', exportOk ? 'PASS' : 'FAIL', `CSV export: HTTP ${exportResp.status()} ct=${ct}`, { role: ADMIN, http: exportResp.status() });
} catch (e) {
observe(M, 'command-center-export', 'FAIL', `exception: ${(e as Error).message}`, { role: ADMIN });
}
});
test('Role access: px_employee + viewer can view, source_user blocked', async ({ page }) => {

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Complaint Detail — Redesign Options</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Kufi+Arabic:wght@400;500;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
navy: '#005696',
blue: { DEFAULT: '#007bbd', light: '#eff6fb' },
light: '#eef6fb',
},
fontFamily: { sans: ['Inter', 'Noto Kufi Arabic', 'sans-serif'] },
},
},
};
</script>
<style>body{font-family:'Inter','Noto Kufi Arabic',sans-serif;}</style>
</head>
<body class="bg-slate-100 min-h-screen">
<div class="max-w-5xl mx-auto px-6 py-12">
<header class="mb-10">
<p class="text-xs font-bold tracking-widest text-navy uppercase mb-2">PX360 · Complaint Detail Redesign</p>
<h1 class="text-3xl md:text-4xl font-extrabold text-slate-800">Four directions to choose from</h1>
<p class="text-slate-500 mt-3 max-w-2xl">
The current page is cluttered: 8 tabs, a wall of buttons, and actions hidden behind an "activate" gate.
Each option below kills that complexity in a different way. All three are responsive and RTL-ready,
and use the same realistic case data so you can compare apples-to-apples.
</p>
</header>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<!-- A -->
<a href="sample-a.html" class="group bg-white rounded-2xl border border-slate-200 overflow-hidden hover:shadow-xl hover:-translate-y-1 transition">
<div class="h-32 bg-gradient-to-br from-navy to-blue flex items-center justify-center">
<svg class="w-12 h-12 text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 12h16M4 18h10"/></svg>
</div>
<div class="p-5">
<p class="text-[10px] font-bold tracking-widest text-blue uppercase">Option A</p>
<h2 class="text-lg font-bold text-slate-800 mt-1">Linear Reader</h2>
<p class="text-sm text-slate-500 mt-2">Zero tabs. Reads like a clean article with a sticky action bar at the bottom. Simplest of the three.</p>
<span class="inline-flex items-center gap-1 text-sm font-bold text-navy mt-4 group-hover:gap-2 transition-all">View <span aria-hidden></span></span>
</div>
</a>
<!-- B -->
<a href="sample-b.html" class="group bg-white rounded-2xl border border-slate-200 overflow-hidden hover:shadow-xl hover:-translate-y-1 transition">
<div class="h-32 bg-gradient-to-br from-slate-700 to-navy flex items-center justify-center">
<svg class="w-12 h-12 text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 5h7v14H4zM13 5h7v7h-7zM13 15h7v4h-7z"/></svg>
</div>
<div class="p-5">
<p class="text-[10px] font-bold tracking-widest text-blue uppercase">Option B</p>
<h2 class="text-lg font-bold text-slate-800 mt-1">Workspace Dashboard</h2>
<p class="text-sm text-slate-500 mt-2">Familiar two-column layout. A smart "Next Step" card and a tidy actions menu replace the button wall.</p>
<span class="inline-flex items-center gap-1 text-sm font-bold text-navy mt-4 group-hover:gap-2 transition-all">View <span aria-hidden></span></span>
</div>
</a>
<!-- C -->
<a href="sample-c.html" class="group bg-white rounded-2xl border border-slate-200 overflow-hidden hover:shadow-xl hover:-translate-y-1 transition">
<div class="h-32 bg-gradient-to-br from-blue to-emerald-500 flex items-center justify-center">
<svg class="w-12 h-12 text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 8h10M5 8l3-3M5 8l3 3M19 16H9m10 0l-3-3m3 3l-3 3"/></svg>
</div>
<div class="p-5">
<p class="text-[10px] font-bold tracking-widest text-blue uppercase">Option C</p>
<h2 class="text-lg font-bold text-slate-800 mt-1">Workflow Stepper</h2>
<p class="text-sm text-slate-500 mt-2">A big lifecycle stepper shows exactly where the case is, with one context-aware primary action. Best for guiding staff.</p>
<span class="inline-flex items-center gap-1 text-sm font-bold text-navy mt-4 group-hover:gap-2 transition-all">View <span aria-hidden></span></span>
</div>
</a>
<!-- D -->
<a href="sample-d.html" class="group bg-white rounded-2xl border border-slate-200 overflow-hidden hover:shadow-xl hover:-translate-y-1 transition sm:col-span-2 lg:col-span-1">
<div class="h-32 bg-gradient-to-br from-indigo-600 to-navy flex items-center justify-center">
<svg class="w-12 h-12 text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 5h10v10H4zM15 9h5v10h-5zM8 19h4"/></svg>
</div>
<div class="p-5">
<p class="text-[10px] font-bold tracking-widest text-blue uppercase">Option D</p>
<h2 class="text-lg font-bold text-slate-800 mt-1">Detail + Modals</h2>
<p class="text-sm text-slate-500 mt-2">Only the core details live on the page. Departments, Activity, AI, Resolution all open in modals on demand — cleanest canvas.</p>
<span class="inline-flex items-center gap-1 text-sm font-bold text-navy mt-4 group-hover:gap-2 transition-all">View <span aria-hidden></span></span>
</div>
</a>
</div>
<footer class="mt-12 text-center">
<a href="sample-a.html" class="text-sm font-semibold text-slate-500 hover:text-navy">Start with Option A →</a>
</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Option A — Linear Reader · CMP-2025-NOV-8842</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Kufi+Arabic:wght@400;500;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
navy: '#005696',
blue: { DEFAULT: '#007bbd', light: '#eff6fb' },
light: '#eef6fb',
},
fontFamily: { sans: ['Inter', 'Noto Kufi Arabic', 'sans-serif'] },
},
},
};
</script>
<style>
body { font-family: 'Inter', 'Noto Kufi Arabic', sans-serif; }
.timeline-line::before { content:''; position:absolute; left:15px; top:6px; bottom:6px; width:2px; background:#e2e8f0; }
</style>
</head>
<body class="bg-slate-100 min-h-screen pb-28 md:pb-10">
<!-- Sticky minimal top bar -->
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-slate-200">
<div class="max-w-3xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<a href="index.html" class="p-1.5 -ml-1.5 rounded-lg hover:bg-slate-100 text-slate-500">
<i data-lucide="arrow-left" class="w-5 h-5"></i>
</a>
<span class="text-xs text-slate-400 hidden sm:inline">Cases</span>
<span class="text-xs text-slate-400 hidden sm:inline">/</span>
<span class="font-bold text-slate-800 text-sm truncate">CMP-2025-NOV-8842</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-blue-light text-navy">
<span class="w-1.5 h-1.5 rounded-full bg-navy animate-pulse"></span> In Progress
</span>
<span id="slaPill" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-amber-50 text-amber-700 border border-amber-200">
<i data-lucide="clock" class="w-3.5 h-3.5"></i> <span id="slaText"></span>
</span>
</div>
</div>
</header>
<main class="max-w-3xl mx-auto px-4 sm:px-6 py-6 space-y-5">
<!-- Title + meta -->
<section>
<div class="flex items-center gap-2 text-xs font-semibold text-slate-500 mb-2 flex-wrap">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-slate-100 text-slate-600"><i data-lucide="globe" class="w-3 h-3"></i> MOH Tawasul</span>
<span class="text-red-600 font-bold inline-flex items-center gap-1"><i data-lucide="alert-octagon" class="w-3 h-3"></i> High severity</span>
</div>
<h1 class="text-2xl sm:text-3xl font-extrabold text-slate-900 leading-tight">ER Admission Delay &amp; Poor Communication</h1>
<p class="text-sm text-slate-500 mt-1.5">Created 12 Nov 2025, 09:14 AM · Due 14 Nov 2025, 08:00 AM</p>
</section>
<!-- Description -->
<section class="bg-white rounded-2xl border border-slate-200 p-5">
<div class="border-l-4 border-navy pl-4">
<p class="text-[15px] leading-relaxed text-slate-700 italic">
"The patient's relative complained about the delay in the ER admission process. The doctor was unavailable and nursing staff did not provide updates for over 45 minutes, causing distress to the family."
</p>
</div>
</section>
<!-- Key facts grid -->
<section class="bg-white rounded-2xl border border-slate-200 divide-y divide-slate-100">
<div class="grid grid-cols-2 sm:grid-cols-3">
<div class="p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide">Location</p>
<p class="text-sm font-bold text-slate-800 mt-1">Emergency Room</p>
<p class="text-xs text-slate-500">Ground Floor · Zone B</p>
</div>
<div class="p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide">Assigned to</p>
<div class="flex items-center gap-2 mt-1">
<span class="w-6 h-6 rounded-full bg-navy text-white text-[10px] font-bold flex items-center justify-center">SM</span>
<p class="text-sm font-bold text-slate-800">Dr. Sara Mansoor</p>
</div>
<p class="text-xs text-slate-500">Patient Experience</p>
</div>
<div class="p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide">Patient</p>
<p class="text-sm font-bold text-slate-800 mt-1">Ahmed Al-Rashid</p>
<p class="text-xs text-slate-500">MRN: H-29481</p>
</div>
<div class="p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide">Taxonomy</p>
<p class="text-sm font-bold text-slate-800 mt-1">Clinical Care</p>
<p class="text-xs text-slate-500">Emergency Wait Time</p>
</div>
<div class="p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide">Patient contact</p>
<p class="text-sm font-bold text-emerald-600 mt-1 inline-flex items-center gap-1"><i data-lucide="phone" class="w-3.5 h-3.5"></i> Contacted</p>
<p class="text-xs text-slate-500">Nov 12, 02:15 PM</p>
</div>
<div class="p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide">Departments</p>
<p class="text-sm font-bold text-slate-800 mt-1">2 involved</p>
<p class="text-xs text-slate-500">Emergency · Nursing</p>
</div>
</div>
</section>
<!-- Activity (always visible) -->
<section class="bg-white rounded-2xl border border-slate-200 p-5">
<h2 class="text-sm font-bold text-slate-800 mb-4 flex items-center gap-2"><i data-lucide="activity" class="w-4 h-4 text-navy"></i> Activity</h2>
<div class="relative timeline-line pl-10 space-y-5">
<div class="relative">
<span class="absolute -left-[26px] top-0.5 w-3.5 h-3.5 rounded-full bg-white border-[3px] border-navy"></span>
<p class="text-sm font-semibold text-slate-800">ER department submitted a response</p>
<p class="text-xs text-slate-400">Nov 13, 08:45 AM</p>
</div>
<div class="relative">
<span class="absolute -left-[26px] top-0.5 w-3.5 h-3.5 rounded-full bg-white border-[3px] border-emerald-500"></span>
<p class="text-sm font-semibold text-slate-800">Patient contacted by phone</p>
<p class="text-xs text-slate-400">Nov 12, 02:15 PM</p>
</div>
<div class="relative">
<span class="absolute -left-[26px] top-0.5 w-3.5 h-3.5 rounded-full bg-white border-[3px] border-orange-500"></span>
<p class="text-sm font-semibold text-slate-800">Escalated to ER Department Head</p>
<p class="text-xs text-slate-400">Nov 12, 11:30 AM</p>
</div>
<div class="relative">
<span class="absolute -left-[26px] top-0.5 w-3.5 h-3.5 rounded-full bg-white border-[3px] border-blue"></span>
<p class="text-sm font-semibold text-slate-800">Assigned to Dr. Sara Mansoor (PX)</p>
<p class="text-xs text-slate-400">Nov 12, 10:02 AM</p>
</div>
<div class="relative">
<span class="absolute -left-[26px] top-0.5 w-3.5 h-3.5 rounded-full bg-white border-[3px] border-slate-300"></span>
<p class="text-sm font-semibold text-slate-800">Complaint received via MOH Tawasul</p>
<p class="text-xs text-slate-400">Nov 12, 09:14 AM</p>
</div>
</div>
</section>
<!-- Collapsible sections (no tabs) -->
<section class="bg-white rounded-2xl border border-slate-200 divide-y divide-slate-100">
<details class="group">
<summary class="flex items-center justify-between p-5 cursor-pointer list-none">
<span class="flex items-center gap-2 text-sm font-bold text-slate-800"><i data-lucide="users" class="w-4 h-4 text-navy"></i> Departments &amp; Staff</span>
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-400 group-open:rotate-180 transition"></i>
</summary>
<div class="px-5 pb-5 space-y-3">
<div class="flex items-center justify-between p-3 rounded-xl bg-slate-50">
<div>
<p class="text-sm font-bold text-slate-800">Emergency Medicine <span class="text-[10px] font-bold text-navy bg-blue-light px-1.5 py-0.5 rounded ml-1">PRIMARY</span></p>
<p class="text-xs text-slate-500">Response received · accepted</p>
</div>
<i data-lucide="check-circle-2" class="w-5 h-5 text-emerald-500"></i>
</div>
<div class="flex items-center justify-between p-3 rounded-xl bg-slate-50">
<div>
<p class="text-sm font-bold text-slate-800">Nursing Services</p>
<p class="text-xs text-amber-600">Awaiting response</p>
</div>
<i data-lucide="clock" class="w-5 h-5 text-amber-500"></i>
</div>
</div>
</details>
<details class="group">
<summary class="flex items-center justify-between p-5 cursor-pointer list-none">
<span class="flex items-center gap-2 text-sm font-bold text-slate-800"><i data-lucide="sparkles" class="w-4 h-4 text-navy"></i> AI Summary</span>
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-400 group-open:rotate-180 transition"></i>
</summary>
<div class="px-5 pb-5 text-sm text-slate-600 leading-relaxed">
Communication breakdown during high-acuity ER triage. Recurrence risk: medium. Suggested actions: nursing handover protocol refresh &amp; real-time patient-status board.
</div>
</details>
<details class="group">
<summary class="flex items-center justify-between p-5 cursor-pointer list-none">
<span class="flex items-center gap-2 text-sm font-bold text-slate-800"><i data-lucide="file-text" class="w-4 h-4 text-navy"></i> Resolution &amp; PDF</span>
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-400 group-open:rotate-180 transition"></i>
</summary>
<div class="px-5 pb-5 text-sm text-slate-500">Resolution notes will appear here once the case is resolved. A formal PDF report can be generated for MOH submission.</div>
</details>
</section>
</main>
<!-- Sticky bottom action bar (mobile-first) -->
<div class="fixed bottom-0 inset-x-0 z-30 md:hidden">
<div class="bg-white/95 backdrop-blur border-t border-slate-200 px-4 py-3 flex items-center gap-2">
<button class="flex-1 bg-navy text-white font-bold text-sm rounded-xl py-3 flex items-center justify-center gap-2">
<i data-lucide="check-circle-2" class="w-4 h-4"></i> Resolve
</button>
<button class="px-3 py-3 border border-slate-200 rounded-xl text-slate-600"><i data-lucide="user-plus" class="w-4 h-4"></i></button>
<button class="px-3 py-3 border border-red-200 bg-red-50 rounded-xl text-red-600"><i data-lucide="alert-triangle" class="w-4 h-4"></i></button>
</div>
</div>
<!-- Desktop floating action cluster -->
<div class="hidden md:flex fixed bottom-6 right-6 z-30 items-center gap-2">
<div class="relative">
<button id="moreBtn" onclick="toggleMenu('moreMenu')" class="w-12 h-12 bg-white border border-slate-200 rounded-full shadow-sm hover:bg-slate-50 flex items-center justify-center text-slate-600">
<i data-lucide="more-horizontal" class="w-5 h-5"></i>
</button>
<div id="moreMenu" class="hidden absolute bottom-14 right-0 w-52 bg-white rounded-xl shadow-xl border border-slate-200 py-1.5">
<button class="w-full text-left px-4 py-2 text-sm hover:bg-slate-50 flex items-center gap-2 text-slate-700"><i data-lucide="user-plus" class="w-4 h-4"></i> Assign / Reassign</button>
<button class="w-full text-left px-4 py-2 text-sm hover:bg-slate-50 flex items-center gap-2 text-slate-700"><i data-lucide="phone" class="w-4 h-4"></i> Update patient contact</button>
<button class="w-full text-left px-4 py-2 text-sm hover:bg-slate-50 flex items-center gap-2 text-slate-700"><i data-lucide="shield-alert" class="w-4 h-4"></i> OVR escalate</button>
<div class="my-1 border-t border-slate-100"></div>
<button class="w-full text-left px-4 py-2 text-sm hover:bg-red-50 flex items-center gap-2 text-red-600"><i data-lucide="trash-2" class="w-4 h-4"></i> Delete</button>
</div>
</div>
<button class="px-4 h-12 border border-slate-200 bg-white rounded-full shadow-sm hover:bg-slate-50 font-semibold text-sm text-slate-700 flex items-center gap-2"><i data-lucide="user-plus" class="w-4 h-4"></i> Assign</button>
<button class="px-4 h-12 border border-red-200 bg-white rounded-full shadow-sm hover:bg-red-50 font-semibold text-sm text-red-600 flex items-center gap-2"><i data-lucide="alert-triangle" class="w-4 h-4"></i> Escalate</button>
<button class="px-6 h-12 bg-navy text-white rounded-full shadow-lg hover:bg-blue font-bold text-sm flex items-center gap-2"><i data-lucide="check-circle-2" class="w-5 h-5"></i> Resolve Case</button>
</div>
<script>
lucide.createIcons();
// Live SLA countdown (target ~18h from load so it always looks real)
(function () {
var due = Date.now() + 18 * 3600 * 1000 + 42 * 60 * 1000;
var pill = document.getElementById('slaPill');
var txt = document.getElementById('slaText');
function tick() {
var diff = due - Date.now();
if (diff <= 0) { txt.textContent = 'Overdue'; pill.className = pill.className.replace('amber', 'red'); return; }
var h = Math.floor(diff / 3600000);
var m = Math.floor((diff % 3600000) / 60000);
txt.textContent = h + 'h ' + m + 'm left';
if (diff < 3600000) pill.className = pill.className.replace(/amber-50|amber-200|amber-700/g, function(m){return m.replace('amber','red')});
}
tick(); setInterval(tick, 30000);
})();
function toggleMenu(id) {
var el = document.getElementById(id);
el.classList.toggle('hidden');
}
document.addEventListener('click', function (e) {
var m = document.getElementById('moreMenu');
var b = document.getElementById('moreBtn');
if (m && !m.contains(e.target) && !b.contains(e.target)) m.classList.add('hidden');
});
</script>
</body>
</html>

View File

@ -0,0 +1,265 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Option B — Workspace · CMP-2025-NOV-8842</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Kufi+Arabic:wght@400;500;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
navy: '#005696',
blue: { DEFAULT: '#007bbd', light: '#eff6fb' },
light: '#eef6fb',
},
fontFamily: { sans: ['Inter', 'Noto Kufi Arabic', 'sans-serif'] },
},
},
};
</script>
<style>body{font-family:'Inter','Noto Kufi Arabic',sans-serif;}</style>
</head>
<body class="bg-slate-100 min-h-screen">
<!-- Top bar -->
<header class="bg-white border-b border-slate-200 sticky top-0 z-30">
<div class="max-w-7xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0 text-sm">
<a href="index.html" class="p-1.5 -ml-1.5 rounded-lg hover:bg-slate-100 text-slate-500"><i data-lucide="arrow-left" class="w-5 h-5"></i></a>
<span class="text-slate-400 hidden sm:inline">Cases</span>
<span class="text-slate-300 hidden sm:inline">/</span>
<span class="font-bold text-slate-800 truncate">CMP-2025-NOV-8842</span>
<span class="ml-1 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-blue-light text-navy">
<span class="w-1.5 h-1.5 rounded-full bg-navy animate-pulse"></span> In Progress
</span>
</div>
<button class="text-slate-500 hover:text-navy p-2 rounded-lg hover:bg-slate-100"><i data-lucide="ellipsis-vertical" class="w-5 h-5"></i></button>
</div>
</header>
<!-- Page heading -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 pt-6">
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
<div>
<h1 class="text-2xl font-extrabold text-slate-900">ER Admission Delay &amp; Poor Communication</h1>
<p class="text-sm text-slate-500 mt-1">Emergency Room · Ground Floor, Zone B · Created 12 Nov 2025</p>
</div>
<div class="flex items-center gap-2 text-xs font-semibold">
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-red-50 text-red-600"><i data-lucide="alert-octagon" class="w-3 h-3"></i> High</span>
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-slate-100 text-slate-600"><i data-lucide="globe" class="w-3 h-3"></i> MOH Tawasul</span>
<span id="slaPill" class="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-amber-50 text-amber-700 border border-amber-200"><i data-lucide="clock" class="w-3 h-3"></i> <span id="slaText"></span></span>
</div>
</div>
</div>
<!-- Body grid -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 py-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left column -->
<div class="lg:col-span-2 space-y-6">
<!-- Description card -->
<section class="bg-white rounded-2xl border border-slate-200 p-5">
<h2 class="text-xs font-bold uppercase tracking-wide text-slate-400 mb-3">Complaint</h2>
<div class="border-l-4 border-navy pl-4">
<p class="text-[15px] leading-relaxed text-slate-700 italic">"The patient's relative complained about the delay in the ER admission process. The doctor was unavailable and nursing staff did not provide updates for over 45 minutes, causing distress to the family."</p>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-5 pt-5 border-t border-slate-100">
<div>
<p class="text-[11px] font-semibold text-slate-400 uppercase">Patient</p>
<p class="text-sm font-bold text-slate-800">Ahmed Al-Rashid</p>
<p class="text-xs text-slate-500">MRN: H-29481</p>
</div>
<div>
<p class="text-[11px] font-semibold text-slate-400 uppercase">Severity</p>
<p class="text-sm font-bold text-red-600">High</p>
</div>
<div>
<p class="text-[11px] font-semibold text-slate-400 uppercase">Taxonomy</p>
<p class="text-sm font-bold text-slate-800">Emergency</p>
<p class="text-xs text-slate-500">Clinical Wait Time</p>
</div>
<div>
<p class="text-[11px] font-semibold text-slate-400 uppercase">Due</p>
<p class="text-sm font-bold text-slate-800">14 Nov, 08:00</p>
</div>
</div>
</section>
<!-- Departments -->
<section class="bg-white rounded-2xl border border-slate-200">
<div class="flex items-center justify-between p-5 pb-3">
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2"><i data-lucide="building-2" class="w-4 h-4 text-navy"></i> Involved Departments</h2>
<button class="text-xs font-bold text-navy hover:underline">+ Add</button>
</div>
<div class="divide-y divide-slate-100">
<div class="flex items-center justify-between p-4">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-blue-light flex items-center justify-center"><i data-lucide="stethoscope" class="w-4 h-4 text-navy"></i></div>
<div>
<p class="text-sm font-bold text-slate-800">Emergency Medicine <span class="text-[10px] font-bold text-navy bg-blue-light px-1.5 py-0.5 rounded ml-1">PRIMARY</span></p>
<p class="text-xs text-slate-500">Response received · accepted</p>
</div>
</div>
<span class="inline-flex items-center gap-1 text-xs font-bold text-emerald-600"><i data-lucide="check-circle-2" class="w-4 h-4"></i></span>
</div>
<div class="flex items-center justify-between p-4">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-blue-light flex items-center justify-center"><i data-lucide="heart-pulse" class="w-4 h-4 text-navy"></i></div>
<div>
<p class="text-sm font-bold text-slate-800">Nursing Services</p>
<p class="text-xs text-amber-600">Awaiting response · 14h left</p>
</div>
</div>
<span class="inline-flex items-center gap-1 text-xs font-bold text-amber-500"><i data-lucide="clock" class="w-4 h-4"></i></span>
</div>
</div>
</section>
<!-- Activity -->
<section class="bg-white rounded-2xl border border-slate-200 p-5">
<h2 class="text-sm font-bold text-slate-800 mb-4 flex items-center gap-2"><i data-lucide="activity" class="w-4 h-4 text-navy"></i> Activity</h2>
<ol class="relative border-l-2 border-slate-100 ml-2 space-y-5">
<li class="ml-5">
<span class="absolute -left-[9px] w-4 h-4 rounded-full bg-white border-[3px] border-navy"></span>
<p class="text-sm font-semibold text-slate-800">ER department submitted a response</p>
<p class="text-xs text-slate-400">Nov 13, 08:45 AM</p>
</li>
<li class="ml-5">
<span class="absolute -left-[9px] w-4 h-4 rounded-full bg-white border-[3px] border-emerald-500"></span>
<p class="text-sm font-semibold text-slate-800">Patient contacted by phone</p>
<p class="text-xs text-slate-400">Nov 12, 02:15 PM</p>
</li>
<li class="ml-5">
<span class="absolute -left-[9px] w-4 h-4 rounded-full bg-white border-[3px] border-orange-500"></span>
<p class="text-sm font-semibold text-slate-800">Escalated to ER Department Head</p>
<p class="text-xs text-slate-400">Nov 12, 11:30 AM</p>
</li>
<li class="ml-5">
<span class="absolute -left-[9px] w-4 h-4 rounded-full bg-white border-[3px] border-blue"></span>
<p class="text-sm font-semibold text-slate-800">Assigned to Dr. Sara Mansoor</p>
<p class="text-xs text-slate-400">Nov 12, 10:02 AM</p>
</li>
</ol>
</section>
</div>
<!-- Right rail -->
<aside class="space-y-6">
<!-- Next Step (the hero of the rail) -->
<section class="rounded-2xl bg-navy text-white p-5 shadow-lg shadow-navy/20">
<div class="flex items-center gap-2 text-blue-light text-xs font-bold uppercase tracking-wide"><i data-lucide="lightbulb" class="w-4 h-4"></i> Next step</div>
<p class="text-lg font-bold mt-2 leading-snug">Collect Nursing response &amp; resolve</p>
<p class="text-sm text-white/70 mt-1">Emergency Medicine has responded. Waiting on Nursing Services before you can resolve.</p>
<div class="flex flex-col gap-2 mt-4">
<button class="w-full bg-white text-navy font-bold text-sm rounded-xl py-3 flex items-center justify-center gap-2 hover:bg-blue-light transition"><i data-lucide="check-circle-2" class="w-4 h-4"></i> Resolve Case</button>
<button class="w-full border border-white/30 text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 hover:bg-white/10 transition"><i data-lucide="send" class="w-4 h-4"></i> Remind Nursing</button>
</div>
</section>
<!-- Lifecycle mini-stepper -->
<section class="bg-white rounded-2xl border border-slate-200 p-5">
<h2 class="text-xs font-bold uppercase tracking-wide text-slate-400 mb-4">Lifecycle</h2>
<div class="space-y-0">
<!-- step -->
<div class="flex gap-3 items-start">
<div class="flex flex-col items-center">
<span class="w-6 h-6 rounded-full bg-emerald-500 text-white flex items-center justify-center"><i data-lucide="check" class="w-3.5 h-3.5"></i></span>
<span class="w-px flex-1 bg-slate-200 min-h-[20px]"></span>
</div>
<div class="pb-4">
<p class="text-sm font-bold text-slate-800">Received</p>
<p class="text-xs text-slate-400">Nov 12, 09:14</p>
</div>
</div>
<div class="flex gap-3 items-start">
<div class="flex flex-col items-center">
<span class="w-6 h-6 rounded-full bg-emerald-500 text-white flex items-center justify-center"><i data-lucide="check" class="w-3.5 h-3.5"></i></span>
<span class="w-px flex-1 bg-slate-200 min-h-[20px]"></span>
</div>
<div class="pb-4">
<p class="text-sm font-bold text-slate-800">Assigned</p>
<p class="text-xs text-slate-400">Dr. Sara Mansoor</p>
</div>
</div>
<div class="flex gap-3 items-start">
<div class="flex flex-col items-center">
<span class="w-6 h-6 rounded-full bg-navy text-white flex items-center justify-center ring-4 ring-blue-light"><i data-lucide="search" class="w-3.5 h-3.5"></i></span>
<span class="w-px flex-1 bg-slate-200 min-h-[20px]"></span>
</div>
<div class="pb-4">
<p class="text-sm font-bold text-navy">Investigating</p>
<p class="text-xs text-slate-400">In progress</p>
</div>
</div>
<div class="flex gap-3 items-start">
<div class="flex flex-col items-center">
<span class="w-6 h-6 rounded-full bg-slate-100 border-2 border-dashed border-slate-300"></span>
<span class="w-px flex-1 bg-slate-200 min-h-[20px]"></span>
</div>
<div class="pb-4">
<p class="text-sm font-semibold text-slate-400">Resolved</p>
</div>
</div>
<div class="flex gap-3 items-start">
<div class="flex flex-col items-center">
<span class="w-6 h-6 rounded-full bg-slate-100 border-2 border-dashed border-slate-300"></span>
</div>
<div>
<p class="text-sm font-semibold text-slate-400">Closed</p>
</div>
</div>
</div>
</section>
<!-- Assignment + tidy actions -->
<section class="bg-white rounded-2xl border border-slate-200 p-5">
<div class="flex items-center gap-3 pb-4 border-b border-slate-100">
<span class="w-10 h-10 rounded-full bg-navy text-white text-sm font-bold flex items-center justify-center">SM</span>
<div class="min-w-0">
<p class="text-sm font-bold text-slate-800">Dr. Sara Mansoor</p>
<p class="text-xs text-slate-500">Patient Experience · Owner</p>
</div>
</div>
<div class="grid grid-cols-2 gap-2 pt-4">
<button class="px-3 py-2.5 rounded-xl border border-slate-200 hover:bg-slate-50 text-sm font-semibold text-slate-700 flex items-center justify-center gap-2"><i data-lucide="user-plus" class="w-4 h-4"></i> Assign</button>
<button class="px-3 py-2.5 rounded-xl border border-red-200 bg-red-50 hover:bg-red-100 text-sm font-semibold text-red-600 flex items-center justify-center gap-2"><i data-lucide="alert-triangle" class="w-4 h-4"></i> Escalate</button>
</div>
<details class="mt-2 group">
<summary class="flex items-center justify-center gap-1 px-3 py-2 text-xs font-semibold text-slate-500 hover:text-navy cursor-pointer list-none">
More actions <i data-lucide="chevron-down" class="w-3.5 h-3.5 group-open:rotate-180 transition"></i>
</summary>
<div class="mt-2 space-y-1">
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-slate-50 text-sm text-slate-600 flex items-center gap-2"><i data-lucide="phone" class="w-4 h-4"></i> Update patient contact</button>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-slate-50 text-sm text-slate-600 flex items-center gap-2"><i data-lucide="shield-alert" class="w-4 h-4"></i> OVR escalate</button>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-slate-50 text-sm text-slate-600 flex items-center gap-2"><i data-lucide="rotate-ccw" class="w-4 h-4"></i> Reopen</button>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-red-50 text-sm text-red-600 flex items-center gap-2"><i data-lucide="trash-2" class="w-4 h-4"></i> Delete</button>
</div>
</details>
</section>
</aside>
</main>
<script>
lucide.createIcons();
(function () {
var due = Date.now() + 18 * 3600 * 1000 + 42 * 60 * 1000;
var pill = document.getElementById('slaPill');
var txt = document.getElementById('slaText');
function tick() {
var diff = due - Date.now();
if (diff <= 0) { txt.textContent = 'Overdue'; return; }
txt.textContent = Math.floor(diff / 3600000) + 'h ' + Math.floor((diff % 3600000) / 60000) + 'm left';
}
tick(); setInterval(tick, 30000);
})();
</script>
</body>
</html>

View File

@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Option C — Workflow Stepper · CMP-2025-NOV-8842</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Kufi+Arabic:wght@400;500;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
navy: '#005696',
blue: { DEFAULT: '#007bbd', light: '#eff6fb' },
light: '#eef6fb',
},
fontFamily: { sans: ['Inter', 'Noto Kufi Arabic', 'sans-serif'] },
},
},
};
</script>
<style>body{font-family:'Inter','Noto Kufi Arabic',sans-serif;}</style>
</head>
<body class="bg-slate-100 min-h-screen">
<!-- Top bar -->
<header class="bg-white border-b border-slate-200 sticky top-0 z-30">
<div class="max-w-4xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0 text-sm">
<a href="index.html" class="p-1.5 -ml-1.5 rounded-lg hover:bg-slate-100 text-slate-500"><i data-lucide="arrow-left" class="w-5 h-5"></i></a>
<span class="font-bold text-slate-800 truncate">CMP-2025-NOV-8842</span>
</div>
<div class="flex items-center gap-2">
<span id="slaPill" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-amber-50 text-amber-700 border border-amber-200"><i data-lucide="clock" class="w-3.5 h-3.5"></i> <span id="slaText"></span></span>
<div class="relative">
<button id="moreBtn" onclick="toggleMenu('moreMenu')" class="p-2 rounded-lg hover:bg-slate-100 text-slate-500"><i data-lucide="ellipsis-vertical" class="w-5 h-5"></i></button>
<div id="moreMenu" class="hidden absolute right-0 top-11 w-56 bg-white rounded-xl shadow-xl border border-slate-200 py-1.5 z-40">
<button class="w-full text-left px-4 py-2 text-sm hover:bg-slate-50 flex items-center gap-2 text-slate-700"><i data-lucide="user-plus" class="w-4 h-4"></i> Assign / Reassign</button>
<button class="w-full text-left px-4 py-2 text-sm hover:bg-slate-50 flex items-center gap-2 text-slate-700"><i data-lucide="phone" class="w-4 h-4"></i> Update patient contact</button>
<button class="w-full text-left px-4 py-2 text-sm hover:bg-slate-50 flex items-center gap-2 text-slate-700"><i data-lucide="shield-alert" class="w-4 h-4"></i> OVR escalate</button>
<button class="w-full text-left px-4 py-2 text-sm hover:bg-slate-50 flex items-center gap-2 text-slate-700"><i data-lucide="file-text" class="w-4 h-4"></i> Generate PDF report</button>
<div class="my-1 border-t border-slate-100"></div>
<button class="w-full text-left px-4 py-2 text-sm hover:bg-red-50 flex items-center gap-2 text-red-600"><i data-lucide="trash-2" class="w-4 h-4"></i> Delete</button>
</div>
</div>
</div>
</div>
</header>
<main class="max-w-4xl mx-auto px-4 sm:px-6 py-6 space-y-6">
<!-- Title -->
<section>
<div class="flex items-center gap-2 mb-2 flex-wrap">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-blue-light text-navy text-xs font-bold"><span class="w-1.5 h-1.5 rounded-full bg-navy animate-pulse"></span> Stage 3 of 5 · Investigating</span>
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-red-50 text-red-600 text-xs font-bold"><i data-lucide="alert-octagon" class="w-3 h-3"></i> High</span>
</div>
<h1 class="text-2xl sm:text-3xl font-extrabold text-slate-900">ER Admission Delay &amp; Poor Communication</h1>
<p class="text-sm text-slate-500 mt-1.5">Emergency Room · Ground Floor, Zone B · Created 12 Nov 2025</p>
</section>
<!-- Lifecycle stepper (the hero) -->
<section class="bg-white rounded-2xl border border-slate-200 p-5 sm:p-6">
<div class="flex items-center">
<!-- step 1 done -->
<div class="flex-1 flex flex-col items-center text-center relative">
<div class="w-10 h-10 rounded-full bg-emerald-500 text-white flex items-center justify-center shadow-sm shadow-emerald-500/30 z-10"><i data-lucide="check" class="w-5 h-5"></i></div>
<p class="text-xs font-bold text-slate-700 mt-2">Received</p>
<p class="text-[10px] text-slate-400">Nov 12</p>
</div>
<div class="flex-1 h-1 -mx-1 bg-emerald-500"></div>
<!-- step 2 done -->
<div class="flex-1 flex flex-col items-center text-center relative">
<div class="w-10 h-10 rounded-full bg-emerald-500 text-white flex items-center justify-center shadow-sm shadow-emerald-500/30 z-10"><i data-lucide="check" class="w-5 h-5"></i></div>
<p class="text-xs font-bold text-slate-700 mt-2">Assigned</p>
<p class="text-[10px] text-slate-400">Sara Mansoor</p>
</div>
<div class="flex-1 h-1 -mx-1 bg-gradient-to-r from-emerald-500 to-navy"></div>
<!-- step 3 current -->
<div class="flex-1 flex flex-col items-center text-center relative">
<div class="w-12 h-12 -my-1 rounded-full bg-navy text-white flex items-center justify-center ring-4 ring-blue-light z-10"><i data-lucide="search" class="w-5 h-5"></i></div>
<p class="text-xs font-extrabold text-navy mt-2">Investigating</p>
<p class="text-[10px] text-navy/70">In progress</p>
</div>
<div class="flex-1 h-1 -mx-1 bg-slate-200"></div>
<!-- step 4 -->
<div class="flex-1 flex flex-col items-center text-center relative">
<div class="w-10 h-10 rounded-full bg-white border-2 border-dashed border-slate-300 text-slate-300 flex items-center justify-center z-10"><i data-lucide="check-circle-2" class="w-5 h-5"></i></div>
<p class="text-xs font-semibold text-slate-400 mt-2">Resolved</p>
<p class="text-[10px] text-slate-300">Pending</p>
</div>
<div class="flex-1 h-1 -mx-1 bg-slate-200"></div>
<!-- step 5 -->
<div class="flex-1 flex flex-col items-center text-center relative">
<div class="w-10 h-10 rounded-full bg-white border-2 border-dashed border-slate-300 text-slate-300 flex items-center justify-center z-10"><i data-lucide="lock" class="w-4 h-4"></i></div>
<p class="text-xs font-semibold text-slate-400 mt-2">Closed</p>
</div>
</div>
</section>
<!-- Current stage context + PRIMARY action -->
<section class="rounded-2xl border-2 border-navy bg-white overflow-hidden">
<div class="p-5 sm:p-6">
<div class="flex items-center gap-2 text-navy text-xs font-bold uppercase tracking-wide"><i data-lucide="lightbulb" class="w-4 h-4"></i> What to do now</div>
<h2 class="text-xl font-extrabold text-slate-900 mt-2">Gather remaining responses, then resolve</h2>
<p class="text-sm text-slate-600 mt-1.5 max-w-xl">Emergency Medicine has responded and is accepted. Nursing Services hasn't responded yet. You can nudge them, or resolve now if their input isn't blocking.</p>
<div class="grid sm:grid-cols-2 gap-3 mt-5">
<div class="rounded-xl bg-emerald-50 border border-emerald-200 p-3 flex items-center gap-3">
<i data-lucide="check-circle-2" class="w-5 h-5 text-emerald-600 shrink-0"></i>
<div>
<p class="text-sm font-bold text-slate-800">Emergency Medicine</p>
<p class="text-xs text-slate-500">Responded · accepted</p>
</div>
</div>
<div class="rounded-xl bg-amber-50 border border-amber-200 p-3 flex items-center gap-3">
<i data-lucide="clock" class="w-5 h-5 text-amber-600 shrink-0"></i>
<div>
<p class="text-sm font-bold text-slate-800">Nursing Services</p>
<p class="text-xs text-slate-500">No response · 14h left</p>
</div>
</div>
</div>
</div>
<div class="bg-slate-50 border-t border-slate-100 px-5 sm:px-6 py-4 flex flex-col sm:flex-row gap-3">
<button class="flex-1 bg-navy text-white font-bold text-base rounded-xl py-3.5 flex items-center justify-center gap-2 hover:bg-blue transition shadow-md shadow-navy/20"><i data-lucide="check-circle-2" class="w-5 h-5"></i> Resolve Case</button>
<div class="flex gap-3">
<button class="flex-1 sm:flex-none px-4 py-3.5 border border-slate-300 bg-white rounded-xl font-semibold text-sm text-slate-700 hover:bg-slate-50 flex items-center justify-center gap-2"><i data-lucide="send" class="w-4 h-4"></i> Nudge Nursing</button>
<button class="flex-1 sm:flex-none px-4 py-3.5 border border-red-300 bg-red-50 rounded-xl font-semibold text-sm text-red-600 hover:bg-red-100 flex items-center justify-center gap-2"><i data-lucide="alert-triangle" class="w-4 h-4"></i> Escalate</button>
</div>
</div>
</section>
<!-- Case details (collapsible — secondary) -->
<section class="bg-white rounded-2xl border border-slate-200">
<details>
<summary class="flex items-center justify-between p-5 cursor-pointer list-none">
<span class="flex items-center gap-2 text-sm font-bold text-slate-800"><i data-lucide="info" class="w-4 h-4 text-navy"></i> Case details</span>
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-400"></i>
</summary>
<div class="px-5 pb-5">
<div class="border-l-4 border-navy pl-4 mb-4">
<p class="text-[15px] leading-relaxed text-slate-700 italic">"The patient's relative complained about the delay in the ER admission process. The doctor was unavailable and nursing staff did not provide updates for over 45 minutes, causing distress to the family."</p>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div><p class="text-[11px] font-semibold text-slate-400 uppercase">Patient</p><p class="text-sm font-bold text-slate-800">Ahmed Al-Rashid</p><p class="text-xs text-slate-500">MRN: H-29481</p></div>
<div><p class="text-[11px] font-semibold text-slate-400 uppercase">Severity</p><p class="text-sm font-bold text-red-600">High</p></div>
<div><p class="text-[11px] font-semibold text-slate-400 uppercase">Source</p><p class="text-sm font-bold text-slate-800">MOH Tawasul</p></div>
<div><p class="text-[11px] font-semibold text-slate-400 uppercase">Taxonomy</p><p class="text-sm font-bold text-slate-800">Emergency</p><p class="text-xs text-slate-500">Clinical Wait Time</p></div>
<div><p class="text-[11px] font-semibold text-slate-400 uppercase">Owner</p><p class="text-sm font-bold text-slate-800">Dr. Sara Mansoor</p></div>
<div><p class="text-[11px] font-semibold text-slate-400 uppercase">Patient contact</p><p class="text-sm font-bold text-emerald-600">Contacted</p></div>
<div><p class="text-[11px] font-semibold text-slate-400 uppercase">Created</p><p class="text-sm font-bold text-slate-800">12 Nov, 09:14</p></div>
<div><p class="text-[11px] font-semibold text-slate-400 uppercase">Due</p><p class="text-sm font-bold text-slate-800">14 Nov, 08:00</p></div>
</div>
</div>
</details>
</section>
<!-- Recent activity (short) -->
<section class="bg-white rounded-2xl border border-slate-200 p-5">
<h2 class="text-sm font-bold text-slate-800 mb-4 flex items-center gap-2"><i data-lucide="history" class="w-4 h-4 text-navy"></i> Recent activity</h2>
<div class="space-y-3">
<div class="flex items-start gap-3">
<span class="mt-0.5 w-2 h-2 rounded-full bg-navy"></span>
<div><p class="text-sm font-semibold text-slate-800">ER department submitted a response</p><p class="text-xs text-slate-400">Nov 13, 08:45 AM</p></div>
</div>
<div class="flex items-start gap-3">
<span class="mt-0.5 w-2 h-2 rounded-full bg-emerald-500"></span>
<div><p class="text-sm font-semibold text-slate-800">Patient contacted by phone</p><p class="text-xs text-slate-400">Nov 12, 02:15 PM</p></div>
</div>
<div class="flex items-start gap-3">
<span class="mt-0.5 w-2 h-2 rounded-full bg-orange-500"></span>
<div><p class="text-sm font-semibold text-slate-800">Escalated to ER Department Head</p><p class="text-xs text-slate-400">Nov 12, 11:30 AM</p></div>
</div>
</div>
</section>
</main>
<script>
lucide.createIcons();
function toggleMenu(id) { document.getElementById(id).classList.toggle('hidden'); }
document.addEventListener('click', function (e) {
var m = document.getElementById('moreMenu');
var b = document.getElementById('moreBtn');
if (m && !m.contains(e.target) && !b.contains(e.target)) m.classList.add('hidden');
});
(function () {
var due = Date.now() + 18 * 3600 * 1000 + 42 * 60 * 1000;
var pill = document.getElementById('slaPill');
var txt = document.getElementById('slaText');
function tick() {
var diff = due - Date.now();
if (diff <= 0) { txt.textContent = 'Overdue'; return; }
txt.textContent = Math.floor(diff / 3600000) + 'h ' + Math.floor((diff % 3600000) / 60000) + 'm left';
}
tick(); setInterval(tick, 30000);
})();
</script>
</body>
</html>

View File

@ -0,0 +1,352 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Option D — Detail + Modals · CMP-2025-NOV-8842</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Kufi+Arabic:wght@400;500;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
navy: '#005696',
blue: { DEFAULT: '#007bbd', light: '#eff6fb' },
light: '#eef6fb',
},
fontFamily: { sans: ['Inter', 'Noto Kufi Arabic', 'sans-serif'] },
},
},
};
</script>
<style>
body { font-family: 'Inter', 'Noto Kufi Arabic', sans-serif; }
.modal { display: none; }
.modal.open { display: flex; }
.modal-panel { animation: pop .18s ease-out; }
@keyframes pop { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
</style>
</head>
<body class="bg-slate-100 min-h-screen pb-28">
<!-- Top bar -->
<header class="bg-white border-b border-slate-200 sticky top-0 z-30">
<div class="max-w-2xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0 text-sm">
<a href="index.html" class="p-1.5 -ml-1.5 rounded-lg hover:bg-slate-100 text-slate-500"><i data-lucide="arrow-left" class="w-5 h-5"></i></a>
<span class="font-bold text-slate-800 truncate">CMP-2025-NOV-8842</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-blue-light text-navy"><span class="w-1.5 h-1.5 rounded-full bg-navy animate-pulse"></span> In Progress</span>
<span id="slaPill" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-amber-50 text-amber-700 border border-amber-200"><i data-lucide="clock" class="w-3.5 h-3.5"></i> <span id="slaText"></span></span>
</div>
</div>
</header>
<main class="max-w-2xl mx-auto px-4 sm:px-6 py-6 space-y-5">
<!-- Main details (always on the page) -->
<section>
<div class="flex items-center gap-2 text-xs font-semibold mb-2 flex-wrap">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-red-50 text-red-600"><i data-lucide="alert-octagon" class="w-3 h-3"></i> High severity</span>
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-slate-100 text-slate-600"><i data-lucide="globe" class="w-3 h-3"></i> MOH Tawasul</span>
</div>
<h1 class="text-2xl font-extrabold text-slate-900 leading-tight">ER Admission Delay &amp; Poor Communication</h1>
<p class="text-sm text-slate-500 mt-1.5">Created 12 Nov 2025 · Due 14 Nov 2025, 08:00 AM</p>
</section>
<!-- Description -->
<section class="bg-white rounded-2xl border border-slate-200 p-5">
<div class="border-l-4 border-navy pl-4">
<p class="text-[15px] leading-relaxed text-slate-700 italic">"The patient's relative complained about the delay in the ER admission process. The doctor was unavailable and nursing staff did not provide updates for over 45 minutes, causing distress to the family."</p>
</div>
</section>
<!-- Key facts grid (the essentials that live on-page) -->
<section class="bg-white rounded-2xl border border-slate-200 divide-y divide-slate-100">
<div class="grid grid-cols-2">
<div class="p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide">Location</p>
<p class="text-sm font-bold text-slate-800 mt-1">Emergency Room</p>
<p class="text-xs text-slate-500">Ground Floor · Zone B</p>
</div>
<div class="p-4 border-l border-slate-100">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide">Assigned to</p>
<div class="flex items-center gap-2 mt-1">
<span class="w-6 h-6 rounded-full bg-navy text-white text-[10px] font-bold flex items-center justify-center">SM</span>
<p class="text-sm font-bold text-slate-800">Dr. Sara Mansoor</p>
</div>
</div>
<div class="p-4 border-t border-slate-100">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide">Patient</p>
<p class="text-sm font-bold text-slate-800 mt-1">Ahmed Al-Rashid</p>
<p class="text-xs text-slate-500">MRN: H-29481</p>
</div>
<div class="p-4 border-t border-l border-slate-100">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide">Taxonomy</p>
<p class="text-sm font-bold text-slate-800 mt-1">Emergency</p>
<p class="text-xs text-slate-500">Clinical Wait Time</p>
</div>
</div>
</section>
<!-- Modal launchers -->
<section class="space-y-2.5">
<h2 class="text-xs font-bold uppercase tracking-wide text-slate-400 px-1">Explore</h2>
<button onclick="openModal('mDepartments')" class="w-full bg-white rounded-2xl border border-slate-200 p-4 flex items-center gap-4 hover:border-navy hover:shadow-sm transition text-left">
<span class="w-10 h-10 rounded-xl bg-blue-light flex items-center justify-center shrink-0"><i data-lucide="building-2" class="w-5 h-5 text-navy"></i></span>
<span class="flex-1 min-w-0">
<span class="flex items-center gap-2 text-sm font-bold text-slate-800">Departments &amp; Staff</span>
<span class="text-xs text-slate-500">2 departments · 1 awaiting response</span>
</span>
<span class="inline-flex items-center gap-1 text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-md">1 pending</span>
<i data-lucide="chevron-right" class="w-4 h-4 text-slate-300"></i>
</button>
<button onclick="openModal('mActivity')" class="w-full bg-white rounded-2xl border border-slate-200 p-4 flex items-center gap-4 hover:border-navy hover:shadow-sm transition text-left">
<span class="w-10 h-10 rounded-xl bg-blue-light flex items-center justify-center shrink-0"><i data-lucide="activity" class="w-5 h-5 text-navy"></i></span>
<span class="flex-1 min-w-0">
<span class="text-sm font-bold text-slate-800">Activity timeline</span>
<span class="text-xs text-slate-500 block">Last: ER response submitted · Nov 13</span>
</span>
<span class="inline-flex items-center gap-1 text-xs font-bold text-slate-500 bg-slate-100 px-2 py-1 rounded-md">5 events</span>
<i data-lucide="chevron-right" class="w-4 h-4 text-slate-300"></i>
</button>
<button onclick="openModal('mAi')" class="w-full bg-white rounded-2xl border border-slate-200 p-4 flex items-center gap-4 hover:border-navy hover:shadow-sm transition text-left">
<span class="w-10 h-10 rounded-xl bg-blue-light flex items-center justify-center shrink-0"><i data-lucide="sparkles" class="w-5 h-5 text-navy"></i></span>
<span class="flex-1 min-w-0">
<span class="text-sm font-bold text-slate-800">AI analysis</span>
<span class="text-xs text-slate-500 block">Summary, sentiment &amp; suggested actions</span>
</span>
<i data-lucide="chevron-right" class="w-4 h-4 text-slate-300"></i>
</button>
<button onclick="openModal('mResolution')" class="w-full bg-white rounded-2xl border border-slate-200 p-4 flex items-center gap-4 hover:border-navy hover:shadow-sm transition text-left">
<span class="w-10 h-10 rounded-xl bg-blue-light flex items-center justify-center shrink-0"><i data-lucide="file-text" class="w-5 h-5 text-navy"></i></span>
<span class="flex-1 min-w-0">
<span class="text-sm font-bold text-slate-800">Resolution &amp; PDF report</span>
<span class="text-xs text-slate-500 block">Add outcome · generate MOH report</span>
</span>
<i data-lucide="chevron-right" class="w-4 h-4 text-slate-300"></i>
</button>
</section>
<!-- Secondary actions -->
<section class="grid grid-cols-3 gap-2.5">
<button onclick="openModal('mAssign')" class="bg-white border border-slate-200 rounded-xl py-3 flex flex-col items-center gap-1 hover:bg-slate-50 transition">
<i data-lucide="user-plus" class="w-5 h-5 text-slate-600"></i>
<span class="text-[11px] font-bold uppercase text-slate-600">Assign</span>
</button>
<button onclick="openModal('mEscalate')" class="bg-white border border-red-200 bg-red-50/40 rounded-xl py-3 flex flex-col items-center gap-1 hover:bg-red-50 transition">
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-500"></i>
<span class="text-[11px] font-bold uppercase text-red-600">Escalate</span>
</button>
<button onclick="openModal('mMore')" class="bg-white border border-slate-200 rounded-xl py-3 flex flex-col items-center gap-1 hover:bg-slate-50 transition">
<i data-lucide="more-horizontal" class="w-5 h-5 text-slate-600"></i>
<span class="text-[11px] font-bold uppercase text-slate-600">More</span>
</button>
</section>
</main>
<!-- Sticky primary action -->
<div class="fixed bottom-0 inset-x-0 z-30">
<div class="max-w-2xl mx-auto px-4 sm:px-6 pb-4">
<button class="w-full bg-navy text-white font-bold text-base rounded-2xl py-4 flex items-center justify-center gap-2 shadow-lg shadow-navy/25 hover:bg-blue transition">
<i data-lucide="check-circle-2" class="w-5 h-5"></i> Resolve Case
</button>
</div>
</div>
<!-- ============ MODALS ============ -->
<!-- Departments -->
<div id="mDepartments" class="modal fixed inset-0 z-50 bg-black/50 items-end sm:items-center justify-center p-0 sm:p-4" onclick="if(event.target===this)closeModal('mDepartments')">
<div class="modal-panel bg-white w-full sm:max-w-lg rounded-t-3xl sm:rounded-3xl max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between p-5 border-b border-slate-100">
<h3 class="font-bold text-slate-800 flex items-center gap-2"><i data-lucide="building-2" class="w-5 h-5 text-navy"></i> Departments &amp; Staff</h3>
<button onclick="closeModal('mDepartments')" class="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<div class="p-5 overflow-y-auto space-y-3">
<div class="flex items-center justify-between p-4 rounded-xl bg-slate-50">
<div>
<p class="text-sm font-bold text-slate-800">Emergency Medicine <span class="text-[10px] font-bold text-navy bg-blue-light px-1.5 py-0.5 rounded ml-1">PRIMARY</span></p>
<p class="text-xs text-slate-500 mt-0.5">Response received · accepted · Dr. Khalid Ahmed</p>
</div>
<i data-lucide="check-circle-2" class="w-6 h-6 text-emerald-500"></i>
</div>
<div class="flex items-center justify-between p-4 rounded-xl bg-amber-50 border border-amber-200">
<div>
<p class="text-sm font-bold text-slate-800">Nursing Services</p>
<p class="text-xs text-amber-600 mt-0.5">Awaiting response · 14h left · Nurse Huda Ali</p>
</div>
<button class="text-xs font-bold text-navy border border-navy px-3 py-1.5 rounded-lg hover:bg-blue-light">Remind</button>
</div>
<button class="w-full mt-2 text-sm font-bold text-navy hover:underline flex items-center justify-center gap-1.5"><i data-lucide="plus" class="w-4 h-4"></i> Add department / staff</button>
</div>
</div>
</div>
<!-- Activity -->
<div id="mActivity" class="modal fixed inset-0 z-50 bg-black/50 items-end sm:items-center justify-center p-0 sm:p-4" onclick="if(event.target===this)closeModal('mActivity')">
<div class="modal-panel bg-white w-full sm:max-w-lg rounded-t-3xl sm:rounded-3xl max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between p-5 border-b border-slate-100">
<h3 class="font-bold text-slate-800 flex items-center gap-2"><i data-lucide="activity" class="w-5 h-5 text-navy"></i> Activity timeline</h3>
<button onclick="closeModal('mActivity')" class="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<div class="p-5 overflow-y-auto">
<ol class="relative border-l-2 border-slate-100 ml-2 space-y-5">
<li class="ml-5"><span class="absolute -left-[9px] w-4 h-4 rounded-full bg-white border-[3px] border-navy"></span><p class="text-sm font-semibold text-slate-800">ER department submitted a response</p><p class="text-xs text-slate-400">Nov 13, 08:45 AM</p></li>
<li class="ml-5"><span class="absolute -left-[9px] w-4 h-4 rounded-full bg-white border-[3px] border-emerald-500"></span><p class="text-sm font-semibold text-slate-800">Patient contacted by phone</p><p class="text-xs text-slate-400">Nov 12, 02:15 PM</p></li>
<li class="ml-5"><span class="absolute -left-[9px] w-4 h-4 rounded-full bg-white border-[3px] border-orange-500"></span><p class="text-sm font-semibold text-slate-800">Escalated to ER Department Head</p><p class="text-xs text-slate-400">Nov 12, 11:30 AM</p></li>
<li class="ml-5"><span class="absolute -left-[9px] w-4 h-4 rounded-full bg-white border-[3px] border-blue"></span><p class="text-sm font-semibold text-slate-800">Assigned to Dr. Sara Mansoor</p><p class="text-xs text-slate-400">Nov 12, 10:02 AM</p></li>
<li class="ml-5"><span class="absolute -left-[9px] w-4 h-4 rounded-full bg-white border-[3px] border-slate-300"></span><p class="text-sm font-semibold text-slate-800">Complaint received via MOH Tawasul</p><p class="text-xs text-slate-400">Nov 12, 09:14 AM</p></li>
</ol>
</div>
</div>
</div>
<!-- AI -->
<div id="mAi" class="modal fixed inset-0 z-50 bg-black/50 items-end sm:items-center justify-center p-0 sm:p-4" onclick="if(event.target===this)closeModal('mAi')">
<div class="modal-panel bg-white w-full sm:max-w-lg rounded-t-3xl sm:rounded-3xl max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between p-5 border-b border-slate-100">
<h3 class="font-bold text-slate-800 flex items-center gap-2"><i data-lucide="sparkles" class="w-5 h-5 text-navy"></i> AI analysis</h3>
<button onclick="closeModal('mAi')" class="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<div class="p-5 overflow-y-auto space-y-4">
<div class="flex items-center justify-between p-4 rounded-xl bg-gradient-to-br from-blue-light to-white border border-slate-200">
<div>
<p class="text-xs font-semibold text-slate-400 uppercase">Sentiment</p>
<p class="text-sm font-bold text-slate-800">Frustrated · High intensity</p>
</div>
<div class="flex gap-1 items-end h-8">
<span class="w-1.5 h-3 bg-navy/30 rounded-full"></span><span class="w-1.5 h-5 bg-navy/50 rounded-full"></span><span class="w-1.5 h-7 bg-navy/70 rounded-full"></span><span class="w-1.5 h-8 bg-navy rounded-full"></span>
</div>
</div>
<div>
<p class="text-xs font-semibold text-slate-400 uppercase mb-1.5">Summary</p>
<p class="text-sm text-slate-700 leading-relaxed">Communication breakdown during high-acuity ER triage. The doctor was unavailable and nursing staff didn't update the family for 45+ minutes. Recurrence risk: medium.</p>
</div>
<div>
<p class="text-xs font-semibold text-slate-400 uppercase mb-1.5">Suggested actions</p>
<div class="space-y-2">
<div class="flex items-center gap-2 p-3 rounded-lg bg-slate-50 text-sm text-slate-700"><i data-lucide="check-square" class="w-4 h-4 text-navy shrink-0"></i> Refresh nursing handover protocol</div>
<div class="flex items-center gap-2 p-3 rounded-lg bg-slate-50 text-sm text-slate-700"><i data-lucide="check-square" class="w-4 h-4 text-navy shrink-0"></i> Deploy real-time patient-status board in ER</div>
</div>
</div>
</div>
</div>
</div>
<!-- Resolution -->
<div id="mResolution" class="modal fixed inset-0 z-50 bg-black/50 items-end sm:items-center justify-center p-0 sm:p-4" onclick="if(event.target===this)closeModal('mResolution')">
<div class="modal-panel bg-white w-full sm:max-w-lg rounded-t-3xl sm:rounded-3xl max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between p-5 border-b border-slate-100">
<h3 class="font-bold text-slate-800 flex items-center gap-2"><i data-lucide="file-text" class="w-5 h-5 text-navy"></i> Resolution &amp; PDF</h3>
<button onclick="closeModal('mResolution')" class="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<div class="p-5 overflow-y-auto space-y-4">
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Resolution notes</label>
<textarea rows="4" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="Describe what was done to resolve this case..."></textarea>
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Outcome</label>
<select class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none"><option>Resolved — process improved</option><option>Resolved — staff coached</option><option>Partially resolved</option></select>
</div>
<div class="flex items-center justify-between p-3 rounded-xl bg-slate-50">
<span class="text-sm text-slate-600 flex items-center gap-2"><i data-lucide="file-down" class="w-4 h-4 text-navy"></i> Generate MOH PDF report</span>
<button class="text-xs font-bold text-navy border border-navy px-3 py-1.5 rounded-lg hover:bg-blue-light">Generate</button>
</div>
</div>
<div class="p-5 border-t border-slate-100 flex gap-3">
<button onclick="closeModal('mResolution')" class="flex-1 px-4 py-3 border border-slate-200 rounded-xl font-semibold text-slate-600 hover:bg-slate-50">Cancel</button>
<button class="flex-1 px-4 py-3 bg-navy text-white rounded-xl font-bold hover:bg-blue flex items-center justify-center gap-2"><i data-lucide="save" class="w-4 h-4"></i> Save</button>
</div>
</div>
</div>
<!-- Assign -->
<div id="mAssign" class="modal fixed inset-0 z-50 bg-black/50 items-end sm:items-center justify-center p-0 sm:p-4" onclick="if(event.target===this)closeModal('mAssign')">
<div class="modal-panel bg-white w-full sm:max-w-md rounded-t-3xl sm:rounded-3xl">
<div class="flex items-center justify-between p-5 border-b border-slate-100">
<h3 class="font-bold text-slate-800 flex items-center gap-2"><i data-lucide="user-plus" class="w-5 h-5 text-navy"></i> Assign / Reassign</h3>
<button onclick="closeModal('mAssign')" class="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<div class="p-5 space-y-2">
<div class="flex items-center gap-3 p-3 rounded-xl border-2 border-navy bg-blue-light"><span class="w-9 h-9 rounded-full bg-navy text-white text-xs font-bold flex items-center justify-center">SM</span><div><p class="text-sm font-bold text-slate-800">Dr. Sara Mansoor</p><p class="text-xs text-slate-500">Patient Experience · current</p></div><i data-lucide="check-circle-2" class="w-5 h-5 text-navy ml-auto"></i></div>
<div class="flex items-center gap-3 p-3 rounded-xl border border-slate-200 hover:bg-slate-50"><span class="w-9 h-9 rounded-full bg-slate-200 text-slate-600 text-xs font-bold flex items-center justify-center">KA</span><div><p class="text-sm font-bold text-slate-800">Dr. Khalid Ahmed</p><p class="text-xs text-slate-500">Emergency Medicine</p></div></div>
</div>
</div>
</div>
<!-- Escalate -->
<div id="mEscalate" class="modal fixed inset-0 z-50 bg-black/50 items-end sm:items-center justify-center p-0 sm:p-4" onclick="if(event.target===this)closeModal('mEscalate')">
<div class="modal-panel bg-white w-full sm:max-w-md rounded-t-3xl sm:rounded-3xl">
<div class="flex items-center justify-between p-5 border-b border-slate-100">
<h3 class="font-bold text-slate-800 flex items-center gap-2"><i data-lucide="alert-triangle" class="w-5 h-5 text-red-500"></i> Escalate case</h3>
<button onclick="closeModal('mEscalate')" class="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<div class="p-5 space-y-4">
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Escalate to</label>
<select class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none"><option>Dr. Omar (ER Head)</option><option>Huda Ali (Nursing Manager)</option><option>PX Management</option></select>
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Reason</label>
<textarea rows="3" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="Why is this being escalated?"></textarea>
</div>
<button class="w-full px-4 py-3 bg-red-500 text-white rounded-xl font-bold hover:bg-red-600 flex items-center justify-center gap-2"><i data-lucide="send" class="w-4 h-4"></i> Send escalation</button>
</div>
</div>
</div>
<!-- More -->
<div id="mMore" class="modal fixed inset-0 z-50 bg-black/50 items-end sm:items-center justify-center p-0 sm:p-4" onclick="if(event.target===this)closeModal('mMore')">
<div class="modal-panel bg-white w-full sm:max-w-sm rounded-t-3xl sm:rounded-3xl p-2">
<button class="w-full text-left px-4 py-3 rounded-xl hover:bg-slate-50 flex items-center gap-3 text-slate-700"><i data-lucide="phone" class="w-5 h-5 text-slate-500"></i> Update patient contact</button>
<button class="w-full text-left px-4 py-3 rounded-xl hover:bg-slate-50 flex items-center gap-3 text-slate-700"><i data-lucide="message-square-plus" class="w-5 h-5 text-slate-500"></i> Add note</button>
<button class="w-full text-left px-4 py-3 rounded-xl hover:bg-slate-50 flex items-center gap-3 text-slate-700"><i data-lucide="shield-alert" class="w-5 h-5 text-slate-500"></i> OVR escalate</button>
<button class="w-full text-left px-4 py-3 rounded-xl hover:bg-slate-50 flex items-center gap-3 text-slate-700"><i data-lucide="rotate-ccw" class="w-5 h-5 text-slate-500"></i> Reopen case</button>
<div class="my-1 border-t border-slate-100"></div>
<button class="w-full text-left px-4 py-3 rounded-xl hover:bg-red-50 flex items-center gap-3 text-red-600"><i data-lucide="trash-2" class="w-5 h-5"></i> Delete case</button>
</div>
</div>
<script>
lucide.createIcons();
function openModal(id) {
var m = document.getElementById(id);
m.classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeModal(id) {
document.getElementById(id).classList.remove('open');
document.body.style.overflow = '';
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
document.querySelectorAll('.modal.open').forEach(function (m) { m.classList.remove('open'); });
document.body.style.overflow = '';
}
});
(function () {
var due = Date.now() + 18 * 3600 * 1000 + 42 * 60 * 1000;
var pill = document.getElementById('slaPill');
var txt = document.getElementById('slaText');
function tick() {
var diff = due - Date.now();
if (diff <= 0) { txt.textContent = 'Overdue'; return; }
txt.textContent = Math.floor(diff / 3600000) + 'h ' + Math.floor((diff % 3600000) / 60000) + 'm left';
}
tick(); setInterval(tick, 30000);
})();
</script>
</body>
</html>

View File

@ -34,6 +34,11 @@ dependencies = [
"google-auth-oauthlib>=1.2.3",
"user-agents>=2.2.0",
"polib>=1.2.0",
"sentry-sdk>=2.0.0",
"cryptography>=42.0.0",
"django-appconf>=1.0.0",
"django-cryptography>=1.1",
"django-ai-po>=0.1.2",
]
[project.optional-dependencies]

View File

@ -18,11 +18,14 @@ click==8.3.1
click-didyoumean==0.3.1
click-plugins==1.1.1.2
click-repl==0.3.0
cryptography==46.0.7
cron-descriptor==1.4.5
cssselect2==0.8.0
distro==1.9.0
django==6.0.1
django-appconf==1.2.0
django-celery-beat==2.9.0
django-cryptography==1.1
django-environ==0.12.0
django-extensions==4.1
django-filter==25.1
@ -133,9 +136,12 @@ vine==5.1.0
watchdog==6.0.0
wcwidth==0.2.14
weasyprint==67.0
matplotlib==3.11.0
webencodings==0.5.1
whitenoise==6.11.0
xlrd==2.0.2
yarl==1.22.0
zipp==3.23.0
zopfli==0.4.0
sentry-sdk==2.20.0
django-ai-po

315
scripts/db_sync.sh Executable file
View File

@ -0,0 +1,315 @@
#!/bin/bash
# ============================================================
# PX360 Database Sync Script
# ============================================================
# Copies the local Docker PostgreSQL database + media files
# to a production DB server.
#
# Usage:
# ./scripts/db_sync.sh # Dry-run: dump locally only
# ./scripts/db_sync.sh --deploy user@host # Full: dump → scp → restore
#
# Prerequisites:
# - Docker containers running locally (px360_db)
# - SSH access to the DB server
# - PostgreSQL client tools on the DB server (pg_restore, createdb)
# - .env.production file with DB_HOST, DB_USER, DB_NAME
# ============================================================
set -euo pipefail
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Paths
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
BACKUP_DIR="$PROJECT_DIR/backups"
# Default config (overridden by .env.production if available)
LOCAL_DB_CONTAINER="px360_db"
LOCAL_DB_USER="px360"
LOCAL_DB_NAME="px360"
PROD_DB_HOST=""
PROD_DB_USER="px360"
PROD_DB_NAME="px360"
PROD_SSH=""
# Tables to exclude from dump (dev-only data)
EXCLUDE_TABLES=(
"django_session"
"simulator_hisrequestlog"
"simulator_emaillog"
"simulator_smslog"
)
# Flags
DEPLOY=false
# ============================================================
# Parse arguments
# ============================================================
while [[ $# -gt 0 ]]; do
case $1 in
--deploy)
DEPLOY=true
PROD_SSH="$2"
shift 2
;;
--help|-h)
echo "Usage: ./scripts/db_sync.sh [--deploy user@db-server-ip]"
echo ""
echo " Without --deploy: Creates a local dump only (dry-run)"
echo " With --deploy: Dumps locally, copies to prod DB server, restores"
echo ""
echo "Example:"
echo " ./scripts/db_sync.sh # local dump"
echo " ./scripts/db_sync.sh --deploy root@10.10.1.100 # full deploy"
exit 0
;;
*)
echo -e "${RED}Unknown option: $1${NC}"
exit 1
;;
esac
done
# ============================================================
# Load production config
# ============================================================
if [ "$DEPLOY" = true ]; then
if [ -z "$PROD_SSH" ]; then
echo -e "${RED}Error: --deploy requires SSH target (user@host)${NC}"
exit 1
fi
ENV_FILE="$PROJECT_DIR/.env.production"
if [ -f "$ENV_FILE" ]; then
PROD_DB_HOST=$(grep -E "^DB_HOST=" "$ENV_FILE" | cut -d= -f2 || echo "")
PROD_DB_USER=$(grep -E "^DB_USER=" "$ENV_FILE" | cut -d= -f2 || echo "px360")
PROD_DB_NAME=$(grep -E "^DB_NAME=" "$ENV_FILE" | cut -d= -f2 || echo "px360")
if [ -z "$PROD_DB_HOST" ]; then
PROD_DB_HOST=$(grep -E "^DATABASE_URL=" "$ENV_FILE" | sed -E 's/.*@([^:]+):.*/\1/' || echo "")
fi
fi
if [ -z "$PROD_DB_HOST" ]; then
echo -e "${YELLOW}Warning: DB_HOST not found in .env.production${NC}"
echo -e "Enter DB server IP/hostname: "
read -r PROD_DB_HOST
fi
echo -e "${BLUE}Production target:${NC}"
echo -e " SSH: $PROD_SSH"
echo -e " DB Host: $PROD_DB_HOST"
echo -e " DB User: $PROD_DB_USER"
echo -e " DB Name: $PROD_DB_NAME"
echo ""
echo -e "${YELLOW}This will OVERWRITE production database data. Continue? (y/N)${NC}"
read -r CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
fi
# ============================================================
# Setup
# ============================================================
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DB_DUMP="$BACKUP_DIR/px360_${TIMESTAMP}.dump"
MEDIA_DUMP="$BACKUP_DIR/media_${TIMESTAMP}.tar.gz"
echo ""
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} PX360 Database Sync${NC}"
echo -e "${BLUE} $(date '+%Y-%m-%d %H:%M:%S')${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# ============================================================
# Step 1: Dump local database
# ============================================================
echo -e "${BLUE}[1/5] Dumping local database...${NC}"
# Build exclude flags
EXCLUDE_FLAGS=""
for table in "${EXCLUDE_TABLES[@]}"; do
EXCLUDE_FLAGS="$EXCLUDE_FLAGS --exclude-table-data=$table"
done
# Check if Docker DB is running
if ! docker ps --format '{{.Names}}' | grep -q "^${LOCAL_DB_CONTAINER}$"; then
echo -e "${YELLOW}Docker DB container not found. Trying local PostgreSQL...${NC}"
# Fall back to local pg_dump (for SQLite → PG migration or local PG)
if command -v pg_dump &> /dev/null; then
PGPASSWORD="${LOCAL_DB_USER}" pg_dump -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" \
--no-owner --no-privileges -Fc $EXCLUDE_FLAGS \
-f "$DB_DUMP" 2>/dev/null || {
echo -e "${RED}Failed to dump database. Neither Docker nor local PostgreSQL available.${NC}"
exit 1
}
else
echo -e "${RED}No Docker DB container and no pg_dump found.${NC}"
exit 1
fi
else
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" \
--no-owner --no-privileges -Fc $EXCLUDE_FLAGS > "$DB_DUMP"
fi
DB_SIZE=$(du -h "$DB_DUMP" | cut -f1)
echo -e "${GREEN} Database dump: $DB_DUMP ($DB_SIZE)${NC}"
# Show table counts
echo -e "${BLUE} Table counts:${NC}"
if docker ps --format '{{.Names}}' | grep -q "^${LOCAL_DB_CONTAINER}$"; then
docker exec "$LOCAL_DB_CONTAINER" psql -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" -t -c "
SELECT ' ' || relname || ': ' || n_live_tup
FROM pg_stat_user_tables
WHERE n_live_tup > 0
ORDER BY n_live_tup DESC
LIMIT 10;
" 2>/dev/null || true
fi
# ============================================================
# Step 2: Dump media files
# ============================================================
echo ""
echo -e "${BLUE}[2/5] Dumping media files...${NC}"
if [ -d "$PROJECT_DIR/media" ] && [ "$(ls -A "$PROJECT_DIR/media" 2>/dev/null)" ]; then
tar -czf "$MEDIA_DUMP" -C "$PROJECT_DIR/media" .
MEDIA_SIZE=$(du -h "$MEDIA_DUMP" | cut -f1)
echo -e "${GREEN} Media dump: $MEDIA_DUMP ($MEDIA_SIZE)${NC}"
else
echo -e "${YELLOW} No media files found, skipping.${NC}"
MEDIA_DUMP=""
fi
# ============================================================
# Step 3: Cleanup old local backups (keep last 3)
# ============================================================
echo ""
echo -e "${BLUE}[3/5] Cleaning old local backups (keeping last 3)...${NC}"
ls -t "$BACKUP_DIR"/px360_*.dump 2>/dev/null | tail -n +4 | while read -r old_file; do
rm -f "$old_file"
echo -e " ${YELLOW}Removed: $(basename "$old_file")${NC}"
done
ls -t "$BACKUP_DIR"/media_*.tar.gz 2>/dev/null | tail -n +4 | while read -r old_file; do
rm -f "$old_file"
echo -e " ${YELLOW}Removed: $(basename "$old_file")${NC}"
done
# ============================================================
# Dry-run check
# ============================================================
if [ "$DEPLOY" = false ]; then
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} Dry-run complete!${NC}"
echo -e "${GREEN} Dump saved locally.${NC}"
echo -e "${GREEN} Run with --deploy user@host to push to prod.${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "Database: ${DB_DUMP} (${DB_SIZE})"
if [ -n "$MEDIA_DUMP" ]; then
echo -e "Media: ${MEDIA_DUMP} (${MEDIA_SIZE})"
fi
exit 0
fi
# ============================================================
# Step 4: Copy to production DB server
# ============================================================
echo ""
echo -e "${BLUE}[4/5] Copying to production server ($PROD_SSH)...${NC}"
echo -e " Copying database dump..."
scp -q "$DB_DUMP" "${PROD_SSH}:/tmp/px360_restore.dump"
echo -e "${GREEN} Database dump uploaded.${NC}"
if [ -n "$MEDIA_DUMP" ]; then
echo -e " Copying media files..."
scp -q "$MEDIA_DUMP" "${PROD_SSH}:/tmp/px360_media.tar.gz"
echo -e "${GREEN} Media files uploaded.${NC}"
fi
# ============================================================
# Step 5: Restore on production DB server
# ============================================================
echo ""
echo -e "${BLUE}[5/5] Restoring on production database...${NC}"
ssh "$PROD_SSH" bash -s << REMOTE_SCRIPT
set -e
DB_USER="${PROD_DB_USER}"
DB_NAME="${PROD_DB_NAME}"
DB_HOST="${PROD_DB_HOST}"
echo " Creating database if not exists..."
createdb -U "\$DB_USER" "\$DB_NAME" 2>/dev/null || echo " Database already exists."
echo " Restoring database (this may take a minute)..."
pg_restore -U "\$DB_USER" -d "\$DB_NAME" \
--no-owner --no-privileges --clean --if-exists \
--no-tablespaces \
/tmp/px360_restore.dump 2>&1 | grep -v "does not exist" || true
echo " Verifying restore..."
psql -U "\$DB_USER" -d "\$DB_NAME" -t -c "
SELECT ' Total tables: ' || count(*) FROM pg_stat_user_tables;
" 2>/dev/null || true
psql -U "\$DB_USER" -d "\$DB_NAME" -t -c "
SELECT ' ' || relname || ': ' || n_live_tup
FROM pg_stat_user_tables
WHERE n_live_tup > 0
ORDER BY n_live_tup DESC
LIMIT 10;
" 2>/dev/null || true
echo " Cleaning up temp files..."
rm -f /tmp/px360_restore.dump /tmp/px360_media.tar.gz
echo " Restore complete."
REMOTE_SCRIPT
# ============================================================
# Media restore on app server (if separate from DB server)
# ============================================================
if [ -n "$MEDIA_DUMP" ]; then
echo ""
echo -e "${YELLOW}Note: Media files were uploaded to the DB server at /tmp/px360_media.tar.gz${NC}"
echo -e "${YELLOW}If your app server is separate, copy and extract there:${NC}"
echo -e " scp ${PROD_SSH}:/tmp/px360_media.tar.gz app-server:/tmp/"
echo -e " ssh app-server 'mkdir -p /path/to/media && tar -xzf /tmp/px360_media.tar.gz -C /path/to/media'"
fi
# ============================================================
# Summary
# ============================================================
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} Sync complete!${NC}"
echo -e "${GREEN} $(date '+%Y-%m-%d %H:%M:%S')${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "Local backup: ${DB_DUMP} (${DB_SIZE})"
if [ -n "$MEDIA_DUMP" ]; then
echo -e "Media backup: ${MEDIA_DUMP} (${MEDIA_SIZE})"
fi
echo ""
echo -e "${YELLOW}Next steps:${NC}"
echo -e " 1. Run migrations on app server: docker compose -f docker-compose.prod.yml exec web python manage.py migrate"
echo -e " 2. Clear sessions: docker compose -f docker-compose.prod.yml exec web python manage.py shell -c \"from django.contrib.sessions.models import Session; Session.objects.all().delete()\""
echo -e " 3. Restart services: docker compose -f docker-compose.prod.yml restart"

View File

@ -0,0 +1,39 @@
(function () {
"use strict";
window.PX360 = window.PX360 || {};
// Series value formatter: shows the raw count on each slice.
var countFormatter = function (val, opts) {
return opts.w.globals.series[opts.seriesIndex];
};
// Grand total formatter for the donut center.
var totalFormatter = function (w) {
return w.globals.seriesTotals.reduce(function (a, b) { return a + b; }, 0);
};
// Spread into donut chart configs: slice counts + center total.
PX360.donutValueLabels = function () {
return {
dataLabels: { enabled: true, formatter: countFormatter },
plotOptions: {
pie: {
donut: {
labels: {
show: true,
total: { show: true, label: "Total", formatter: totalFormatter }
}
}
}
}
};
};
// Spread into pie chart configs: slice counts only (no center).
PX360.pieValueLabels = function () {
return {
dataLabels: { enabled: true, formatter: countFormatter }
};
};
})();

9
static/vendor/datastar/datastar.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -82,7 +82,7 @@
<select name="hospital_id" class="w-full px-4 py-2 border-2 border-blue-100 rounded-xl text-navy focus:outline-none focus:ring-2 focus:ring-blue transition">
<option value="">-- {% trans "Select Hospital" %} --</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}">{{ hospital.name }}</option>
<option value="{{ hospital.id }}">{{ hospital.get_localized_name }}</option>
{% endfor %}
</select>
</div>

View File

@ -308,7 +308,7 @@
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition bg-white">
<option value="">{% trans "Select Hospital" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}">{{ hospital.name }}</option>
<option value="{{ hospital.id }}">{{ hospital.get_localized_name }}</option>
{% endfor %}
</select>
</div>

View File

@ -138,7 +138,20 @@
</form>
</div>
<div>
<div class="space-y-6">
<!-- My Performance -->
<div class="bg-navy rounded-2xl p-6 text-white">
<h6 class="font-bold mb-2 flex items-center gap-2">
<i data-lucide="bar-chart-2" class="w-4 h-4 text-blue-200"></i>
{% trans "My Performance" %}
</h6>
<p class="text-sm text-slate-200 mb-4">{% trans "View your performance metrics and evaluations" %}</p>
<a href="{% url 'dashboard:my_performance' %}" class="inline-flex items-center gap-2 px-4 py-2 bg-white text-navy rounded-xl font-bold text-sm hover:bg-blue-50 transition">
<i data-lucide="bar-chart-2" class="w-4 h-4"></i>
{% trans "View Performance" %}
</a>
</div>
<div class="bg-light rounded-2xl p-6">
<h6 class="font-bold text-navy mb-4 flex items-center gap-2">
<i data-lucide="lightbulb" class="w-4 h-4 text-blue"></i>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,617 @@
{% load i18n %}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ report.kpi_id }} - {{ report.indicator_title }} - PX360</title>
<style>
@page {
size: A4 landscape;
margin: 1.5cm;
@top-center {
content: "{{ report.hospital.name }} — KPI Report {{ report.kpi_id }}";
font-size: 8pt;
color: #94a3b8;
}
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-size: 8pt;
color: #94a3b8;
}
@bottom-left {
content: "Generated {{ generated_at }}";
font-size: 8pt;
color: #94a3b8;
}
}
@page :first {
@top-center { content: ""; }
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 9pt;
line-height: 1.5;
color: #1f2937;
}
/* Header */
.report-header {
border-bottom: 3px solid #005696;
padding-bottom: 12px;
margin-bottom: 20px;
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.report-header .header-info {
flex: 1;
}
.report-header .header-logo {
height: 50px;
width: auto;
flex-shrink: 0;
margin-left: 16px;
}
.report-header h1 {
font-size: 18pt;
color: #005696;
margin: 0;
}
.report-header .kpi-badge {
display: inline-block;
background: #005696;
color: white;
padding: 3px 10px;
border-radius: 4px;
font-size: 10pt;
font-weight: bold;
margin-bottom: 6px;
}
.report-header .subtitle {
font-size: 10pt;
color: #64748b;
margin-top: 4px;
}
/* Summary cards */
.summary-grid {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.summary-card {
flex: 1;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
text-align: center;
}
.summary-card .label {
font-size: 8pt;
color: #64748b;
text-transform: uppercase;
font-weight: bold;
margin-bottom: 4px;
}
.summary-card .value {
font-size: 20pt;
font-weight: bold;
color: #005696;
}
.summary-card .value.danger { color: #dc2626; }
.summary-card .value.success { color: #16a34a; }
.summary-card .detail {
font-size: 8pt;
color: #94a3b8;
margin-top: 2px;
}
/* Progress bar */
.progress-container {
margin-bottom: 20px;
padding: 12px;
background: #f8fafc;
border-radius: 8px;
}
.progress-label {
display: flex;
justify-content: space-between;
font-size: 8pt;
font-weight: bold;
color: #64748b;
text-transform: uppercase;
margin-bottom: 6px;
}
.progress-bar {
height: 12px;
background: #e2e8f0;
border-radius: 6px;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
border-radius: 6px;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
table th {
background: #005696;
color: white;
font-size: 8pt;
font-weight: bold;
text-transform: uppercase;
padding: 6px 4px;
text-align: center;
border: 1px solid #005696;
}
table td {
padding: 5px 4px;
border: 1px solid #e2e8f0;
font-size: 8.5pt;
text-align: center;
}
table tr:nth-child(even) td { background: #f8fafc; }
table .row-label {
text-align: left;
font-weight: bold;
color: #1f2937;
background: #f1f5f9 !important;
}
table .total-row td {
background: #005696 !important;
color: white !important;
font-weight: bold;
}
table .below-target {
color: #dc2626;
font-weight: bold;
}
/* Metadata grid */
.metadata-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
}
.metadata-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 6px 12px;
min-width: 120px;
}
.metadata-item .label {
font-size: 7pt;
color: #94a3b8;
text-transform: uppercase;
font-weight: bold;
}
.metadata-item .value {
font-size: 9pt;
color: #1f2937;
font-weight: 600;
}
/* Charts */
.charts-row {
display: flex;
gap: 20px;
margin-bottom: 20px;
page-break-inside: avoid;
}
.chart-box {
flex: 1;
text-align: center;
}
.chart-box img {
max-width: 100%;
height: auto;
}
/* Breakdown cards */
.breakdown-grid {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.breakdown-card {
flex: 1;
min-width: 180px;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px;
page-break-inside: avoid;
}
.breakdown-card h4 {
font-size: 9pt;
color: #005696;
margin-bottom: 6px;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 4px;
}
.breakdown-card .stat {
font-size: 8.5pt;
color: #64748b;
margin-bottom: 2px;
}
.breakdown-card .stat strong { color: #1f2937; }
/* Section heading */
.section-title {
font-size: 11pt;
color: #005696;
font-weight: bold;
margin-bottom: 10px;
margin-top: 20px;
border-left: 4px solid #005696;
padding-left: 8px;
}
/* AI Analysis */
.ai-section {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px;
margin-bottom: 20px;
page-break-inside: avoid;
}
.ai-section h4 {
font-size: 9pt;
color: #005696;
margin-bottom: 6px;
text-transform: uppercase;
font-weight: bold;
}
.ai-section p { font-size: 9pt; margin-bottom: 8px; }
.ai-section ul { padding-left: 18px; margin-bottom: 8px; }
.ai-section li { font-size: 9pt; margin-bottom: 3px; }
.findings-box {
background: white;
border-left: 3px solid #007bbd;
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 0 4px 4px 0;
}
.recommendation-box {
background: #ecfdf5;
border-left: 3px solid #16a34a;
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 0 4px 4px 0;
}
/* Sign-off */
.signoff-grid {
display: flex;
gap: 16px;
margin-top: 30px;
page-break-inside: avoid;
}
.signoff-box {
flex: 1;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 20px 12px 12px 12px;
min-height: 80px;
}
.signoff-box .role {
font-size: 8pt;
color: #64748b;
text-transform: uppercase;
font-weight: bold;
text-align: center;
margin-bottom: 20px;
}
.signoff-box .line {
border-top: 1px solid #94a3b8;
margin-bottom: 4px;
}
.signoff-box .line-label {
font-size: 7pt;
color: #94a3b8;
}
/* Footer */
.report-footer {
margin-top: 30px;
padding-top: 10px;
border-top: 1px solid #e2e8f0;
font-size: 8pt;
color: #94a3b8;
text-align: center;
}
.page-break { page-break-before: always; }
.no-break { page-break-inside: avoid; }
</style>
</head>
<body>
<!-- Report Header -->
<div class="report-header">
<div class="header-info">
<div class="kpi-badge">{{ report.kpi_id }}</div>
<h1>{{ report.indicator_title }}</h1>
<div class="subtitle">
{{ report.hospital.name }} &nbsp;|&nbsp;
{{ report.report_period_display }} &nbsp;|&nbsp;
{% trans "Generated" %}: {{ report.generated_at|default:report.created_at|date:"M d, Y H:i" }}
</div>
</div>
{% if logo_path %}
<img src="{{ logo_path }}" alt="{{ report.hospital.name }}" class="header-logo">
{% endif %}
</div>
<!-- Executive Summary Cards -->
<div class="summary-grid">
<div class="summary-card">
<div class="label">{% trans "Overall Result" %}</div>
<div class="value {% if report.overall_result < report.threshold_percentage %}danger{% elif report.overall_result >= report.target_percentage %}success{% endif %}">
{{ report.overall_result|floatformat:1 }}%
</div>
</div>
<div class="summary-card">
<div class="label">{{ report.numerator_label }}</div>
<div class="value">{{ report.total_numerator }}</div>
</div>
<div class="summary-card">
<div class="label">{{ report.denominator_label }}</div>
<div class="value">{{ report.total_denominator }}</div>
</div>
<div class="summary-card">
<div class="label">{% trans "Target" %}</div>
<div class="value" style="color:#16a34a;">{{ report.target_percentage|floatformat:0 }}%</div>
</div>
<div class="summary-card">
<div class="label">{% trans "Threshold" %}</div>
<div class="value" style="color:#dc2626;">{{ report.threshold_percentage|floatformat:0 }}%</div>
</div>
</div>
<!-- Progress Bar -->
<div class="progress-container">
<div class="progress-label">
<span>{% trans "Performance vs Target" %}</span>
<span>{{ report.overall_result|floatformat:1 }}% / {{ report.target_percentage|floatformat:0 }}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{ report.overall_result|floatformat:0|stringformat:'s' }}%; background: {% if report.overall_result < report.threshold_percentage %}#dc2626{% elif report.overall_result >= report.target_percentage %}#16a34a{% else %}#f59e0b{% endif %};"></div>
</div>
</div>
<!-- Monthly Data Table -->
<div class="section-title">{% trans "Monthly Performance Data" %}</div>
<table>
<thead>
<tr>
<th style="width:140px;">{% trans "Month" %}</th>
<th>Jan</th><th>Feb</th><th>Mar</th><th>Apr</th>
<th>May</th><th>Jun</th><th>Jul</th><th>Aug</th>
<th>Sep</th><th>Oct</th><th>Nov</th><th>Dec</th>
<th style="background:#003d5c;">{% trans "TOTAL" %}</th>
</tr>
</thead>
<tbody>
<!-- Numerator row -->
<tr>
<td class="row-label">{{ report.numerator_label }}</td>
{% for m in monthly_data %}<td>{% if m %}{{ m.numerator }}{% else %}-{% endif %}</td>{% endfor %}
<td>{{ report.total_numerator }}</td>
</tr>
<!-- Denominator row -->
<tr>
<td class="row-label">{{ report.denominator_label }}</td>
{% for m in monthly_data %}<td>{% if m %}{{ m.denominator }}{% else %}-{% endif %}</td>{% endfor %}
<td>{{ report.total_denominator }}</td>
</tr>
<!-- Result % row -->
<tr>
<td class="row-label">{% trans "Result %" %}</td>
{% for m in monthly_data %}
<td {% if m and m.percentage < report.threshold_percentage %}class="below-target"{% endif %}>
{% if m %}{{ m.percentage|floatformat:1 }}{% else %}-{% endif %}
</td>
{% endfor %}
<td><strong>{{ report.overall_result|floatformat:1 }}</strong></td>
</tr>
<!-- Target row -->
<tr>
<td class="row-label">{% trans "Target" %}</td>
<td colspan="12">{{ report.target_percentage|floatformat:0 }}%</td>
<td>{{ report.target_percentage|floatformat:0 }}%</td>
</tr>
<!-- Threshold row -->
<tr>
<td class="row-label">{% trans "Threshold" %}</td>
<td colspan="12">{{ report.threshold_percentage|floatformat:0 }}%</td>
<td>{{ report.threshold_percentage|floatformat:0 }}%</td>
</tr>
</tbody>
</table>
<!-- Metadata -->
<div class="metadata-grid">
<div class="metadata-item">
<div class="label">{% trans "Category" %}</div>
<div class="value">{{ report.category }}</div>
</div>
<div class="metadata-item">
<div class="label">{% trans "KPI Type" %}</div>
<div class="value">{{ report.kpi_type }}</div>
</div>
<div class="metadata-item">
<div class="label">{% trans "Risk Level" %}</div>
<div class="value">{{ report.risk_level }}</div>
</div>
<div class="metadata-item">
<div class="label">{% trans "Dimension" %}</div>
<div class="value">{{ report.dimension }}</div>
</div>
<div class="metadata-item">
<div class="label">{% trans "Data Collection" %}</div>
<div class="value">{{ report.data_collection_method }}</div>
</div>
<div class="metadata-item">
<div class="label">{% trans "Frequency" %}</div>
<div class="value">{{ report.reporting_frequency }}</div>
</div>
{% if report.collector_name %}
<div class="metadata-item">
<div class="label">{% trans "Collector" %}</div>
<div class="value">{{ report.collector_name }}</div>
</div>
{% endif %}
{% if report.analyzer_name %}
<div class="metadata-item">
<div class="label">{% trans "Analyzer" %}</div>
<div class="value">{{ report.analyzer_name }}</div>
</div>
{% endif %}
</div>
<!-- Charts -->
{% if trend_chart or source_chart %}
<div class="charts-row">
{% if trend_chart %}
<div class="chart-box">
<img src="data:image/png;base64,{{ trend_chart }}" alt="Monthly Performance Trend" />
</div>
{% endif %}
{% if source_chart %}
<div class="chart-box" style="max-width: 520px;">
<img src="data:image/png;base64,{{ source_chart }}" alt="Complaints by Source" />
</div>
{% endif %}
</div>
{% endif %}
<!-- Department Breakdown -->
{% if department_breakdowns %}
<div class="section-title">{% trans "Department Breakdown" %}</div>
<div class="breakdown-grid">
{% for dept in department_breakdowns %}
<div class="breakdown-card">
<h4>{{ dept.get_department_category_display }}</h4>
<div class="stat"><strong>{% trans "Complaints" %}:</strong> {{ dept.complaint_count }}</div>
<div class="stat"><strong>{% trans "Resolved" %}:</strong> {{ dept.resolved_count }}</div>
{% if dept.avg_resolution_days %}
<div class="stat"><strong>{% trans "Avg Resolution" %}:</strong> {{ dept.avg_resolution_days|floatformat:1 }} {% trans "days" %}</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
<!-- Location Breakdown -->
{% if location_breakdowns %}
<div class="section-title">{% trans "Location Breakdown" %}</div>
<div class="breakdown-grid">
{% for loc in location_breakdowns %}
<div class="breakdown-card">
<h4>{{ loc.location_type }}</h4>
<div class="stat"><strong>{% trans "Complaints" %}:</strong> {{ loc.complaint_count }}</div>
<div class="stat"><strong>{% trans "Share" %}:</strong> {{ loc.percentage|floatformat:1 }}%</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- AI Analysis -->
{% if ai_analysis %}
<div class="page-break"></div>
<div class="section-title">{% trans "AI-Generated Analysis" %}</div>
{% if ai_analysis.executive_summary %}
<div class="ai-section">
<h4>{% trans "Executive Summary" %}</h4>
<p>{{ ai_analysis.executive_summary }}</p>
</div>
{% endif %}
{% if ai_analysis.performance_analysis %}
<div class="ai-section">
<h4>{% trans "Performance Analysis" %}</h4>
<p>{{ ai_analysis.performance_analysis }}</p>
</div>
{% endif %}
{% if ai_analysis.key_findings %}
<div class="ai-section">
<h4>{% trans "Key Findings" %}</h4>
{% for finding in ai_analysis.key_findings %}
<div class="findings-box">{{ finding }}</div>
{% endfor %}
</div>
{% endif %}
{% if ai_analysis.reasons_for_delays %}
<div class="ai-section">
<h4>{% trans "Reasons for Delays" %}</h4>
<ul>
{% for reason in ai_analysis.reasons_for_delays %}
<li>{{ reason }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if ai_analysis.recommendations %}
<div class="ai-section">
<h4>{% trans "Recommendations" %}</h4>
{% for rec in ai_analysis.recommendations %}
<div class="recommendation-box">{{ rec }}</div>
{% endfor %}
</div>
{% endif %}
{% if ai_analysis.comparison_to_target %}
<div class="ai-section">
<h4>{% trans "Comparison to Target" %}</h4>
<p>{{ ai_analysis.comparison_to_target }}</p>
</div>
{% endif %}
{% endif %}
<!-- Sign-off Section -->
<div class="signoff-grid">
<div class="signoff-box">
<div class="role">{% trans "Prepared By" %}</div>
<div class="line"></div>
<div class="line-label">{% trans "Name & Signature" %}</div>
</div>
<div class="signoff-box">
<div class="role">{% trans "Reviewed By" %}</div>
<div class="line"></div>
<div class="line-label">{% trans "Name & Signature" %}</div>
</div>
<div class="signoff-box">
<div class="role">{% trans "Approved By" %}</div>
<div class="line"></div>
<div class="line-label">{% trans "Name & Signature" %}</div>
</div>
</div>
<!-- Footer -->
<div class="report-footer">
PX360 — Patient Experience Management System &nbsp;|&nbsp;
{{ report.hospital.name }} &nbsp;|&nbsp;
{% trans "Report ID" %}: {{ report.id }} &nbsp;|&nbsp;
{% trans "Generated" %}: {{ generated_at }}
</div>
</body>
</html>

View File

@ -62,16 +62,27 @@
{% else %}bg-slate-100 text-slate-600{% endif %}">
{{ appreciation.get_status_display }}
</span>
{% if can_send %}
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase bg-amber-100 text-amber-700">
<i data-lucide="alert-circle" class="w-3 h-3 inline"></i> {% trans "Not Sent to Dept" %}
</span>
{% endif %}
</div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<i data-lucide="heart" class="w-7 h-7 text-pink-500"></i>
{% trans "Appreciation Detail" %}
</h1>
<div class="flex items-center gap-3">
<a href="{% url 'appreciation:appreciation_pdf' pk=appreciation.pk %}" target="_blank"
class="inline-flex items-center gap-1.5 px-3 py-1.5 border border-slate-200 rounded-lg text-xs font-semibold text-slate hover:border-navy hover:text-navy transition">
<i data-lucide="file-text" class="w-3.5 h-3.5"></i> {% trans "PDF" %}
</a>
<span class="text-xs text-slate-400">
{{ appreciation.created_at|date:"Y-m-d H:i" }} &mdash; {{ appreciation.hospital.name }}
{{ appreciation.created_at|date:"Y-m-d H:i" }} &mdash; {{ appreciation.hospital.get_localized_name }}
</span>
</div>
</div>
</header>
<nav class="bg-white px-6 flex gap-6 border-b shadow-sm mb-6 rounded-t-2xl">
@ -151,7 +162,7 @@
<div>
<p class="text-sm font-bold text-navy">{{ appreciation.get_recipient_name }}</p>
{% if appreciation.department %}
<p class="text-xs text-slate">{{ appreciation.department.name }}</p>
<p class="text-xs text-slate">{{ appreciation.department.get_localized_name }}</p>
{% endif %}
</div>
</div>
@ -279,7 +290,7 @@
<select name="staff" id="id_staff" data-tomselect class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy/20 focus:border-navy outline-none">
<option value="">{% trans "Select staff..." %}</option>
{% for s in staff_list %}
<option value="{{ s.id }}" data-department="{{ s.department.id }}">{{ s.first_name }} {{ s.last_name }}{% if s.employee_id %} ({{ s.employee_id }}){% endif %}{% if s.department %} - {{ s.department.name }}{% endif %}</option>
<option value="{{ s.id }}" data-department="{{ s.department.id }}">{{ s.first_name }} {{ s.last_name }}{% if s.employee_id %} ({{ s.employee_id }}){% endif %}{% if s.department %} - {{ s.department.get_localized_name }}{% endif %}</option>
{% endfor %}
</select>
</div>
@ -288,7 +299,7 @@
<select name="department" id="id_department" data-tomselect class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy/20 focus:border-navy outline-none">
<option value="">{% trans "Auto from staff" %}</option>
{% for d in departments %}
<option value="{{ d.id }}">{{ d.name }}</option>
<option value="{{ d.id }}">{{ d.get_localized_name }}</option>
{% endfor %}
</select>
</div>
@ -297,7 +308,7 @@
<select name="category" class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy/20 focus:border-navy outline-none">
<option value="">{% trans "Select category..." %}</option>
{% for c in categories %}
<option value="{{ c.id }}" {% if appreciation.category and appreciation.category.id == c.id %}selected{% endif %}>{{ c.name_en }}</option>
<option value="{{ c.id }}" {% if appreciation.category and appreciation.category.id == c.id %}selected{% endif %}>{{ c.get_localized_name }}</option>
{% endfor %}
</select>
</div>
@ -312,53 +323,25 @@
{% if can_send %}
<section class="bg-white rounded-2xl p-6 shadow-sm border border-blue-200">
<h3 class="font-bold text-navy mb-4 text-sm flex items-center gap-2">
<i data-lucide="send" class="w-4 h-4"></i> {% trans "Send Appreciation" %}
<i data-lucide="send" class="w-4 h-4"></i> {% trans "Send to Department" %}
</h3>
<form method="post" action="{% url 'appreciation:appreciation_send' pk=appreciation.pk %}">
{% csrf_token %}
<p class="text-slate text-xs mb-4">{% trans "Configure recipients and customize the message before sending." %}</p>
<div class="space-y-3 mb-4">
<div class="flex items-center justify-between p-3 bg-slate-50 rounded-xl">
<div>
<p class="text-xs font-bold text-navy">{% trans "Notify Manager" %}</p>
</div>
<label class="toggle-switch active" onclick="this.classList.toggle('active')">
<input type="checkbox" name="send_to_manager" class="hidden" value="on" checked>
</label>
</div>
<div class="flex items-center justify-between p-3 bg-slate-50 rounded-xl">
<div>
<p class="text-xs font-bold text-navy">{% trans "Notify Dept" %}</p>
</div>
<label class="toggle-switch active" onclick="this.classList.toggle('active')">
<input type="checkbox" name="send_to_department" class="hidden" value="on" checked>
</label>
</div>
</div>
<div class="mb-3">
<label class="block text-[10px] font-bold text-slate uppercase tracking-wider mb-1">{% trans "Custom Message" %}</label>
<textarea name="custom_message" rows="3" class="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy/20 focus:border-navy outline-none resize-none"
placeholder="{% trans 'Add a personal note...' %}">{% if appreciation.ai_analysis.suggested_response_en %}{{ appreciation.ai_analysis.suggested_response_en }}{% endif %}</textarea>
</div>
<div class="mb-3">
<label class="block text-[10px] font-bold text-slate uppercase tracking-wider mb-1">{% trans "CC Recipients" %}</label>
<input type="text" name="cc_emails" class="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy/20 focus:border-navy outline-none"
placeholder="{% trans 'email@example.com' %}">
</div>
<button type="submit" class="w-full px-4 py-2.5 bg-navy text-white rounded-xl font-bold hover:bg-blue transition text-sm inline-flex items-center justify-center gap-2">
<i data-lucide="send" class="w-4 h-4"></i> {% trans "Send Appreciation" %}
<p class="text-slate text-xs mb-4">{% trans "Route this appreciation to a department or a specific person for acknowledgment." %}</p>
<button onclick="showSendModal('{{ appreciation.id }}', 'appreciation'{% if appreciation.department_id %}, '{{ appreciation.department_id }}'{% endif %})"
class="w-full px-4 py-2.5 bg-navy text-white rounded-xl font-bold hover:bg-blue transition text-sm inline-flex items-center justify-center gap-2">
<i data-lucide="send" class="w-4 h-4"></i> {% trans "Send to Department" %}
</button>
</form>
</section>
{% endif %}
</div>
</main>
{% include "components/send_to_modal.html" with users=send_to_users departments=hospital_departments %}
<script>
function switchTab(tabName) {
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.add('hidden'));
document.getElementById('panel-' + tabName).classList.remove('hidden');
document.querySelectorAll('nav button').forEach(tab => {
document.querySelectorAll('main nav button').forEach(tab => {
tab.classList.remove('tab-active');
tab.classList.add('tab-inactive');
});

View File

@ -220,10 +220,10 @@
<span class="text-slate-400">-</span>
{% endif %}
</td>
<td class="px-6 py-4 text-slate-600">{{ apr.department.name|default:"-" }}</td>
<td class="px-6 py-4 text-slate-600">{{ apr.department.get_localized_name|default:"-" }}</td>
<td class="px-6 py-4">
{% if apr.category %}
<span class="bg-blue-100 text-blue-700 px-2 py-0.5 rounded text-[10px] font-bold">{{ apr.category.name_en }}</span>
<span class="bg-blue-100 text-blue-700 px-2 py-0.5 rounded text-[10px] font-bold">{{ apr.category.get_localized_name }}</span>
{% else %}
<span class="text-slate-400">-</span>
{% endif %}

View File

@ -0,0 +1,27 @@
{% extends "shared/letterhead_pdf_base.html" %}
{% load i18n %}
{% block pdf_content %}
<table class="data-table">
<tr>
<td class="lbl">رقم التقدير</td>
<td class="val">{{ object.reference_number }}</td>
<td class="lbl">الحالة</td>
<td class="val">{{ object.get_status_display }}</td>
</tr>
<tr>
<td class="lbl">القسم</td>
<td class="val">{{ object.department.get_localized_name|default:"—" }}</td>
<td class="lbl">الموظف المُقدَّر</td>
<td class="val">{{ object.get_recipient_name }}</td>
</tr>
<tr>
<td class="lbl">الفئة</td>
<td class="val">{{ object.category.name_en|default:"—" }}</td>
<td class="lbl">تاريخ التقديم</td>
<td class="val">{{ object.created_at|date:"Y/m/d H:i" }}</td>
</tr>
</table>
<table class="data-table">
<tr><td colspan="4" class="text-cell">{{ object.message_en }}{% if object.message_ar %}<br><br>{{ object.message_ar }}{% endif %}</td></tr>
</table>
{% endblock %}

View File

@ -89,15 +89,6 @@
{% else %}bg-slate-100 text-slate-600{% endif %}">
{{ complaint.get_status_display }}
</span>
{% if complaint.patient_contact_status == 'contacted' %}
<span class="ml-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-blue-100 text-blue-700">
<i data-lucide="phone" class="w-3 h-3 inline"></i> {% trans "Patient Contacted" %}
</span>
{% elif complaint.patient_contact_status == 'contacted_no_response' %}
<span class="ml-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-orange-100 text-orange-700">
<i data-lucide="phone-off" class="w-3 h-3 inline"></i> {% trans "No Response" %}
</span>
{% endif %}
{% if not complaint.sent_to_any_department %}
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase bg-amber-100 text-amber-700">
<i data-lucide="alert-circle" class="w-3 h-3 inline"></i> {% trans "Not Sent to Dept" %}
@ -152,6 +143,161 @@
</div>
</header>
<!-- Workflow Stepper -->
{% if not workflow_steps.cancelled %}
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 mb-6">
<div class="flex items-center justify-between">
<!-- Step 1: Created -->
<div class="flex flex-col items-center flex-shrink-0">
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-green-500 text-white">
<i data-lucide="check" class="w-5 h-5"></i>
</div>
<span class="text-xs font-bold text-slate mt-2">{% trans "Created" %}</span>
</div>
<div class="flex-1 h-1 mx-2 rounded-full {% if workflow_steps.activated %}bg-green-500{% else %}bg-slate-200{% endif %}"></div>
<!-- Step 2: Activate -->
<div class="flex flex-col items-center flex-shrink-0">
{% if workflow_steps.activated %}
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-green-500 text-white"><i data-lucide="check" class="w-5 h-5"></i></div>
{% else %}
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-navy text-white ring-4 ring-navy/20"><i data-lucide="play" class="w-5 h-5"></i></div>
{% endif %}
<span class="text-xs font-bold mt-2 {% if workflow_steps.activated %}text-slate{% else %}text-navy{% endif %}">{% trans "Activate" %}</span>
</div>
<div class="flex-1 h-1 mx-2 rounded-full {% if workflow_steps.sent_to_department %}bg-green-500{% else %}bg-slate-200{% endif %}"></div>
<!-- Step 3: Send to Department -->
<div class="flex flex-col items-center flex-shrink-0">
{% if workflow_steps.sent_to_department %}
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-green-500 text-white"><i data-lucide="check" class="w-5 h-5"></i></div>
{% elif workflow_steps.activated %}
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-navy text-white ring-4 ring-navy/20"><i data-lucide="send" class="w-5 h-5"></i></div>
{% else %}
<div class="w-10 h-10 rounded-full flex items-center justify-center border-2 border-slate-200 text-slate-300"><i data-lucide="send" class="w-5 h-5"></i></div>
{% endif %}
<span class="text-xs font-bold mt-2 {% if workflow_steps.sent_to_department %}text-slate{% elif workflow_steps.activated %}text-navy{% else %}text-slate-300{% endif %}">{% trans "Send to Dept" %}</span>
</div>
<div class="flex-1 h-1 mx-2 rounded-full {% if workflow_steps.department_responded %}bg-green-500{% else %}bg-slate-200{% endif %}"></div>
<!-- Step 4: Response -->
<div class="flex flex-col items-center flex-shrink-0">
{% if workflow_steps.department_responded %}
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-green-500 text-white"><i data-lucide="check" class="w-5 h-5"></i></div>
{% elif workflow_steps.sent_to_department %}
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-navy text-white ring-4 ring-navy/20"><i data-lucide="message-square" class="w-5 h-5"></i></div>
{% else %}
<div class="w-10 h-10 rounded-full flex items-center justify-center border-2 border-slate-200 text-slate-300"><i data-lucide="message-square" class="w-5 h-5"></i></div>
{% endif %}
<span class="text-xs font-bold mt-2 {% if workflow_steps.department_responded %}text-slate{% elif workflow_steps.sent_to_department %}text-navy{% else %}text-slate-300{% endif %}">{% trans "Response" %}</span>
</div>
<div class="flex-1 h-1 mx-2 rounded-full {% if workflow_steps.resolved %}bg-green-500{% else %}bg-slate-200{% endif %}"></div>
<!-- Step 5: Resolve -->
<div class="flex flex-col items-center flex-shrink-0">
{% if workflow_steps.resolved %}
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-green-500 text-white"><i data-lucide="check" class="w-5 h-5"></i></div>
{% elif workflow_steps.department_responded %}
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-navy text-white ring-4 ring-navy/20"><i data-lucide="check-circle-2" class="w-5 h-5"></i></div>
{% else %}
<div class="w-10 h-10 rounded-full flex items-center justify-center border-2 border-slate-200 text-slate-300"><i data-lucide="check-circle-2" class="w-5 h-5"></i></div>
{% endif %}
<span class="text-xs font-bold mt-2 {% if workflow_steps.resolved %}text-slate{% elif workflow_steps.department_responded %}text-navy{% else %}text-slate-300{% endif %}">{% trans "Resolve" %}</span>
</div>
</div>
<!-- Next Action Callout -->
{% if complaint.is_active_status and can_edit %}
{% if not workflow_steps.activated %}
<div class="mt-5 p-4 bg-blue-50 rounded-xl border border-blue-200 flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center shrink-0"><i data-lucide="play" class="w-4 h-4 text-blue-600"></i></div>
<div><p class="text-sm font-bold text-navy">{% trans "Activate this complaint" %}</p><p class="text-xs text-slate">{% trans "Assign it to yourself to start working on it" %}</p></div>
</div>
<form method="post" action="{% url 'complaints:complaint_activate' pk=complaint.pk %}" class="shrink-0">{% csrf_token %}
<button type="submit" class="px-4 py-2 bg-navy text-white rounded-lg font-bold text-sm hover:bg-blue transition whitespace-nowrap">{% trans "Activate" %}</button>
</form>
</div>
{% elif not workflow_steps.sent_to_department %}
<div class="mt-5 p-4 bg-blue-50 rounded-xl border border-blue-200 flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center shrink-0"><i data-lucide="send" class="w-4 h-4 text-blue-600"></i></div>
<div><p class="text-sm font-bold text-navy">{% trans "Send to department" %}</p><p class="text-xs text-slate">{% trans "Notify the champion and manager to collect their response" %}</p></div>
</div>
<button type="button" onclick="openSendToDeptModal()" class="px-4 py-2 bg-navy text-white rounded-lg font-bold text-sm hover:bg-blue transition whitespace-nowrap shrink-0">{% trans "Send to Department" %}</button>
</div>
{% elif not workflow_steps.department_responded %}
<div class="mt-5 p-4 bg-amber-50 rounded-xl border border-amber-200 flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-amber-100 flex items-center justify-center shrink-0"><i data-lucide="clock" class="w-4 h-4 text-amber-600 animate-pulse"></i></div>
<div><p class="text-sm font-bold text-amber-800">{% trans "Awaiting department response" %}</p><p class="text-xs text-amber-700">{% trans "The department champion and manager have been notified. You'll be notified when they respond." %}</p></div>
</div>
{% elif not workflow_steps.resolved %}
<div class="mt-5 p-4 bg-green-50 rounded-xl border border-green-200 flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-green-100 flex items-center justify-center shrink-0"><i data-lucide="check-circle-2" class="w-4 h-4 text-green-600"></i></div>
<div><p class="text-sm font-bold text-navy">{% trans "Resolve this complaint" %}</p><p class="text-xs text-slate">{% trans "The department has responded. Review and close the complaint." %}</p></div>
</div>
<button type="button" onclick="switchTab('resolution')" class="px-4 py-2 bg-green-600 text-white rounded-lg font-bold text-sm hover:bg-green-700 transition whitespace-nowrap shrink-0">{% trans "Generate Resolution" %}</button>
</div>
{% endif %}
{% elif workflow_steps.resolved %}
{% if complaint.satisfaction %}
<!-- Satisfaction recorded: green banner + satisfaction pill -->
<div class="mt-5 p-4 bg-green-50 rounded-xl border border-green-200 flex items-start gap-3">
<div class="w-9 h-9 rounded-full bg-green-100 flex items-center justify-center shrink-0"><i data-lucide="check-circle-2" class="w-4 h-4 text-green-600"></i></div>
<div class="flex-1">
<p class="text-sm font-bold text-green-800">{% trans "Complaint resolved" %}</p>
<p class="text-xs text-green-700">{% trans "This complaint has been successfully resolved." %}</p>
<div class="flex items-center gap-2 mt-2 flex-wrap">
{% if complaint.resolution_outcome %}
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold
{% if complaint.resolution_outcome == 'patient' %}bg-blue-100 text-blue-700
{% elif complaint.resolution_outcome == 'hospital' %}bg-green-100 text-green-700
{% else %}bg-slate-100 text-slate-600{% endif %}">
{% trans "Outcome:" %} {{ complaint.get_resolution_outcome_display }}
</span>
{% endif %}
{% if complaint.resolution_sent_at %}
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-cyan-100 text-cyan-700 flex items-center gap-1">
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/></svg>
{% trans "Sent to patient" %}
</span>
{% endif %}
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold inline-flex items-center gap-1
{% if complaint.satisfaction == 'satisfied' %}bg-green-200 text-green-800
{% elif complaint.satisfaction == 'neutral' %}bg-yellow-100 text-yellow-800
{% elif complaint.satisfaction == 'dissatisfied' %}bg-red-100 text-red-700
{% else %}bg-slate-100 text-slate-600{% endif %}">
{% if complaint.satisfaction == 'satisfied' %}<i data-lucide="thumbs-up" class="w-3 h-3"></i>
{% elif complaint.satisfaction == 'neutral' %}<i data-lucide="minus-circle" class="w-3 h-3"></i>
{% elif complaint.satisfaction == 'dissatisfied' %}<i data-lucide="thumbs-down" class="w-3 h-3"></i>
{% else %}<i data-lucide="phone-off" class="w-3 h-3"></i>{% endif %}
{% if complaint.satisfaction_locked_by_patient %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
{% if complaint.satisfaction_locked_by_patient %}{% trans "Patient (locked):" %}{% else %}{% trans "Patient:" %}{% endif %} {{ complaint.get_satisfaction_display }}
</span>
{% if complaint.satisfaction_set_at %}
<span class="text-[10px] text-slate-400">{{ complaint.satisfaction_set_at|date:"Y-m-d H:i" }}</span>
{% endif %}
</div>
</div>
</div>
{% else %}
<!-- Satisfaction NOT yet recorded: amber next-action callout -->
<div class="mt-5 p-4 bg-amber-50 rounded-xl border border-amber-200 flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-amber-100 flex items-center justify-center shrink-0"><i data-lucide="phone-call" class="w-4 h-4 text-amber-600"></i></div>
<div>
<p class="text-sm font-bold text-amber-800">{% trans "Record patient satisfaction" %}</p>
<p class="text-xs text-amber-700">{% trans "Call the patient to confirm resolution, then capture their satisfaction level." %}</p>
</div>
</div>
<button type="button" onclick="switchTab('resolution', 'patient-satisfaction')" class="px-4 py-2 bg-amber-600 text-white rounded-lg font-bold text-sm hover:bg-amber-700 transition whitespace-nowrap shrink-0">{% trans "Record Satisfaction" %}</button>
</div>
{% endif %}
{% endif %}
</div>
{% endif %}
<!-- Tab Navigation -->
<nav class="bg-white px-6 flex gap-6 border-b shadow-sm mb-6 rounded-t-2xl">
<button class="py-4 text-sm tab-active" onclick="switchTab('details')" id="tab-details">
@ -224,18 +370,21 @@
<!-- Details Tab -->
<div id="panel-details" class="tab-panel space-y-6">
<section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
{% if complaint.description %}
<p class="text-[10px] font-bold text-slate uppercase mb-2">{% trans "Complaint Description" %}</p>
<div class="bg-slate-50 p-4 rounded-xl border-l-4 border-blue mb-4">
<p class="text-sm leading-relaxed text-slate italic">"{{ complaint.description }}"</p>
<div class="flex items-center gap-2 mb-3">
<i data-lucide="message-square-text" class="w-4 h-4 text-slate-400"></i>
<p class="text-xs font-bold text-slate uppercase tracking-wide">{% trans "Description" %}</p>
</div>
<p class="text-base leading-relaxed text-slate">{{ complaint.description }}</p>
{% endif %}
<div class="grid grid-cols-5 gap-4 py-4">
<div>
<div class="flex items-center gap-1.5">
<p class="text-[10px] font-bold text-slate uppercase">{% trans "Location" %}</p>
<dl class="mt-6 pt-5 border-t border-slate-100 space-y-3">
<!-- Location -->
<div class="flex items-start gap-4">
<dt class="w-32 shrink-0 flex items-center gap-1.5 text-sm font-bold text-slate uppercase pt-0.5">
<i data-lucide="map-pin" class="w-3.5 h-3.5 text-slate-400 shrink-0"></i>
{% trans "Location" %}
{% if can_manage_actions and complaint.is_active_status %}
<button type="button" onclick="showLocationModal()"
title="{% trans 'Edit location details' %}"
@ -243,117 +392,84 @@
<i data-lucide="pencil" class="w-3 h-3"></i>
</button>
{% endif %}
</div>
</dt>
<dd class="flex-1 text-xs leading-relaxed">
{% if complaint.department or complaint.location_type or complaint.area or complaint.section %}
<div class="flex items-center gap-1.5 mt-0.5">
<i data-lucide="building-2" class="w-3.5 h-3.5 text-navy shrink-0"></i>
<p class="text-sm font-bold text-navy truncate">
{% if complaint.department %}{{ complaint.department.get_localized_name }}{% else %}-{% endif %}
</p>
</div>
{% if complaint.department %}<span class="font-bold text-navy">{{ complaint.department.get_localized_name }}</span>{% else %}-{% endif %}
{% if complaint.location_type %}
<div class="mt-1.5">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide bg-navy/10 text-navy">
<i data-lucide="map-pin" class="w-2.5 h-2.5"></i>
{{ complaint.get_location_type_display }}
</span>
</div>
<span class="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide bg-navy/10 text-navy">{{ complaint.get_location_type_display }}</span>
{% endif %}
{% if complaint.area or complaint.department.category or complaint.section or complaint.zone or complaint.floor %}
<dl class="mt-1.5 space-y-0.5">
{% if complaint.area %}
<div class="flex items-start gap-1">
<dt class="text-[10px] font-bold text-slate/60 uppercase shrink-0 pt-px">{% trans "Area" %}</dt>
<dd class="text-xs text-slate leading-tight">{% if LANG == 'ar' and complaint.area.name_ar %}{{ complaint.area.name_ar }}{% else %}{{ complaint.area.name_en }}{% endif %}</dd>
</div>
{% endif %}
{% if complaint.department and complaint.department.category %}
<div class="flex items-start gap-1">
<dt class="text-[10px] font-bold text-slate/60 uppercase shrink-0 pt-px">{% trans "Category" %}</dt>
<dd class="text-xs text-slate leading-tight">{{ complaint.department.get_category_display }}</dd>
</div>
{% endif %}
{% if complaint.section %}
<div class="flex items-start gap-1">
<dt class="text-[10px] font-bold text-slate/60 uppercase shrink-0 pt-px">{% trans "Section" %}</dt>
<dd class="text-xs text-slate leading-tight">{{ complaint.section.get_localized_name }}</dd>
</div>
{% endif %}
{% if complaint.zone %}
<div class="flex items-start gap-1">
<dt class="text-[10px] font-bold text-slate/60 uppercase shrink-0 pt-px">{% trans "Zone" %}</dt>
<dd class="text-xs text-slate leading-tight">{{ complaint.zone }}</dd>
</div>
{% endif %}
{% if complaint.floor %}
<div class="flex items-start gap-1">
<dt class="text-[10px] font-bold text-slate/60 uppercase shrink-0 pt-px">{% trans "Floor" %}</dt>
<dd class="text-xs text-slate leading-tight">{{ complaint.floor }}</dd>
</div>
{% endif %}
</dl>
{% endif %}
{% if complaint.area %}<span class="text-slate ml-2">· {% trans "Area" %}: {% if LANG == 'ar' and complaint.area.name_ar %}{{ complaint.area.name_ar }}{% else %}{{ complaint.area.name_en }}{% endif %}</span>{% endif %}
{% if complaint.department and complaint.department.category %}<span class="text-slate">· {% trans "Category" %}: {{ complaint.department.get_category_display }}</span>{% endif %}
{% if complaint.section %}<span class="text-slate">· {% trans "Section" %}: {{ complaint.section.get_localized_name }}</span>{% endif %}
{% if complaint.zone %}<span class="text-slate">· {% trans "Zone" %}: {{ complaint.zone }}</span>{% endif %}
{% if complaint.floor %}<span class="text-slate">· {% trans "Floor" %}: {{ complaint.floor }}</span>{% endif %}
{% else %}
<p class="text-sm font-bold text-navy">
{% if complaint.legacy_location %}{{ complaint.legacy_location.name_en }}{% else %}-{% endif %}
</p>
{% if complaint.legacy_main_section %}
<p class="text-xs text-slate">{{ complaint.legacy_main_section.name_en }}{% if complaint.legacy_subsection %} &gt; {{ complaint.legacy_subsection.name_en }}{% endif %}</p>
{% endif %}
<span class="font-bold text-navy">{% if complaint.legacy_location %}{{ complaint.legacy_location.name_en }}{% else %}-{% endif %}</span>
{% if complaint.legacy_main_section %}<span class="text-slate ml-2">{{ complaint.legacy_main_section.name_en }}{% if complaint.legacy_subsection %} &gt; {{ complaint.legacy_subsection.name_en }}{% endif %}</span>{% endif %}
{% endif %}
</dd>
</div>
<div>
<p class="text-[10px] font-bold text-slate uppercase">{% trans "Severity" %}</p>
<p class="text-sm font-bold
<!-- Severity -->
<div class="flex items-start gap-4">
<dt class="w-32 shrink-0 flex items-center gap-1.5 text-sm font-bold text-slate uppercase pt-0.5">
<i data-lucide="alert-circle" class="w-3.5 h-3.5 text-slate-400 shrink-0"></i>
{% trans "Severity" %}
</dt>
<dd class="flex-1 text-xs font-bold
{% if complaint.severity == 'critical' %}text-red-600
{% elif complaint.severity == 'high' %}text-orange-600
{% elif complaint.severity == 'medium' %}text-yellow-600
{% else %}text-green-600{% endif %}">
{{ complaint.get_severity_display }}
</p>
</dd>
</div>
<div>
<p class="text-[10px] font-bold text-slate uppercase">{% trans "Classification" %}</p>
<p class="text-sm font-bold text-blue-700">
<!-- Classification -->
<div class="flex items-start gap-4">
<dt class="w-32 shrink-0 flex items-center gap-1.5 text-sm font-bold text-slate uppercase pt-0.5">
<i data-lucide="tag" class="w-3.5 h-3.5 text-slate-400 shrink-0"></i>
{% trans "Classification" %}
</dt>
<dd class="flex-1 text-xs font-bold text-blue-700">
{% if complaint.ai_brief_en %}
{% if LANG == 'ar' and complaint.ai_brief_ar %}
{{ complaint.ai_brief_ar }}
{% else %}
{{ complaint.ai_brief_en }}
{% endif %}
{% if LANG == 'ar' and complaint.ai_brief_ar %}{{ complaint.ai_brief_ar }}{% else %}{{ complaint.ai_brief_en }}{% endif %}
{% else %}-{% endif %}
</p>
</dd>
</div>
<div>
<p class="text-[10px] font-bold text-slate uppercase">{% trans "Created" %}</p>
<p class="text-sm font-bold text-navy">{{ complaint.created_at|date:"d M Y, h:i A" }}</p>
<!-- Created -->
<div class="flex items-start gap-4">
<dt class="w-32 shrink-0 flex items-center gap-1.5 text-sm font-bold text-slate uppercase pt-0.5">
<i data-lucide="calendar" class="w-3.5 h-3.5 text-slate-400 shrink-0"></i>
{% trans "Created" %}
</dt>
<dd class="flex-1 text-xs font-bold text-navy">{{ complaint.created_at|date:"d M Y, h:i A" }}</dd>
</div>
<div>
<p class="text-[10px] font-bold text-slate uppercase">{% trans "Deadline" %}</p>
<p class="text-sm font-bold {% if complaint.is_overdue %}text-red-600{% else %}text-navy{% endif %}">
<!-- Deadline -->
<div class="flex items-start gap-4">
<dt class="w-32 shrink-0 flex items-center gap-1.5 text-sm font-bold text-slate uppercase pt-0.5">
<i data-lucide="clock" class="w-3.5 h-3.5 text-slate-400 shrink-0"></i>
{% trans "Deadline" %}
</dt>
<dd class="flex-1 text-xs font-bold {% if complaint.is_overdue %}text-red-600{% else %}text-navy{% endif %}">
{{ complaint.due_at|date:"d M Y, h:i A" }}
{% if complaint.is_overdue %}
<span class="ml-1 px-1.5 py-0.5 rounded text-[9px] font-bold bg-red-500 text-white uppercase">{% trans "Overdue" %}</span>
<span class="ml-1 px-1.5 py-0.5 rounded text-[10px] font-bold bg-red-500 text-white uppercase">{% trans "Overdue" %}</span>
{% endif %}
</p>
{% if complaint.due_at and complaint.status != 'resolved' and complaint.status != 'closed' and complaint.status != 'cancelled' %}
<p id="sla-countdown" class="text-xs font-bold mt-1"
<p id="sla-countdown" class="text-xs font-bold mt-0.5"
data-due-at="{{ complaint.due_at|date:'c' }}"
data-overdue="{{ complaint.is_overdue|yesno:'true,false' }}">
</p>
{% elif complaint.sla_is_overdue_display %}
<p class="text-xs font-bold text-red-600 mt-1">{{ complaint.sla_is_overdue_display }}</p>
<p class="text-xs font-bold text-red-600 mt-0.5">{{ complaint.sla_is_overdue_display }}</p>
{% endif %}
</dd>
</div>
</div>
</dl>
{% if complaint.domain or complaint.category or complaint.subcategory_obj or complaint.classification_obj %}
<div class="py-3 border-t border-slate-100 flex items-center gap-2 flex-wrap">
<span class="text-[10px] font-bold text-slate uppercase">{% trans "Taxonomy" %}:</span>
<div class="mt-5 pt-5 border-t border-slate-100 flex items-center gap-2 flex-wrap">
<span class="text-xs font-bold text-slate uppercase">{% trans "Taxonomy" %}:</span>
{% if complaint.domain %}<span class="text-sm text-navy font-medium">{{ complaint.domain.get_localized_name }}</span>{% endif %}
{% if complaint.category %}<i data-lucide="chevron-right" class="w-3 h-3 text-slate-300"></i><span class="text-sm text-navy font-medium">{{ complaint.category.get_localized_name }}</span>{% endif %}
{% if complaint.subcategory_obj %}<i data-lucide="chevron-right" class="w-3 h-3 text-slate-300"></i><span class="text-sm text-navy font-medium">{{ complaint.subcategory_obj.get_localized_name }}</span>{% endif %}
@ -426,16 +542,137 @@
</div>
{% endif %}
</section>
{% if complaint.activated_at %}
{% include "complaints/partials/pdf_summary_panel.html" %}
{% else %}
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 text-center">
<div class="w-12 h-12 mx-auto bg-slate-100 rounded-full flex items-center justify-center mb-3">
<i data-lucide="lock" class="w-5 h-5 text-slate-400"></i>
</div>
<p class="text-sm font-semibold text-slate">{% trans "PDF Report" %}</p>
<p class="text-xs text-slate/60 mt-1">{% trans "Activate this complaint to enable PDF report generation." %}</p>
</section>
{% endif %}
</div>
<!-- Departments Tab -->
<!-- Departments Tab (inline; send-to-department opens #sendToDeptModal) -->
<div id="panel-departments" class="tab-panel hidden space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{% include "complaints/partials/departments_panel.html" %}
{% include "complaints/partials/staff_panel.html" %}
<!-- Send status banner -->
{% if not complaint.sent_to_any_department %}
<div class="flex items-center gap-3 p-3 rounded-xl bg-amber-50 border border-amber-200">
<i data-lucide="send" class="w-4 h-4 text-amber-600 shrink-0"></i>
<p class="text-sm text-amber-700 font-semibold">{% trans "Not sent to any department yet" %}</p>
</div>
{% include "complaints/partials/explanation_panel.html" %}
{% elif not complaint.all_departments_responded %}
<div class="flex items-center gap-3 p-3 rounded-xl bg-blue-50 border border-blue-200">
<i data-lucide="send" class="w-4 h-4 text-blue-600 shrink-0"></i>
<p class="text-sm text-blue-700 font-semibold">
{% if complaint.sent_to_department_at %}
{% blocktrans with dt=complaint.sent_to_department_at|date:"d M Y, h:i A" %}Sent to department on {{ dt }} — awaiting response{% endblocktrans %}
{% else %}
{% trans "Sent to department — awaiting response" %}
{% endif %}
</p>
</div>
{% else %}
<div class="flex items-center gap-3 p-3 rounded-xl bg-green-50 border border-green-200">
<i data-lucide="check-circle-2" class="w-4 h-4 text-green-600 shrink-0"></i>
<p class="text-sm text-green-700 font-semibold">{% trans "All departments responded" %}</p>
</div>
{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Involved Departments (read-only) -->
<section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<h4 class="text-sm font-bold text-navy uppercase tracking-wide mb-3">{% trans "Involved Departments" %}</h4>
{% if complaint.involved_departments.exists %}
<ul class="divide-y divide-slate-100 border border-slate-100 rounded-xl overflow-hidden">
{% for dept in complaint.involved_departments.all %}
<li class="flex items-center justify-between p-3 gap-3">
<div class="min-w-0">
<p class="text-sm font-semibold text-navy truncate">
{{ dept.department.get_localized_name }}
{% if dept.is_primary %}<span class="ml-1 text-[10px] font-bold text-navy bg-light px-1.5 py-0.5 rounded align-middle">{% trans "Primary" %}</span>{% endif %}
</p>
{% if dept.assigned_to %}<p class="text-[11px] text-slate-500"><i data-lucide="user-check" class="w-3 h-3 inline"></i> {{ dept.assigned_to.get_full_name }}</p>{% endif %}
{% if dept.forwarded_at or dept.sent_at %}
<p class="text-[10px] text-slate-400 mt-0.5"><i data-lucide="send" class="w-2.5 h-2.5 inline"></i> {% trans "Sent" %} {{ dept.forwarded_at|default:dept.sent_at|date:"d M Y" }}</p>
{% elif dept.sent %}
<p class="text-[10px] text-slate-400 mt-0.5"><i data-lucide="send" class="w-2.5 h-2.5 inline"></i> {% trans "Sent" %}</p>
{% else %}
<p class="text-[10px] text-slate-400 mt-0.5 italic">{% trans "Not sent" %}</p>
{% endif %}
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] font-bold
{% if not dept.response_submitted %}text-slate-400
{% elif dept.acceptance_status == 'acceptable' %}text-green-600
{% elif dept.acceptance_status == 'not_acceptable' %}text-red-600
{% else %}text-amber-600{% endif %}">
{% if not dept.response_submitted %}{% trans "No response" %}
{% elif dept.acceptance_status == 'acceptable' %}{% trans "Accepted" %}
{% elif dept.acceptance_status == 'not_acceptable' %}{% trans "Rejected" %}
{% else %}{% trans "Pending" %}{% endif %}
</span>
{% if can_manage_actions and complaint.is_active_status %}
<form method="post" action="{% url 'complaints:involved_department_remove' pk=dept.pk %}" class="inline"
onsubmit="return confirm('{% trans "Remove this department from the complaint?" %}')">
{% csrf_token %}
<button type="submit" title="{% trans 'Remove' %}" class="text-slate-300 hover:text-red-500 transition p-1">
<i data-lucide="x" class="w-3.5 h-3.5"></i>
</button>
</form>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-sm text-slate-400 italic">{% trans "No departments involved yet" %}</p>
{% endif %}
</section>
<!-- Involved Staff (read-only) -->
<section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<h4 class="text-sm font-bold text-navy uppercase tracking-wide mb-3">{% trans "Involved Staff" %}</h4>
{% if complaint.involved_staff.exists %}
<ul class="divide-y divide-slate-100 border border-slate-100 rounded-xl overflow-hidden">
{% for staff_inv in complaint.involved_staff.all %}
<li class="flex items-center justify-between p-3 gap-3">
<div class="flex items-center gap-2 min-w-0">
<div class="w-7 h-7 bg-light rounded-full flex items-center justify-center shrink-0"><i data-lucide="user" class="w-3.5 h-3.5 text-navy"></i></div>
<div class="min-w-0">
<p class="text-sm font-semibold text-navy truncate">{{ staff_inv.staff.get_localized_name }}</p>
{% if staff_inv.staff.department %}<p class="text-[11px] text-slate-500 truncate">{{ staff_inv.staff.department.get_localized_name }}</p>{% endif %}
</div>
</div>
<span class="text-[11px] font-bold shrink-0 {% if staff_inv.explanation_received %}text-green-600{% elif staff_inv.explanation_requested %}text-amber-600{% else %}text-slate-400{% endif %}">
{% if staff_inv.explanation_received %}{% trans "Responded" %}{% elif staff_inv.explanation_requested %}{% trans "Pending" %}{% else %}—{% endif %}
</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-sm text-slate-400 italic">{% trans "No staff members involved yet" %}</p>
{% endif %}
</section>
</div>
{% if can_manage_actions and complaint.is_active_status %}
<div class="flex items-center gap-3">
<button type="button" onclick="openSendToDeptModal()"
class="flex-1 px-4 py-2.5 bg-navy text-white font-bold text-sm rounded-xl flex items-center justify-center gap-2 hover:bg-blue transition">
<i data-lucide="send" class="w-4 h-4"></i> {% trans "Send to Department" %}
</button>
<a href="{% url 'complaints:complaint_pdf' pk=complaint.id %}" target="_blank"
class="px-3 py-2.5 border border-slate-200 rounded-xl text-slate hover:border-navy hover:text-navy transition flex items-center gap-2 text-sm font-semibold">
<i data-lucide="file-text" class="w-4 h-4"></i>
{% trans "PDF" %}
</a>
</div>
{% endif %}
{% include "complaints/partials/workflow_timeline.html" %}
</div>
<!-- Timeline Tab -->
@ -590,66 +827,6 @@
</div>
</section>
{% if complaint.status != 'open' %}
<!-- Patient Contact Status -->
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<h3 class="font-bold text-navy mb-4 text-sm flex items-center gap-2">
<i data-lucide="phone" class="w-4 h-4"></i>
{% trans "Patient Contact" %}
</h3>
{% if complaint.patient_contact_status and complaint.patient_contact_status != 'not_contacted' %}
<div class="flex items-center gap-3 mb-3">
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold
{% if complaint.patient_contact_status == 'contacted' %}bg-blue-100 text-blue-800
{% else %}bg-orange-100 text-orange-800{% endif %}">
{% if complaint.patient_contact_status == 'contacted' %}
<i data-lucide="phone" class="w-3.5 h-3.5"></i>
{% else %}
<i data-lucide="phone-off" class="w-3.5 h-3.5"></i>
{% endif %}
{{ complaint.get_patient_contact_status_display }}
</span>
</div>
<div class="text-[10px] text-slate mb-3">
{% if complaint.patient_contact_status_at %}
{{ complaint.patient_contact_status_at|date:"Y-m-d H:i" }}
{% endif %}
{% if complaint.patient_contact_status_by %}
&middot; {% trans "by" %} {{ complaint.patient_contact_status_by.get_full_name }}
{% endif %}
</div>
{% endif %}
{% if can_edit %}
<form method="post" action="{% url 'complaints:update_patient_contact_status' pk=complaint.pk %}">
{% csrf_token %}
<div class="flex flex-wrap gap-2">
<button type="submit" name="patient_contact_status" value="contacted"
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-semibold border-2 transition
{% if complaint.patient_contact_status == 'contacted' %}border-blue-500 bg-blue-50 text-blue-700
{% else %}border-slate-200 text-slate-600 hover:border-blue-400 hover:bg-blue-50{% endif %}">
<i data-lucide="phone" class="w-3.5 h-3.5"></i> {% trans "Contacted" %}
</button>
<button type="submit" name="patient_contact_status" value="contacted_no_response"
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-semibold border-2 transition
{% if complaint.patient_contact_status == 'contacted_no_response' %}border-orange-500 bg-orange-50 text-orange-700
{% else %}border-slate-200 text-slate-600 hover:border-orange-400 hover:bg-orange-50{% endif %}">
<i data-lucide="phone-off" class="w-3.5 h-3.5"></i> {% trans "No Response" %}
</button>
{% if complaint.patient_contact_status != 'not_contacted' %}
<button type="submit" name="patient_contact_status" value="not_contacted"
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-semibold border-2 transition border-slate-200 text-slate-600 hover:border-slate-400 hover:bg-slate-50">
<i data-lucide="rotate-ccw" class="w-3.5 h-3.5"></i> {% trans "Reset" %}
</button>
{% endif %}
</div>
</form>
{% elif not complaint.patient_contact_status or complaint.patient_contact_status == 'not_contacted' %}
<p class="text-sm text-slate italic">{% trans "Patient has not been contacted yet." %}</p>
{% endif %}
</section>
{% endif %}
</div>
</main>
@ -932,130 +1109,57 @@
</div>
</div>
{% if can_manage_actions and complaint.is_active_status %}
<!-- Add Department Modal -->
<div id="addDepartmentModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div class="bg-white rounded-2xl p-6 w-full max-w-2xl mx-4 shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<i data-lucide="building-2" class="w-5 h-5 text-navy"></i>
<!-- Send to Department modal (department + staff selects) -->
<div id="sendToDeptModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4">
<div class="flex items-center justify-between p-5 border-b border-slate-100">
<h3 class="text-lg font-bold text-navy flex items-center gap-2"><i data-lucide="send" class="w-5 h-5"></i> {% trans "Send to Department" %}</h3>
<button type="button" onclick="closeModal('sendToDeptModal')" class="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<h3 class="text-xl font-bold text-navy">{% trans "Add Involved Department" %}</h3>
</div>
<form id="addDepartmentForm" onsubmit="handleAddDepartmentSubmit(event)">
<form id="sendToDeptForm" onsubmit="handleSendToDeptSubmit(event)">
{% csrf_token %}
<div id="addDepartmentErrors" class="hidden mb-4 bg-red-50 border border-red-200 rounded-xl p-3 text-sm text-red-700"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="md:col-span-2">
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Department" %} <span class="text-red-500">*</span></label>
<select name="department" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" data-tomselect required>
<div class="p-5 space-y-4">
<div id="sendDeptError" class="hidden text-sm text-red-600 bg-red-50 border border-red-200 p-3 rounded-lg"></div>
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{% trans "Department" %} <span class="text-red-500">*</span></label>
<select id="sendDeptDeptSelect" name="department_id" onchange="loadSendDeptStaff(this.value)"
class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" required>
<option value="">{% trans "Select Department" %}</option>
{% for choice in involved_department_form.department.field.choices %}
{% if choice.0 %}
<option value="{{ choice.0 }}">{{ choice.1 }}</option>
{% endif %}
{% for dept in hospital_departments %}
<option value="{{ dept.id }}">{{ dept.get_localized_name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Role" %} <span class="text-red-500">*</span></label>
<select name="role" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" required>
<option value="">{% trans "Select Role" %}</option>
{% for choice in involved_department_form.role.field.choices %}
<option value="{{ choice.0 }}">{{ choice.1 }}</option>
{% endfor %}
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{% trans "Staff" %} <span class="text-slate-400 font-normal">({% trans "optional" %})</span></label>
<select id="sendDeptStaffSelect" name="staff_id"
class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none">
<option value="">{% trans "Select department first" %}</option>
</select>
<p class="text-xs text-slate-400 mt-1">{% trans "Records the staff as involved. The department champion and manager are notified." %}</p>
</div>
<div>
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Assign To" %} <span class="text-slate/50 font-normal">({% trans "Optional" %})</span></label>
<select name="assigned_to" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none">
<option value="">{% trans "Select User (Optional)" %}</option>
{% for choice in involved_department_form.assigned_to.field.choices %}
{% if choice.0 %}
<option value="{{ choice.0 }}">{{ choice.1 }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="md:col-span-2 flex items-center gap-2">
<input type="checkbox" name="is_primary" id="addDeptIsPrimary" class="w-4 h-4 accent-navy">
<label for="addDeptIsPrimary" class="text-sm font-semibold text-slate">{% trans "Mark as Primary Department" %}</label>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Notes" %} <span class="text-slate/50 font-normal">({% trans "Optional" %})</span></label>
<textarea name="notes" rows="2" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="{% trans 'Enter any additional notes...' %}"></textarea>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{% trans "Note" %} <span class="text-slate-400 font-normal">({% trans "optional" %})</span></label>
<textarea name="note" rows="2" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="{% trans 'Add context or instructions...' %}"></textarea>
</div>
</div>
<div class="flex gap-3">
<button type="button" onclick="closeModal('addDepartmentModal')" class="flex-1 px-4 py-2 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 transition">
{% trans "Cancel" %}
</button>
<button type="submit" class="flex-1 px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center justify-center gap-2">
<i data-lucide="save" class="w-4 h-4"></i> {% trans "Add Department" %}
<div class="p-5 border-t border-slate-100 flex gap-3">
<button type="button" onclick="closeModal('sendToDeptModal')" class="flex-1 px-4 py-2.5 border border-slate-200 text-slate-600 rounded-xl font-semibold hover:bg-slate-50 transition">{% trans "Cancel" %}</button>
<button type="submit" id="sendDeptSubmitBtn" class="flex-1 px-4 py-2.5 bg-navy text-white rounded-xl font-bold hover:bg-blue transition flex items-center justify-center gap-2">
<i data-lucide="send" class="w-4 h-4"></i> {% trans "Send" %}
</button>
</div>
</form>
</div>
</div>
<!-- Add Staff Modal -->
<div id="addStaffModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div class="bg-white rounded-2xl p-6 w-full max-w-2xl mx-4 shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<i data-lucide="users" class="w-5 h-5 text-navy"></i>
</div>
<h3 class="text-xl font-bold text-navy">{% trans "Add Involved Staff" %}</h3>
</div>
<form id="addStaffForm" onsubmit="handleAddStaffSubmit(event)">
{% csrf_token %}
<div id="addStaffErrors" class="hidden mb-4 bg-red-50 border border-red-200 rounded-xl p-3 text-sm text-red-700"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="md:col-span-2">
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Staff Member" %} <span class="text-red-500">*</span></label>
<select name="staff" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" data-tomselect required>
<option value="">{% trans "Select Staff Member" %}</option>
{% for choice in involved_staff_form.staff.field.choices %}
{% if choice.0 %}
<option value="{{ choice.0 }}">{{ choice.1 }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Role" %} <span class="text-red-500">*</span></label>
<select name="role" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" required>
<option value="">{% trans "Select Role" %}</option>
{% for choice in involved_staff_form.role.field.choices %}
<option value="{{ choice.0 }}">{{ choice.1 }}</option>
{% endfor %}
</select>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Notes" %} <span class="text-slate/50 font-normal">({% trans "Optional" %})</span></label>
<textarea name="notes" rows="2" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="{% trans 'Enter any additional notes...' %}"></textarea>
</div>
</div>
<div class="flex gap-3">
<button type="button" onclick="closeModal('addStaffModal')" class="flex-1 px-4 py-2 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 transition">
{% trans "Cancel" %}
</button>
<button type="submit" class="flex-1 px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center justify-center gap-2">
<i data-lucide="save" class="w-4 h-4"></i> {% trans "Add Staff" %}
</button>
</div>
</form>
</div>
</div>
{% endif %}
<!-- Tab Switching JavaScript -->
<script>
function showActivationRequired() {
alert('{% trans "Please activate this complaint first to access this tab." %}');
}
function switchTab(tabName) {
function switchTab(tabName, scrollToId) {
// Hide all panels
document.querySelectorAll('.tab-panel').forEach(panel => {
panel.classList.add('hidden');
@ -1077,6 +1181,14 @@ function switchTab(tabName) {
if (window.lucide) {
lucide.createIcons();
}
// Optional: scroll to a specific element inside the newly shown panel
if (scrollToId) {
setTimeout(function() {
var el = document.getElementById(scrollToId);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 50);
}
}
// Modal functions
@ -1105,47 +1217,85 @@ function showLocationModal() {
populateLocationDropdowns();
}
function showAddDepartmentModal() {
var modal = document.getElementById('addDepartmentModal');
if (!modal) return;
modal.style.display = 'flex';
var errBox = document.getElementById('addDepartmentErrors');
if (errBox) { errBox.classList.add('hidden'); errBox.innerHTML = ''; }
if (window.lucide) lucide.createIcons();
}
function showAddStaffModal() {
var modal = document.getElementById('addStaffModal');
if (!modal) return;
modal.style.display = 'flex';
var errBox = document.getElementById('addStaffErrors');
if (errBox) { errBox.classList.add('hidden'); errBox.innerHTML = ''; }
if (window.lucide) lucide.createIcons();
}
function closeModal(modalId) {
document.getElementById(modalId).style.display = 'none';
var el = document.getElementById(modalId);
if (el) el.style.display = 'none';
}
// Close modal when clicking outside
// Close modal when clicking outside the dialog
window.onclick = function(event) {
if (event && event.target) {
var modals = ['resolveModal', 'assignModal', 'followUpModal', 'escalateModal', 'closeModal', 'locationModal', 'addDepartmentModal', 'addStaffModal'];
var modals = ['resolveModal', 'assignModal', 'followUpModal', 'escalateModal', 'closeModal', 'locationModal', 'sendToDeptModal'];
if (modals.indexOf(event.target.id) !== -1) {
event.target.style.display = 'none';
}
}
};
// Generic AJAX submit helper for the Add Department / Add Staff modals
function handleInvolvedAddSubmit(event, url, errorBoxId) {
event.preventDefault();
var form = event.target;
var formData = new FormData(form);
var errorBox = document.getElementById(errorBoxId);
if (errorBox) { errorBox.classList.add('hidden'); errorBox.innerHTML = ''; }
// ─── Send to Department modal (department + staff selects) ───
function openSendToDeptModal() {
var m = document.getElementById('sendToDeptModal');
if (!m) return;
var f = document.getElementById('sendToDeptForm');
if (f) f.reset();
var staffSel = document.getElementById('sendDeptStaffSelect');
if (staffSel) staffSel.innerHTML = '<option value="">{% trans "Select department first" %}</option>';
var err = document.getElementById('sendDeptError');
if (err) { err.classList.add('hidden'); err.textContent = ''; }
m.style.display = 'flex';
if (window.lucide) lucide.createIcons();
}
fetch(url, {
function loadSendDeptStaff(deptId) {
var staffSel = document.getElementById('sendDeptStaffSelect');
if (!staffSel) return;
if (!deptId) {
staffSel.innerHTML = '<option value="">{% trans "Select department first" %}</option>';
return;
}
staffSel.innerHTML = '<option value="">{% trans "Loading..." %}</option>';
fetch('/organizations/dropdowns/department-staff/' + encodeURIComponent(deptId) + '/')
.then(function (r) { return r.json(); })
.then(function (items) {
var html = '<option value="">{% trans "Select staff (optional)" %}</option>';
(items || []).forEach(function (it) {
var id = it.staff_id || (it.staff && it.staff.id);
var name = it.name || (it.staff && it.staff.name) || id;
if (id) html += '<option value="' + id + '">' + name + '</option>';
});
staffSel.innerHTML = html;
})
.catch(function () {
staffSel.innerHTML = '<option value="">{% trans "Select staff (optional)" %}</option>';
});
}
function handleSendToDeptSubmit(event) {
event.preventDefault();
var deptSelect = document.getElementById('sendDeptDeptSelect');
var deptId = deptSelect.value;
var staffSel = document.getElementById('sendDeptStaffSelect');
var err = document.getElementById('sendDeptError');
var btn = document.getElementById('sendDeptSubmitBtn');
if (!deptId) {
if (err) { err.textContent = '{% trans "Please select a department." %}'; err.classList.remove('hidden'); }
return;
}
if (err) err.classList.add('hidden');
var deptName = deptSelect.selectedOptions[0] ? deptSelect.selectedOptions[0].textContent.trim() : '';
var confirmMsg = '{% trans "Send this complaint to" %} ' + deptName + '?\n{% trans "The department champion and manager will be notified." %}';
if (!confirm(confirmMsg)) return;
var formData = new FormData(event.target);
formData.set('recipient_type', 'department');
if (!formData.get('department_id')) formData.set('department_id', deptId);
btn.disabled = true;
btn.innerHTML = '<span class="inline-block w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"></span>{% trans "Sending..." %}';
fetch('{% url "complaints:complaint_send_to" pk=complaint.pk %}', {
method: 'POST',
body: formData,
headers: {
@ -1153,50 +1303,22 @@ function handleInvolvedAddSubmit(event, url, errorBoxId) {
'X-CSRFToken': formData.get('csrfmiddlewaretoken') || ''
}
})
.then(function (response) { return response.json(); })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success) {
window.location.reload();
} else {
var msgs = [];
var errs = data.errors || {};
Object.keys(errs).forEach(function (field) {
var val = errs[field];
if (Array.isArray(val)) {
val.forEach(function (item) {
msgs.push(typeof item === 'string' ? item : (item.message || JSON.stringify(item)));
});
} else if (typeof val === 'string') {
msgs.push(val);
} else if (val && val.message) {
msgs.push(val.message);
}
});
if (data.error && !msgs.length) msgs.push(data.error);
if (!msgs.length) msgs.push('{% trans "Please correct the errors below." %}');
if (errorBox) {
errorBox.innerHTML = msgs.join('<br>');
errorBox.classList.remove('hidden');
}
throw new Error(data.error || '{% trans "Failed to send." %}');
}
})
.catch(function (err) {
console.error('Error submitting form:', err);
if (errorBox) {
errorBox.textContent = '{% trans "An error occurred. Please try again." %}';
errorBox.classList.remove('hidden');
}
.catch(function (e) {
if (err) { err.textContent = e.message; err.classList.remove('hidden'); }
btn.disabled = false;
btn.innerHTML = '<i data-lucide="send" class="w-4 h-4"></i> {% trans "Send" %}';
if (window.lucide) lucide.createIcons();
});
}
function handleAddDepartmentSubmit(event) {
handleInvolvedAddSubmit(event, '{% url "complaints:involved_department_add" complaint_pk=complaint.pk %}', 'addDepartmentErrors');
}
function handleAddStaffSubmit(event) {
handleInvolvedAddSubmit(event, '{% url "complaints:involved_staff_add" complaint_pk=complaint.pk %}', 'addStaffErrors');
}
// Location modal dependent dropdowns
function populateLocationDropdowns() {
var hospitalId = document.getElementById('locationHospitalInput').value;
@ -1367,7 +1489,4 @@ document.addEventListener('DOMContentLoaded', function () {
})();
</script>
{% include "components/send_to_modal.html" with users=send_to_users departments=hospital_departments email_subject=send_to_email_subject email_body=send_to_email_body %}
{% include "components/department_response_modal.html" %}
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<title>تقرير الشكوى - {{ complaint.reference_number }}</title>
<style>
@page { size: A4; margin: 0; }
@page :first { margin: 0; }
@font-face { font-family: 'Noto Kufi Arabic'; font-style: normal; font-weight: 400; src: url('vendor/fonts/noto-kufi-arabic/files/noto-kufi-arabic-full-regular.ttf') format('truetype'); }
@font-face { font-family: 'Noto Kufi Arabic'; font-style: normal; font-weight: 700; src: url('vendor/fonts/noto-kufi-arabic/files/noto-kufi-arabic-full-bold.ttf') format('truetype'); }
:root {
--blue: #005696;
--blue-light: #007bbd;
--ink: #1e293b;
--body: #334155;
--muted: #64748b;
--border: #cbd5e1;
--surface: #f1f5f9;
--white: #ffffff;
--font: 'Noto Kufi Arabic', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; font-family: var(--font); }
body { background: var(--white); color: var(--ink); }
.page {
width: 210mm;
height: 297mm;
position: relative;
overflow: hidden;
}
/* === Header === */
.page-header {
position: relative;
height: 22mm;
margin: 8mm 20mm 0 20mm;
}
.header-ar {
position: absolute;
left: 0;
top: 4mm;
text-align: left;
}
.header-logo-wrap {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
text-align: center;
}
.header-en {
position: absolute;
right: 0;
top: 4mm;
text-align: right;
}
.header-logo {
width: 55px;
height: auto;
object-fit: contain;
}
.header-hospital-name {
font-size: 14px;
font-weight: 700;
color: var(--blue);
}
.header-cr {
font-size: 9px;
font-weight: 700;
color: var(--blue);
text-align: center;
}
.header-line {
height: 2px;
background: var(--blue);
margin: 8px 20mm 0 20mm;
}
/* === Watermark === */
.watermark {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0.04;
pointer-events: none;
z-index: 0;
}
.watermark img {
width: 300px;
height: auto;
}
/* === Footer === */
.page-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
.footer-line {
height: 2px;
background: var(--blue);
margin: 0 15mm;
}
.footer-content {
padding: 6px 15mm 10mm 15mm;
text-align: center;
font-size: 8px;
color: var(--muted);
line-height: 1.8;
}
.footer-ar { direction: rtl; }
.footer-en { direction: ltr; }
/* === Body === */
.form-body {
padding: 8mm 20mm;
position: relative;
z-index: 1;
}
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
}
.data-table td {
border: 1px solid var(--border);
font-size: 11px;
padding: 8px 12px;
}
.data-table .lbl {
background: var(--surface);
color: var(--body);
font-weight: 700;
width: 15%;
}
.data-table .val {
color: var(--ink);
width: 35%;
}
.data-table .text-cell {
padding: 12px;
line-height: 2;
text-align: justify;
}
</style>
</head>
<body>
<div class="page">
{% include "complaints/partials/pdf_letterhead_header.html" %}
<div class="watermark"><img src="{{ logo_path }}" alt=""></div>
<div class="form-body">
<table class="data-table">
<tr>
<td class="lbl">رقم الشكوى</td>
<td class="val">{{ complaint.reference_number }}</td>
<td class="lbl">تاريخ الإرسال</td>
<td class="val">{{ sent_date }}</td>
</tr>
<tr>
<td class="lbl">القسم</td>
<td class="val">{{ department_name|default:"—" }}</td>
<td class="lbl">الموظف المشكو عليه</td>
<td class="val">{{ staff_name|default:"—" }}</td>
</tr>
{% if staff_title %}
<tr>
<td class="lbl">الوظيفة</td>
<td class="val">{{ staff_title }}</td>
<td class="lbl"></td>
<td class="val"></td>
</tr>
{% endif %}
</table>
<table class="data-table">
<tr>
<td colspan="4" class="text-cell">{{ complaint.description }}</td>
</tr>
</table>
</div>
{% include "complaints/partials/pdf_letterhead_footer.html" %}
</div>
</body>
</html>

View File

@ -300,11 +300,6 @@
{% else %}bg-slate-100 text-slate-600{% endif %}">
{{ complaint.get_status_display }}
</span>
{% if complaint.patient_contact_status == 'contacted' %}
<span class="ml-1 px-1.5 py-0.5 rounded-full text-[9px] font-bold uppercase bg-blue-100 text-blue-700">{% trans "Contacted" %}</span>
{% elif complaint.patient_contact_status == 'contacted_no_response' %}
<span class="ml-1 px-1.5 py-0.5 rounded-full text-[9px] font-bold uppercase bg-orange-100 text-orange-700">{% trans "No Response" %}</span>
{% endif %}
</td>
<td class="px-6 py-4 text-center">
{% if complaint.due_at and complaint.status != 'resolved' and complaint.status != 'closed' and complaint.status != 'cancelled' %}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<title>مراجعة الردود - {{ complaint.reference_number }}</title>
<style>
@page { size: A4; margin: 0; }
@font-face { font-family: 'Noto Kufi Arabic'; font-weight: 400; src: url('vendor/fonts/noto-kufi-arabic/files/noto-kufi-arabic-full-regular.ttf') format('truetype'); }
@font-face { font-family: 'Noto Kufi Arabic'; font-weight: 700; src: url('vendor/fonts/noto-kufi-arabic/files/noto-kufi-arabic-full-bold.ttf') format('truetype'); }
:root { --blue: #005696; --ink: #1e293b; --body: #334155; --muted: #64748b; --border: #cbd5e1; --surface: #f1f5f9; --font: 'Noto Kufi Arabic', sans-serif; }
* { box-sizing: border-box; margin: 0; padding: 0; font-family: var(--font); }
body { background: #fff; color: var(--ink); }
.page { width: 210mm; min-height: 297mm; position: relative; overflow: hidden; padding-bottom: 30mm; }
.page-header { position: relative; height: 22mm; margin: 8mm 20mm 0 20mm; }
.header-ar { position: absolute; left: 0; top: 4mm; text-align: left; }
.header-logo-wrap { position: absolute; left: 50%; top: 0; transform: translateX(-50%); text-align: center; }
.header-en { position: absolute; right: 0; top: 4mm; text-align: right; }
.header-logo { width: 55px; height: auto; }
.header-hospital-name { font-size: 14px; font-weight: 700; color: var(--blue); }
.header-cr { font-size: 9px; font-weight: 700; color: var(--blue); text-align: center; }
.header-line { height: 2px; background: var(--blue); margin: 8px 20mm 0 20mm; }
.watermark { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); opacity: 0.04; pointer-events: none; z-index: 0; }
.watermark img { width: 300px; }
.page-footer { position: absolute; bottom: 0; left: 0; right: 0; }
.footer-line { height: 2px; background: var(--blue); margin: 0 15mm; }
.footer-content { padding: 6px 15mm 10mm 15mm; text-align: center; font-size: 8px; color: var(--muted); line-height: 1.8; }
.form-body { padding: 8mm 20mm; position: relative; z-index: 1; }
.form-title { text-align: center; font-size: 16px; color: var(--blue); padding: 10px 0 16px 0; }
.data-table { width: 100%; border-collapse: collapse; margin-bottom: 8mm; }
.data-table td { border: 1px solid var(--border); font-size: 11px; padding: 8px 12px; }
.data-table .lbl { background: var(--surface); color: var(--body); font-weight: 700; width: 15%; }
.data-table .val { color: var(--ink); width: 35%; }
.staff-section { margin-bottom: 6mm; }
.staff-header { background: var(--surface); color: var(--blue); font-size: 12px; font-weight: 700; padding: 6px 12px; border: 1px solid var(--border); border-bottom: none; }
.qa-table { width: 100%; border-collapse: collapse; }
.qa-table td { border: 1px solid var(--border); font-size: 10px; padding: 6px 10px; }
.qa-table .q-lbl { background: var(--surface); font-weight: 700; width: 30%; }
.qa-table .q-val { width: 70%; }
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 9px; font-weight: 700; }
.status-yes { background: #dcfce7; color: #166534; }
.status-no { background: #fee2e2; color: #991b1b; }
.status-pending { background: #fef9c3; color: #854d0e; }
</style>
</head>
<body>
<div class="page">
{% include "complaints/partials/pdf_letterhead_header.html" %}
<div class="watermark"><img src="{{ logo_path }}" alt=""></div>
<div class="form-body">
<div class="form-title">مراجعة ردود الموظفين</div>
<table class="data-table">
<tr>
<td class="lbl">رقم الشكوى</td>
<td class="val">{{ complaint.reference_number }}</td>
<td class="lbl">العنوان</td>
<td class="val">{{ complaint.title }}</td>
</tr>
</table>
<table class="data-table">
<tr>
<td colspan="4" class="lbl" style="width:auto">{{ complaint.description }}</td>
</tr>
</table>
{% for sd in staff_data %}
<div class="staff-section">
<div class="staff-header">
{{ sd.staff.get_full_name }}
{% if sd.is_completed %}
<span class="status-badge status-yes">تم الرد</span>
{% else %}
<span class="status-badge status-pending">بانتظار الرد</span>
{% endif %}
</div>
{% if sd.qa_pairs %}
<table class="qa-table">
{% for qa in sd.qa_pairs %}
<tr>
<td class="q-lbl">{{ qa.question }}</td>
<td class="q-val">
{% if qa.question_type == 'yes_no' %}
{% if qa.answer == 'yes' %}<span class="status-badge status-yes">نعم</span>
{% elif qa.answer == 'no' %}<span class="status-badge status-no">لا</span>
{% else %}—{% endif %}
{% else %}
{{ qa.answer|default:"—" }}
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
{% endfor %}
</div>
{% include "complaints/partials/pdf_letterhead_footer.html" %}
</div>
</body>
</html>

View File

@ -36,28 +36,37 @@
/* === Header === */
.page-header {
padding: 12mm 15mm 0 15mm;
position: relative;
height: 22mm;
margin: 8mm 20mm 0 20mm;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15mm;
.header-ar {
position: absolute;
left: 0;
top: 4mm;
text-align: left;
}
.header-side {
width: 50%;
.header-logo-wrap {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
text-align: center;
}
.header-side--ar {
.header-en {
position: absolute;
right: 0;
top: 4mm;
text-align: right;
direction: rtl;
}
.header-logo {
width: 90px;
width: 55px;
height: auto;
object-fit: contain;
}
.header-hospital-name {
font-size: 14px;
font-weight: 700;
color: var(--blue);
}
.header-branch {
@ -66,7 +75,9 @@
}
.header-cr {
font-size: 9px;
color: var(--muted);
font-weight: 700;
color: var(--blue);
text-align: center;
}
.header-line {
height: 2px;
@ -85,7 +96,7 @@
z-index: 0;
}
.watermark img {
width: 180px;
width: 300px;
height: auto;
}
@ -129,41 +140,38 @@
padding: 10px 0 16px 0;
}
.form-section-header {
font-size: 12px;
color: var(--blue);
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
}
.data-table th {
background: var(--surface);
color: var(--blue);
font-size: 12px;
padding: 6px 12px;
border-right: 3px solid var(--blue);
margin-top: 12px;
margin-bottom: 2px;
border-left: 1px solid var(--border);
border-top: 1px solid var(--border);
text-align: right;
}
.form-row {
display: flex;
border-bottom: 1px solid var(--border);
}
.form-row:last-child {
border-bottom: none;
}
.form-label {
width: 35%;
padding: 8px 12px;
.data-table td {
border: 1px solid var(--border);
font-size: 11px;
color: var(--body);
padding: 8px 12px;
}
.data-table .lbl {
background: var(--surface);
color: var(--body);
font-weight: 700;
width: 15%;
}
.form-value {
width: 65%;
padding: 8px 12px;
font-size: 11px;
.data-table .val {
color: var(--ink);
width: 35%;
}
.form-text-block {
.data-table .text-cell {
padding: 12px;
font-size: 11px;
color: var(--ink);
line-height: 2;
text-align: justify;
}
@ -201,60 +209,45 @@
<div class="form-body">
<div class="form-title">نموذج الشكوى</div>
<div class="form-section-header">بيانات المشفى</div>
<div class="form-row">
<div class="form-label">رقم الملف</div>
<div class="form-value">{{ complaint.reference_number|default:"—" }}</div>
</div>
<div class="form-row">
<div class="form-label">الاسم</div>
<div class="form-value">{{ complainant_name|default:"—" }}</div>
</div>
<div class="form-row">
<div class="form-label">جهة الادعاء</div>
<div class="form-value">{{ source_name|default:"—" }}</div>
</div>
<div class="form-row">
<div class="form-label">تاريخ تقديم الشكوى</div>
<div class="form-value">{{ submission_date }}</div>
</div>
<div class="form-row">
<div class="form-label">تاريخ الحادثة</div>
<div class="form-value">{{ incident_date }}</div>
</div>
<div class="form-row">
<div class="form-label">رقم الحالة</div>
<div class="form-value">{{ complaint.reference_number|default:"—" }}</div>
</div>
<table class="data-table">
<tr>
<td class="lbl">رقم الملف</td>
<td class="val">{{ complaint.reference_number|default:"—" }}</td>
<td class="lbl">الاسم</td>
<td class="val">{{ complainant_name|default:"—" }}</td>
</tr>
<tr>
<td class="lbl">جهة الادعاء</td>
<td class="val">{{ source_name|default:"—" }}</td>
<td class="lbl">تاريخ التقديم</td>
<td class="val">{{ submission_date }}</td>
</tr>
<tr>
<td class="lbl">تاريخ الحادثة</td>
<td class="val">{{ incident_date }}</td>
<td class="lbl">رقم الحالة</td>
<td class="val">{{ complaint.reference_number|default:"—" }}</td>
</tr>
<tr>
<td class="lbl">القسم</td>
<td class="val">{{ department_name|default:"—" }}</td>
<td class="lbl">الموظف المشكو عليه</td>
<td class="val">{{ accused_staff_name|default:"—" }}</td>
</tr>
<tr>
<td class="lbl">الوظيفة</td>
<td class="val">{{ accused_staff_title|default:"—" }}</td>
<td class="lbl">تاريخ الإرسال</td>
<td class="val">{{ sent_to_dept_date }}</td>
</tr>
</table>
<div class="form-section-header">بيانات المشفى</div>
<div class="form-row">
<div class="form-label">القسم</div>
<div class="form-value">{{ department_name|default:"—" }}</div>
</div>
<div class="form-row">
<div class="form-label">اسم الموظف</div>
<div class="form-value">{{ accused_staff_name|default:"—" }}</div>
</div>
<div class="form-row">
<div class="form-label">الوظيفة</div>
<div class="form-value">{{ accused_staff_title|default:"—" }}</div>
</div>
<div class="form-row">
<div class="form-label">تاريخ إرسال الشكوى</div>
<div class="form-value">{{ sent_to_dept_date }}</div>
</div>
<div class="form-section-header">مختصر الشكوى</div>
<div class="form-text-block">
{{ content_summary }}
</div>
<div class="signature-block">
<div class="signature-label">قسم علاقات المرضى</div>
<div class="signature-line"></div>
<div class="signature-stamp-text">التوقيع والختم</div>
</div>
<table class="data-table">
<tr><th colspan="4">مختصر الشكوى</th></tr>
<tr>
<td colspan="4" class="text-cell">{{ content_summary }}</td>
</tr>
</table>
</div>
{% include "complaints/partials/pdf_letterhead_footer.html" %}
@ -268,42 +261,33 @@
<div class="form-body">
<div class="form-title">نموذج رد الشكوى</div>
<div class="form-section-header">بيانات رد الشكوى</div>
<div class="form-row">
<div class="form-label">اسم الموظف</div>
<div class="form-value">{{ accused_staff_name|default:"—" }}</div>
</div>
<div class="form-row">
<div class="form-label">الوظيفة</div>
<div class="form-value">{{ accused_staff_title|default:"—" }}</div>
</div>
<div class="form-row">
<div class="form-label">القسم</div>
<div class="form-value">{{ department_name|default:"—" }}</div>
</div>
<div class="form-row">
<div class="form-label">تاريخ رد الشكوى</div>
<div class="form-value">{{ response_date }}</div>
</div>
<div class="form-row">
<div class="form-label">تاريخ إرسال الشكوى</div>
<div class="form-value">{{ sent_to_dept_date }}</div>
</div>
<table class="data-table">
<tr>
<td class="lbl">الموظف المشكو عليه</td>
<td class="val">{{ accused_staff_name|default:"—" }}</td>
<td class="lbl">الوظيفة</td>
<td class="val">{{ accused_staff_title|default:"—" }}</td>
</tr>
<tr>
<td class="lbl">القسم</td>
<td class="val">{{ department_name|default:"—" }}</td>
<td class="lbl">تاريخ الرد</td>
<td class="val">{{ response_date }}</td>
</tr>
<tr>
<td class="lbl">تاريخ الإرسال</td>
<td class="val">{{ sent_to_dept_date }}</td>
<td class="lbl"></td>
<td class="val"></td>
</tr>
</table>
<div class="form-section-header">مختصر الرد</div>
<div class="form-text-block">
{% if dept_response_summary %}
{{ dept_response_summary }}
{% else %}
لم يتم تسجيل رد من القسم بعد.
{% endif %}
</div>
<div class="signature-block">
<div class="signature-label">قسم علاقات المرضى</div>
<div class="signature-line"></div>
<div class="signature-stamp-text">التوقيع والختم</div>
</div>
<table class="data-table">
<tr><th colspan="4">مختصر الرد</th></tr>
<tr>
<td colspan="4" class="text-cell">{% if dept_response_summary %}{{ dept_response_summary }}{% else %}لم يتم تسجيل رد من القسم بعد.{% endif %}</td>
</tr>
</table>
</div>
{% include "complaints/partials/pdf_letterhead_footer.html" %}

View File

@ -174,7 +174,7 @@
{% for hospital in form.hospital.field.queryset %}
<option value="{{ hospital.id }}"
{% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name }}
{{ hospital.get_localized_name }}
</option>
{% endfor %}
</select>

View File

@ -189,7 +189,7 @@
{% for hospital in form.hospital.field.queryset %}
<option value="{{ hospital.id }}"
{% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name }}
{{ hospital.get_localized_name }}
</option>
{% endfor %}
</select>

Some files were not shown because too many files have changed in this diff Show More