update regarding the complaint flow
This commit is contained in:
parent
ae4afbcca9
commit
7f19b3283c
68
.env.bak_qa
Normal file
68
.env.bak_qa
Normal 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
4
.gitignore
vendored
@ -18,6 +18,10 @@ lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
||||
# Database backups
|
||||
backups/*.dump
|
||||
backups/*.tar.gz
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
|
||||
17
.mimocode/plans/1782056018571-stellar-garden.md
Normal file
17
.mimocode/plans/1782056018571-stellar-garden.md
Normal 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
238
apps/accounts/forms.py
Normal 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
|
||||
220
apps/analytics/kpi_pdf_service.py
Normal file
220
apps/analytics/kpi_pdf_service.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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',
|
||||
)
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
@ -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."))
|
||||
@ -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"
|
||||
|
||||
@ -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}")
|
||||
@ -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,
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
54
apps/complaints/migrations/0024_investigation_attachments.py
Normal file
54
apps/complaints/migrations/0024_investigation_attachments.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
33
apps/complaints/migrations/0027_investigation_findings.py
Normal file
33
apps/complaints/migrations/0027_investigation_findings.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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}"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
@ -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
130
apps/complaints/workflow_log.py
Normal file
130
apps/complaints/workflow_log.py
Normal 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
|
||||
@ -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"""
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
39
apps/core/pdf_utils.py
Normal 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
|
||||
@ -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})
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
@ -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',
|
||||
)
|
||||
|
||||
80
apps/organizations/department_contacts.py
Normal file
80
apps/organizations/department_contacts.py
Normal 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))
|
||||
@ -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."
|
||||
))
|
||||
@ -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
|
||||
|
||||
106
apps/organizations/patient_lookup.py
Normal file
106
apps/organizations/patient_lookup.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
0
backups/.gitkeep
Normal file
BIN
backups/db_pre_dedup_20260628_101948.sqlite3
Normal file
BIN
backups/db_pre_dedup_20260628_101948.sqlite3
Normal file
Binary file not shown.
118
config/celery.py
118
config/celery.py
@ -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
|
||||
# },
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
95
mockups/complaint_detail/index.html
Normal file
95
mockups/complaint_detail/index.html
Normal 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>
|
||||
251
mockups/complaint_detail/sample-a.html
Normal file
251
mockups/complaint_detail/sample-a.html
Normal 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 & 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 & 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 & 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 & 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>
|
||||
265
mockups/complaint_detail/sample-b.html
Normal file
265
mockups/complaint_detail/sample-b.html
Normal 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 & 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 & 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>
|
||||
204
mockups/complaint_detail/sample-c.html
Normal file
204
mockups/complaint_detail/sample-c.html
Normal 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 & 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>
|
||||
352
mockups/complaint_detail/sample-d.html
Normal file
352
mockups/complaint_detail/sample-d.html
Normal 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 & 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 & 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 & 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 & 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 & 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 & 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>
|
||||
@ -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]
|
||||
|
||||
@ -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
315
scripts/db_sync.sh
Executable 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"
|
||||
39
static/js/report-charts.js
Normal file
39
static/js/report-charts.js
Normal 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
9
static/vendor/datastar/datastar.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
617
templates/analytics/kpi_report_weasyprint.html
Normal file
617
templates/analytics/kpi_report_weasyprint.html
Normal 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 }} |
|
||||
{{ report.report_period_display }} |
|
||||
{% 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 |
|
||||
{{ report.hospital.name }} |
|
||||
{% trans "Report ID" %}: {{ report.id }} |
|
||||
{% trans "Generated" %}: {{ generated_at }}
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@ -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" }} — {{ appreciation.hospital.name }}
|
||||
{{ appreciation.created_at|date:"Y-m-d H:i" }} — {{ 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');
|
||||
});
|
||||
|
||||
@ -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 %}
|
||||
|
||||
27
templates/appreciation/appreciation_pdf.html
Normal file
27
templates/appreciation/appreciation_pdf.html
Normal 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 %}
|
||||
@ -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 %} > {{ 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 %} > {{ 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 %}
|
||||
· {% 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 %}
|
||||
|
||||
1503
templates/complaints/complaint_detail_ds.html
Normal file
1503
templates/complaints/complaint_detail_ds.html
Normal file
File diff suppressed because it is too large
Load Diff
194
templates/complaints/complaint_explanation_pdf.html
Normal file
194
templates/complaints/complaint_explanation_pdf.html
Normal 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>
|
||||
@ -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
105
templates/complaints/complaint_review_pdf.html
Normal file
105
templates/complaints/complaint_review_pdf.html
Normal 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>
|
||||
@ -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" %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user