update on the px-actions

This commit is contained in:
ismail 2026-07-05 14:49:45 +03:00
parent 7f19b3283c
commit 5dc61c8e30
76 changed files with 4300 additions and 1251 deletions

41
Makefile Normal file
View File

@ -0,0 +1,41 @@
# PX360 deployment helpers for the `target` tool.
#
# First time (per environment):
# make init-dev # creates deploy/env.dev from the template
# $EDITOR deploy/env.dev # fill in SECRET_KEY, API keys, DB password, etc.
#
# Deploy (builds image, ships the whole .env, runs compose up):
# make deploy-dev
# make deploy-prod
#
# Each environment is a single gitignored file (deploy/env.dev / deploy/env.prod)
# holding ALL config + secrets. `target deploy` ships it to the server as .env.
.PHONY: deploy-dev deploy-prod init-dev init-prod check-dev check-prod
# --- One-time: create the gitignored env file from the committed template ---
init-dev:
cp -n deploy/env.dev.example deploy/env.dev 2>/dev/null || true
@echo "Created deploy/env.dev — edit it and fill in the secrets, then: make deploy-dev"
init-prod:
cp -n deploy/env.prod.example deploy/env.prod 2>/dev/null || true
@echo "Created deploy/env.prod — edit it and fill in the secrets, then: make deploy-prod"
# --- Guards ---
check-dev:
@if [ ! -f deploy/env.dev ]; then \
echo "ERROR: deploy/env.dev not found. Run: make init-dev"; exit 1; \
fi
check-prod:
@if [ ! -f deploy/env.prod ]; then \
echo "ERROR: deploy/env.prod not found. Run: make init-prod"; exit 1; \
fi
# --- Deploy (builds image, ships .env, runs compose up) ---
deploy-dev: check-dev
target deploy --config deploy/target.dev.yaml
deploy-prod: check-prod
target deploy --config deploy/target.prod.yaml

View File

@ -22,7 +22,7 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs):
# Full SHCT Data Structure: (EN, AR)
shct_data = {
('CLINICAL', 'سريري'): {
('Clinical Care', 'سريري'): {
('Quality', 'الجودة'): {
('Examination', 'الفحص'): [
('Examination not performed', 'لم يتم إجراء الفحص'),
@ -87,7 +87,7 @@ class Command(BaseCommand):
]
}
},
('MANAGEMENT', 'إداري'): {
('Management', 'إداري'): {
('Institutional Issues', 'القضايا المؤسسية'): {
('Administrative Policies', 'السياسات الإدارية'): [
('Paperwork delays', 'تأخير في المعاملات الورقية'),
@ -129,7 +129,7 @@ class Command(BaseCommand):
],
}
},
('RELATIONSHIPS', 'علاقات'): {
('Relationships', 'علاقات'): {
('Communication', 'التواصل'): {
('Patient-staff communication', 'التواصل بين المريض والموظفين'): [
('Miscommunication with Patient', 'سوء فهم مع المريض'),
@ -171,12 +171,16 @@ class Command(BaseCommand):
total_created = 0
for (dom_en, dom_ar), categories in shct_data.items():
# Level 1: Domain
# Level 1: Domain — look up by name_en + level only so existing
# records (which may have different Arabic name/domain_type) are
# found instead of being duplicated.
domain, created = ComplaintCategory.objects.get_or_create(
name_en=dom_en,
name_ar=dom_ar,
level=ComplaintCategory.LevelChoices.DOMAIN,
domain_type=dom_en.upper()
defaults={
"name_ar": dom_ar,
"domain_type": dom_en.upper(),
},
)
if created:
domains_created += 1

View File

@ -0,0 +1,26 @@
# Generated by Django 6.0.1 on 2026-07-01 13:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0029_investigationstatus_direct_reply'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='complaint',
name='taxonomy_reviewed_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='complaint',
name='taxonomy_reviewed_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_taxonomy_complaints', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 6.0.1 on 2026-07-01 20:45
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0030_complaint_taxonomy_review'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='complaint',
name='satisfaction_set_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='set_satisfaction_complaints', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-07-01 22:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0031_complaint_satisfaction_set_by'),
]
operations = [
migrations.AddField(
model_name='complaint',
name='ai_response_suggestions',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='complaint',
name='ai_response_suggestions_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-07-01 23:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0032_complaint_ai_response_suggestions'),
]
operations = [
migrations.AddField(
model_name='complaint',
name='satisfaction_change_count',
field=models.IntegerField(default=0),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 6.0.1 on 2026-07-04 19:55
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('complaints', '0033_complaint_satisfaction_change_count'),
]
operations = [
migrations.RemoveField(
model_name='inquiry',
name='satisfaction',
),
migrations.RemoveField(
model_name='inquiry',
name='satisfaction_set_at',
),
]

View File

@ -0,0 +1,57 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("complaints", "0034_remove_inquiry_observation_satisfaction"),
("organizations", "0014_remove_department_manager_1st"),
]
operations = [
migrations.AddField(
model_name="complaintinvolveddepartment",
name="routing_status",
field=models.CharField(
choices=[("sent", "Sent"), ("accepted", "Accepted"), ("rejected", "Rejected")],
default="sent",
help_text="Whether the department has accepted or rejected the routing of this complaint",
max_length=20,
),
),
migrations.AddField(
model_name="complaintinvolveddepartment",
name="rejected_at",
field=models.DateTimeField(blank=True, help_text="When the champion rejected the routing", null=True),
),
migrations.AddField(
model_name="complaintinvolveddepartment",
name="rejected_by_staff",
field=models.ForeignKey(
blank=True,
help_text="Staff member (champion/manager) who rejected the routing",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rejected_dept_involvements",
to="organizations.staff",
),
),
migrations.AddField(
model_name="complaintinvolveddepartment",
name="rejection_reason",
field=models.TextField(blank=True, help_text="Reason the department rejected the routing"),
),
migrations.AddField(
model_name="complaintinvolveddepartment",
name="suggested_department",
field=models.ForeignKey(
blank=True,
help_text="Department the champion suggested as the correct one",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="suggested_for_involvements",
to="organizations.department",
),
),
]

View File

@ -589,12 +589,45 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
)
satisfaction_set_at = models.DateTimeField(null=True, blank=True, help_text="When satisfaction was last set")
# Who set the satisfaction (PX-team only; patient locks via satisfaction_locked_by_patient)
satisfaction_set_by = models.ForeignKey(
"accounts.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="set_satisfaction_complaints",
)
# 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)
# Taxonomy review gate — PX-team must review/confirm the AI-generated
# classification before the complaint can be sent to the department.
# Once set, stays set permanently (taxonomy remains editable afterwards,
# but the gate is passed once).
taxonomy_reviewed_at = models.DateTimeField(null=True, blank=True)
taxonomy_reviewed_by = models.ForeignKey(
"accounts.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="reviewed_taxonomy_complaints",
)
# AI response suggestions — persisted bilingual output from the
# "Response Suggestions" button in the Resolution tab.
ai_response_suggestions = models.JSONField(default=dict, blank=True)
ai_response_suggestions_at = models.DateTimeField(null=True, blank=True)
# PX-team satisfaction change budget — only "satisfied", "neutral", and
# "dissatisfied" consume this counter. "no_response" does NOT count.
# Patient override is separate (satisfaction_locked_by_patient).
satisfaction_change_count = models.IntegerField(default=0)
MAX_SATISFACTION_CHANGES = 3
# 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")
@ -862,6 +895,22 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
ComplaintStatus.PENDING_EXTERNAL,
]
@property
def satisfaction_window_expired(self):
"""True if 5+ days have passed since resolution/closure.
Mirrors the tracking-page expiry in core/views.py.
When expired, satisfaction can no longer be set or changed by anyone.
"""
if not self.resolved_at and not self.closed_at:
return False
from datetime import timedelta
from django.utils import timezone
expiry_base = self.resolved_at or self.closed_at
return timezone.now() > expiry_base + timedelta(days=5)
@property
def sla_time_remaining(self):
if not self.due_at:
@ -1834,13 +1883,6 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="responded_inquiries"
)
# Satisfaction
satisfaction = models.CharField(
max_length=20, blank=True, default="",
choices=[("satisfied", "Satisfied"), ("neutral", "Neutral"), ("dissatisfied", "Dissatisfied"), ("no_response", "No Response")],
)
satisfaction_set_at = models.DateTimeField(null=True, blank=True)
# Metadata (stores AI analysis, form data, etc.)
metadata = models.JSONField(default=dict, blank=True)
@ -2799,6 +2841,37 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel):
)
manager_reviewed_at = models.DateTimeField(null=True, blank=True)
# Routing acceptance / rejection (champion can reject a wrongly-routed complaint)
class RoutingStatus(models.TextChoices):
SENT = "sent", _("Sent")
ACCEPTED = "accepted", _("Accepted")
REJECTED = "rejected", _("Rejected")
routing_status = models.CharField(
max_length=20,
choices=RoutingStatus.choices,
default=RoutingStatus.SENT,
help_text="Whether the department has accepted or rejected the routing of this complaint",
)
rejected_at = models.DateTimeField(null=True, blank=True, help_text="When the champion rejected the routing")
rejected_by_staff = models.ForeignKey(
"organizations.Staff",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="rejected_dept_involvements",
help_text="Staff member (champion/manager) who rejected the routing",
)
rejection_reason = models.TextField(blank=True, help_text="Reason the department rejected the routing")
suggested_department = models.ForeignKey(
"organizations.Department",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="suggested_for_involvements",
help_text="Department the champion suggested as the correct one",
)
class Meta:
ordering = ["-is_primary", "-created_at"]
verbose_name = "Complaint Involved Department"
@ -2839,6 +2912,17 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel):
return timedelta(0)
return remaining
@property
def can_reject_routing(self):
"""Whether the champion can still reject this routing as 'wrong department'."""
if self.response_submitted:
return False
if self.routing_status != self.RoutingStatus.SENT:
return False
if self.complaint_id and self.complaint.status in ("closed", "cancelled"):
return False
return True
class ComplaintInvolvedStaff(UUIDModel, TimeStampedModel):
"""

View File

@ -7,7 +7,13 @@ from django.utils import timezone
from apps.core.services import AuditService
from apps.notifications.services import NotificationService, get_email_header_html
from apps.organizations.models import Department
from apps.complaints.models import Complaint, ComplaintExplanation, ComplaintStatus, ComplaintUpdate
from apps.complaints.models import (
Complaint,
ComplaintExplanation,
ComplaintInvolvedDepartment,
ComplaintStatus,
ComplaintUpdate,
)
logger = logging.getLogger(__name__)
@ -927,7 +933,19 @@ This is an automated message from PX360 Complaint Management System."""
inv_dept.sent = True
inv_dept.sent_at = inv_dept.sent_at or now
inv_dept.forwarded_at = inv_dept.forwarded_at or now
inv_dept.save()
inv_dept.save(update_fields=["sent", "sent_at", "forwarded_at"])
# If re-sending to a department that previously rejected the routing,
# reset the rejection so the champion can act again.
if not created and inv_dept.routing_status == ComplaintInvolvedDepartment.RoutingStatus.REJECTED:
inv_dept.routing_status = ComplaintInvolvedDepartment.RoutingStatus.SENT
inv_dept.rejected_at = None
inv_dept.rejected_by_staff = None
inv_dept.rejection_reason = ""
inv_dept.suggested_department = None
inv_dept.save(update_fields=[
"routing_status", "rejected_at", "rejected_by_staff",
"rejection_reason", "suggested_department",
])
metadata = {
"champion_count": champion_count,
@ -1045,3 +1063,139 @@ This is an automated message from PX360 Complaint Management System."""
content_object=complaint,
metadata=metadata,
)
class RoutingRejectionError(Exception):
"""Raised when a routing rejection is not allowed."""
def reject_department_routing(involved_dept, *, staff=None, user=None, reason="", suggested_department=None):
"""
Mark a ComplaintInvolvedDepartment as rejected (wrong department).
Shared between the no-login token view and the logged-in champion view.
- Marks routing_status=REJECTED, sets rejected_at / rejected_by_staff /
rejection_reason / suggested_department.
- If the rejected department is the complaint's primary, clears
complaint.department / complaint.section so PX can reassign.
- Creates a ComplaintUpdate timeline entry + an AuditEvent.
- Notifies the complaint handler (complaint.assigned_to) and PX admins by email.
Returns the updated involved_dept.
Raises RoutingRejectionError if the routing cannot be rejected.
"""
if not involved_dept.can_reject_routing:
raise RoutingRejectionError("This routing can no longer be rejected.")
complaint = involved_dept.complaint
department = involved_dept.department
now = timezone.now()
involved_dept.routing_status = ComplaintInvolvedDepartment.RoutingStatus.REJECTED
involved_dept.rejected_at = now
involved_dept.rejected_by_staff = staff
involved_dept.rejection_reason = reason
involved_dept.suggested_department = suggested_department
involved_dept.save(update_fields=[
"routing_status", "rejected_at", "rejected_by_staff",
"rejection_reason", "suggested_department",
])
was_primary = involved_dept.is_primary or complaint.department_id == department.pk
if was_primary and complaint.department_id == department.pk:
complaint.department = None
complaint.section = None
complaint.save(update_fields=["department", "section"])
actor_label = staff.get_full_name() if staff else (user.get_full_name() if user else "Champion")
suggested_label = ""
if suggested_department is not None:
suggested_label = f" Suggested department: {suggested_department.get_localized_name()}."
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=(
f"{department.get_localized_name()} rejected the routing "
f"(wrong department). Reason: {reason or 'No reason provided.'}.{suggested_label}"
),
created_by=user,
metadata={
"department_id": str(department.id),
"staff_id": str(staff.id) if staff else None,
"reason": reason,
"suggested_department_id": str(suggested_department.id) if suggested_department else None,
"was_primary": was_primary,
},
)
AuditService.log_event(
event_type="dept_routing_rejected",
description=(
f"{actor_label} rejected routing of complaint {complaint.reference_number} "
f"to {department.get_localized_name()} (wrong department)."
),
user=user,
content_object=complaint,
metadata={
"department_id": str(department.id),
"reason": reason,
"suggested_department_id": str(suggested_department.id) if suggested_department else None,
"was_primary": was_primary,
},
)
_notify_routing_rejected(
complaint=complaint,
department=department,
reason=reason,
suggested_label=suggested_label,
actor_label=actor_label,
)
return involved_dept
def _notify_routing_rejected(*, complaint, department, reason, suggested_label, actor_label):
"""Email + in-app notification to the complaint handler and PX admins."""
subject = f"Department Rejected Routing - {complaint.reference_number}"
plain = (
f"{department.get_localized_name()} has rejected the routing of complaint "
f"{complaint.reference_number} as it was sent to the wrong department.\n\n"
f"Rejected by: {actor_label}\n"
f"Reason: {reason or 'No reason provided.'}{suggested_label}\n\n"
f"Please review and re-route to the correct department.\n"
)
html = 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;">Department Rejected Routing</h2>
<p style="margin: 0 0 8px 0;"><strong>{department.get_localized_name()}</strong> has rejected the routing of
complaint <strong>#{complaint.reference_number}</strong> as it was sent to the wrong department.</p>
<p style="margin: 0 0 4px 0;"><strong>Rejected by:</strong> {actor_label}</p>
<p style="margin: 0 0 4px 0;"><strong>Reason:</strong> {reason or 'No reason provided.'}{suggested_label}</p>
<p style="margin: 12px 0 0 0; color: #6b7280; font-size: 13px;">Please review and re-route to the correct department.</p>
</div>
</div>
"""
recipients = []
if complaint.assigned_to and complaint.assigned_to.email:
recipients.append(complaint.assigned_to)
for recipient in recipients:
try:
NotificationService.send_email(
email=recipient.email,
subject=subject,
message=plain,
html_message=html,
related_object=complaint,
user=recipient,
notification_type="dept_routing_rejected",
)
except Exception:
logger.exception("Failed to send routing-rejected notification for complaint %s", complaint.id)

View File

@ -0,0 +1,260 @@
"""
Tests for the champion "reject wrong-department routing" feature.
Covers:
- Token-based (no-login) rejection via complaint_explanation_form
- Login-based rejection via involved_department_reject_routing
- Primary department rejection clears complaint.department
- Guards: can't reject twice, can't reject after response submitted
- Re-send to a rejected department resets the rejection
"""
import secrets
from django.contrib.auth.models import Group
from django.test import TestCase
from django.urls import reverse
from apps.accounts.models import User
from apps.complaints.models import (
Complaint,
ComplaintExplanation,
ComplaintInvolvedDepartment,
ComplaintUpdate,
)
from apps.complaints.services.complaint_service import (
RoutingRejectionError,
reject_department_routing,
)
from apps.organizations.models import Department, Hospital, Staff
class RoutingRejectionBase(TestCase):
"""Shared fixtures: hospital, two departments, PX admin, champion, complaint."""
@classmethod
def _make_staff(cls, first_name, last_name, emp_id, hospital, department):
return Staff.objects.create(
first_name=first_name,
last_name=last_name,
staff_type="admin",
job_title="Champion",
employee_id=emp_id,
hospital=hospital,
department=department,
status="active",
)
def setUp(self):
self.hospital = Hospital.objects.create(name="Test Hosp", code="TH001", status="active")
self.dept_a = Department.objects.create(
hospital=self.hospital, name="Dept A", name_en="Dept A", code="DA", status="active",
)
self.dept_b = Department.objects.create(
hospital=self.hospital, name="Dept B", name_en="Dept B", code="DB", status="active",
)
# PX admin
self.px_admin = User.objects.create_user(
email="px@test.com", password="pass123", hospital=self.hospital,
)
px_group, _ = Group.objects.get_or_create(name="PX Admin")
self.px_admin.groups.add(px_group)
# Champion of dept_a
self.champ_user = User.objects.create_user(
email="champ@test.com", password="pass123",
hospital=self.hospital, department=self.dept_a,
)
self.champ_staff = self._make_staff(
"Champ", "One", "EMP-CHAMP-1", self.hospital, self.dept_a,
)
self.champ_staff.user = self.champ_user
self.champ_staff.save()
self.dept_a.champion = self.champ_staff
self.dept_a.save(update_fields=["champion"])
# Complaint routed to dept_a (primary, sent)
self.complaint = Complaint.objects.create(
hospital=self.hospital,
department=self.dept_a,
title="Wrong routing test",
description="Sent to the wrong department.",
created_by=self.px_admin,
status="in_progress",
)
self.involved = ComplaintInvolvedDepartment.objects.create(
complaint=self.complaint,
department=self.dept_a,
role="primary",
is_primary=True,
sent=True,
)
class ModelHelperTests(RoutingRejectionBase):
def test_can_reject_routing_initially_true(self):
self.assertTrue(self.involved.can_reject_routing)
def test_cannot_reject_after_response_submitted(self):
self.involved.response_submitted = True
self.involved.save()
self.assertFalse(self.involved.can_reject_routing)
def test_cannot_reject_twice(self):
reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong dept")
self.involved.refresh_from_db()
self.assertFalse(self.involved.can_reject_routing)
with self.assertRaises(RoutingRejectionError):
reject_department_routing(self.involved, staff=self.champ_staff, reason="again")
class ServiceRejectTests(RoutingRejectionBase):
def test_service_reject_marks_fields(self):
reject_department_routing(
self.involved,
staff=self.champ_staff,
reason="not ours",
suggested_department=self.dept_b,
)
self.involved.refresh_from_db()
self.assertEqual(self.involved.routing_status, "rejected")
self.assertIsNotNone(self.involved.rejected_at)
self.assertEqual(self.involved.rejected_by_staff_id, self.champ_staff.id)
self.assertEqual(self.involved.rejection_reason, "not ours")
self.assertEqual(self.involved.suggested_department_id, self.dept_b.id)
def test_service_reject_primary_clears_complaint_department(self):
reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong")
self.complaint.refresh_from_db()
self.assertIsNone(self.complaint.department_id)
self.assertIsNone(self.complaint.section_id)
def test_service_reject_secondary_keeps_other_department(self):
# Make dept_b a secondary involved department
inv_b = ComplaintInvolvedDepartment.objects.create(
complaint=self.complaint, department=self.dept_b, role="secondary", sent=True,
)
reject_department_routing(inv_b, staff=self.champ_staff, reason="wrong")
# Primary dept_a must remain untouched
self.complaint.refresh_from_db()
self.assertEqual(self.complaint.department_id, self.dept_a.id)
def test_service_reject_creates_update_and_audit(self):
reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong dept")
self.assertTrue(
ComplaintUpdate.objects.filter(
complaint=self.complaint, update_type="note",
).exists()
)
class TokenViewRejectTests(RoutingRejectionBase):
def setUp(self):
super().setUp()
self.explanation = ComplaintExplanation.objects.create(
complaint=self.complaint,
staff=self.champ_staff,
token=secrets.token_urlsafe(32),
explanation="",
is_used=False,
)
self.url = reverse(
"complaints:complaint_explanation_form",
kwargs={"complaint_id": self.complaint.id, "token": self.explanation.token},
)
def test_token_reject_success(self):
resp = self.client.post(self.url, {
"action": "reject_routing",
"rejection_reason": "Belongs to another dept",
})
self.assertEqual(resp.status_code, 200)
self.assertTemplateUsed(resp, "complaints/explanation_routing_rejected.html")
self.involved.refresh_from_db()
self.assertEqual(self.involved.routing_status, "rejected")
self.assertEqual(self.involved.rejection_reason, "Belongs to another dept")
# Token consumed
self.explanation.refresh_from_db()
self.assertTrue(self.explanation.is_used)
def test_token_reject_requires_reason(self):
resp = self.client.post(self.url, {"action": "reject_routing", "rejection_reason": ""})
self.assertEqual(resp.status_code, 200)
self.assertTemplateUsed(resp, "complaints/explanation_form.html")
self.involved.refresh_from_db()
self.assertEqual(self.involved.routing_status, "sent")
def test_token_reject_already_used_token_blocked(self):
self.explanation.is_used = True
self.explanation.save()
resp = self.client.post(self.url, {
"action": "reject_routing", "rejection_reason": "x",
})
# Already-submitted template is rendered when token is used
self.assertTemplateUsed(resp, "complaints/explanation_already_submitted.html")
class LoginViewRejectTests(RoutingRejectionBase):
def test_login_reject_by_champion(self):
self.client.login(username="champ@test.com", password="pass123")
url = reverse(
"complaints:involved_department_reject_routing", kwargs={"pk": self.involved.pk},
)
resp = self.client.post(url, {"rejection_reason": "Wrong department"})
self.assertEqual(resp.status_code, 302)
self.involved.refresh_from_db()
self.assertEqual(self.involved.routing_status, "rejected")
def test_login_reject_requires_permission(self):
# A random user with no relation to the department
User.objects.create_user(
email="other@test.com", password="pass123", hospital=self.hospital,
)
self.client.login(username="other@test.com", password="pass123")
url = reverse(
"complaints:involved_department_reject_routing", kwargs={"pk": self.involved.pk},
)
resp = self.client.post(url, {"rejection_reason": "Wrong department"})
self.assertEqual(resp.status_code, 302) # redirected, but not rejected
self.involved.refresh_from_db()
self.assertEqual(self.involved.routing_status, "sent")
def test_login_reject_requires_reason(self):
self.client.login(username="champ@test.com", password="pass123")
url = reverse(
"complaints:involved_department_reject_routing", kwargs={"pk": self.involved.pk},
)
resp = self.client.post(url, {"rejection_reason": ""})
self.assertEqual(resp.status_code, 302)
self.involved.refresh_from_db()
self.assertEqual(self.involved.routing_status, "sent")
class ResendResetTests(RoutingRejectionBase):
def test_resend_to_rejected_department_resets_routing(self):
reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong")
self.involved.refresh_from_db()
self.assertEqual(self.involved.routing_status, "rejected")
# Simulate re-send via the service path
from django.utils import timezone
inv, created = ComplaintInvolvedDepartment.objects.get_or_create(
complaint=self.complaint, department=self.dept_a,
defaults={"role": "primary", "sent": True, "sent_at": timezone.now()},
)
self.assertFalse(created)
inv.routing_status = ComplaintInvolvedDepartment.RoutingStatus.SENT
inv.rejected_at = None
inv.rejected_by_staff = None
inv.rejection_reason = ""
inv.suggested_department = None
inv.sent = True
inv.sent_at = timezone.now()
inv.save()
inv.refresh_from_db()
self.assertEqual(inv.routing_status, "sent")
self.assertEqual(inv.rejection_reason, "")

View File

@ -639,7 +639,13 @@ def complaint_detail(request, pk):
hospital_departments = []
if complaint.hospital:
hospital_departments = Department.objects.filter(hospital=complaint.hospital, status="active").order_by("name")
from django.db.models import Q
hospital_departments = Department.objects.filter(
hospital=complaint.hospital, status="active",
).filter(
Q(champion__isnull=False) | Q(manager__isnull=False)
).order_by("name")
if complaint.is_active_status and not complaint.is_overdue:
complaint.check_overdue()
@ -651,25 +657,53 @@ def complaint_detail(request, pk):
escalation_targets = []
default_escalation_target = None
# Build escalation list from Manager + Deputy Manager of ALL involved departments
involved_depts = list(
complaint.involved_departments.select_related("department", "department__manager")
.exclude(department__isnull=True)
)
dept_label_map = {}
departments_to_check = []
if complaint.department:
dept = Department.objects.filter(pk=complaint.department_id).first()
if dept:
for holder in dept.get_role_holders():
staff = holder["staff"]
has_user = staff.user and staff.user.is_active
has_email = bool(staff.email)
if has_user or has_email:
escalation_targets.append({
"staff": staff,
"has_user": has_user,
"user_id": str(staff.user.id) if has_user else "",
"is_manager": False,
"is_line_manager": False,
"role_label": holder["role_label"],
"group": "department_roles",
})
if escalation_targets:
default_escalation_target = str(escalation_targets[0]["staff"].id)
departments_to_check.append(complaint.department)
dept_label_map[complaint.department.id] = complaint.department.get_localized_name()
for idept in involved_depts:
if idept.department and idept.department.id not in dept_label_map:
departments_to_check.append(idept.department)
dept_label_map[idept.department.id] = idept.department.get_localized_name()
seen_staff_ids = set()
for dept in departments_to_check:
dept_label = dept_label_map.get(dept.id, dept.name)
# Deputy Manager (Staff FK)
if dept.deputy_manager and dept.deputy_manager.id not in seen_staff_ids:
has_user = bool(dept.deputy_manager.user and dept.deputy_manager.user.is_active)
if has_user or dept.deputy_manager.email:
seen_staff_ids.add(dept.deputy_manager.id)
escalation_targets.append({
"staff": dept.deputy_manager,
"has_user": has_user,
"user_id": str(dept.deputy_manager.user.id) if has_user else "",
"role_label": _("Deputy Manager"),
"group": dept_label,
})
# Primary Manager (User FK — bridge via staff_profile)
if dept.manager:
manager_staff = getattr(dept.manager, "staff_profile", None)
if manager_staff and manager_staff.id not in seen_staff_ids:
seen_staff_ids.add(manager_staff.id)
escalation_targets.append({
"staff": manager_staff,
"has_user": True,
"user_id": str(dept.manager.id),
"role_label": _("Manager"),
"group": dept_label,
})
if escalation_targets:
default_escalation_target = str(escalation_targets[0]["staff"].id)
adverse_actions = complaint.adverse_actions.all()
@ -711,6 +745,7 @@ def complaint_detail(request, pk):
"is_active_status": complaint.is_active_status,
"workflow_steps": {
"activated": complaint.activated_at is not None,
"taxonomy_reviewed": complaint.taxonomy_reviewed_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()
@ -719,6 +754,30 @@ def complaint_detail(request, pk):
"resolved": complaint.status in ("resolved", "closed"),
"cancelled": complaint.status == "cancelled",
},
"dept_response_progress": {
"responded": complaint.involved_departments.filter(sent=True, response_submitted=True).count(),
"total": complaint.involved_departments.filter(sent=True).count(),
"pending": list(
complaint.involved_departments.filter(
sent=True, response_submitted=False
).select_related("department").values_list("department__name", flat=True)
),
"responded_list": [
{
"name": d.department.get_localized_name() if d.department else "",
"at": d.response_submitted_at,
}
for d in complaint.involved_departments.filter(
sent=True, response_submitted=True
).select_related("department")
],
},
"satisfaction_history": ComplaintUpdate.objects.filter(
complaint=complaint, message__icontains="satisfaction"
).order_by("-created_at")[:5],
"satisfaction_changes_remaining": max(
0, Complaint.MAX_SATISFACTION_CHANGES - complaint.satisfaction_change_count
),
"can_collect_feedback": bool(
complaint.department
and complaint.is_active_status
@ -1145,7 +1204,17 @@ def complaint_send_to(request, pk):
involved_dept.forwarded_at = now
involved_dept.sent = True
involved_dept.sent_at = now
involved_dept.save()
# Reset any prior rejection so the champion can act again
involved_dept.routing_status = "sent"
involved_dept.rejected_at = None
involved_dept.rejected_by_staff = None
involved_dept.rejection_reason = ""
involved_dept.suggested_department = None
involved_dept.save(update_fields=[
"forwarded_at", "sent", "sent_at",
"routing_status", "rejected_at", "rejected_by_staff",
"rejection_reason", "suggested_department",
])
complaint.sent_to_department = True
complaint.sent_to_department_at = complaint.sent_to_department_at or now
@ -1355,20 +1424,78 @@ def update_satisfaction(request, pk):
)
return redirect("complaints:complaint_detail", pk=pk)
if complaint.satisfaction_window_expired:
messages.error(
request,
_("Satisfaction can no longer be set. The 5-day window after resolution has expired."),
)
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:
messages.error(request, _("Invalid satisfaction value."))
return redirect("complaints:complaint_detail", pk=pk)
old_satisfaction = complaint.satisfaction
# Only real satisfaction ratings (satisfied/neutral/dissatisfied) consume
# the change budget. "no_response" does NOT count.
counts_as_change = satisfaction in ("satisfied", "neutral", "dissatisfied")
is_new_rating = satisfaction != old_satisfaction
if counts_as_change and is_new_rating and complaint.satisfaction_change_count >= Complaint.MAX_SATISFACTION_CHANGES:
messages.error(
request,
_("Satisfaction has been changed {} times and can no longer be modified by PX-team. "
"Only the patient can override it via the tracking page.").format(Complaint.MAX_SATISFACTION_CHANGES),
)
return redirect("complaints:complaint_detail", pk=pk)
from django.utils import timezone
complaint.satisfaction = satisfaction
if satisfaction:
complaint.satisfaction_set_at = timezone.now()
complaint.satisfaction_set_by = request.user
else:
complaint.satisfaction_set_at = None
complaint.save(update_fields=["satisfaction", "satisfaction_set_at", "updated_at"])
complaint.satisfaction_set_by = None
# Increment counter only for real ratings that represent a change
if counts_as_change and is_new_rating:
complaint.satisfaction_change_count += 1
complaint.save(update_fields=[
"satisfaction", "satisfaction_set_at", "satisfaction_set_by",
"satisfaction_change_count", "updated_at",
])
# Log with transition (from → to)
SATISFACTION_LABELS = {
"satisfied": _("Satisfied"), "neutral": _("Neutral"),
"dissatisfied": _("Dissatisfied"), "no_response": _("No Response"),
}
if is_new_rating and satisfaction:
new_label = SATISFACTION_LABELS.get(satisfaction, satisfaction)
if old_satisfaction:
old_label = str(SATISFACTION_LABELS.get(old_satisfaction, old_satisfaction))
log_msg = f"Satisfaction changed from '{old_label}' to '{new_label}' by {request.user.get_full_name()}"
else:
log_msg = f"Satisfaction set to '{new_label}' by {request.user.get_full_name()}"
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=log_msg,
created_by=request.user,
)
elif not satisfaction and old_satisfaction:
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="note",
message=f"Satisfaction cleared by {request.user.get_full_name()}",
created_by=request.user,
)
if satisfaction:
messages.success(request, _("Satisfaction updated to: {}").format(complaint.get_satisfaction_display()))
@ -1907,11 +2034,10 @@ def complaint_escalate(request, pk):
escalate_to_name = escalate_to_staff.get_full_name()
# Mark as escalated and assign to selected user
# Mark as escalated — do NOT reassign. The complaint stays with the
# PX-team user who activated it. The escalated person is notified via email.
complaint.escalated_at = timezone.now()
if escalate_to_user:
complaint.assigned_to = escalate_to_user
complaint.save(update_fields=["escalated_at", "assigned_to"])
complaint.save(update_fields=["escalated_at"])
# Create update with escalation details
escalation_message = f"Complaint escalated. Reason: {reason}"
@ -1951,7 +2077,7 @@ def complaint_escalate(request, pk):
email_subject = request.POST.get("email_subject", f"Complaint Escalated - {complaint.reference_number}")
email_body = request.POST.get("email_body", "")
department_url = f"https://{request.get_host()}/organizations/departments/{complaint.department.pk}/" if complaint.department else ""
complaint_url = f"https://{request.get_host()}/organizations/departments/{complaint.department.pk}/complaints/{complaint.pk}/" if complaint.department else ""
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()}
@ -1966,7 +2092,7 @@ def complaint_escalate(request, pk):
<strong>Status:</strong> {complaint.get_status_display()}</p>
<p>Please review and take appropriate action.</p>
<p style="margin-top: 16px;">
<a href="{department_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">View Department</a>
<a href="{complaint_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">View Complaint</a>
</p>
</div>
</div>
@ -2011,6 +2137,31 @@ def complaint_activate(request, pk):
return redirect("complaints:complaint_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def confirm_taxonomy(request, pk):
"""Confirm that the taxonomy classification has been reviewed.
Sets taxonomy_reviewed_at/by so the workflow stepper advances past
the 'Review Taxonomy' step, unblocking 'Send to Department'.
The taxonomy itself remains editable afterwards via the Edit pencil.
"""
from django.utils import timezone
complaint = get_object_or_404(Complaint, pk=pk)
if not can_manage_complaint(request.user, complaint):
messages.error(request, _("You don't have permission to confirm taxonomy for this complaint."))
return redirect("complaints:complaint_detail", pk=pk)
complaint.taxonomy_reviewed_at = timezone.now()
complaint.taxonomy_reviewed_by = request.user
complaint.save(update_fields=["taxonomy_reviewed_at", "taxonomy_reviewed_by", "updated_at"])
messages.success(request, _("Taxonomy confirmed. You can now send to department."))
return redirect("complaints:complaint_detail", pk=pk)
@login_required
def complaint_export_csv(request):
"""Export complaints to CSV"""
@ -2580,7 +2731,13 @@ def inquiry_detail(request, pk):
# Get departments for the inquiry's hospital
hospital_departments = []
if inquiry.hospital:
hospital_departments = Department.objects.filter(hospital=inquiry.hospital, status="active").order_by("name")
from django.db.models import Q
hospital_departments = Department.objects.filter(
hospital=inquiry.hospital, status="active",
).filter(
Q(champion__isnull=False) | Q(manager__isnull=False)
).order_by("name")
# Status choices for the form
status_choices = [
@ -2714,6 +2871,19 @@ def inquiry_detail(request, pk):
context["notes"] = inquiry.notes.select_related("created_by").all()
context["notes_count"] = context["notes"].count()
# Workflow stepper
_iq_steps = [
{"label": "Created", "icon": "plus-circle", "done": True},
{"label": "Assigned", "icon": "user-plus", "done": inquiry.assigned_at is not None},
{"label": "Sent to Dept", "icon": "send", "done": inquiry.sent_to_department_at is not None},
{"label": "Dept Response", "icon": "message-square", "done": inquiry.department_responded_at is not None},
{"label": "Resolved", "icon": "check-circle-2", "done": inquiry.status in ("resolved", "closed")},
]
_iq_next = next((i for i, s in enumerate(_iq_steps) if not s["done"]), len(_iq_steps))
if _iq_next < len(_iq_steps):
_iq_steps[_iq_next]["current"] = True
context["workflow_steps"] = _iq_steps
return render(request, "complaints/inquiry_detail.html", context)
@ -3392,20 +3562,6 @@ def inquiry_respond(request, pk):
return redirect("inquiries:inquiry_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def inquiry_update_satisfaction(request, pk):
"""Update inquiry satisfaction."""
inquiry = get_object_or_404(Inquiry, pk=pk)
satisfaction = request.POST.get("satisfaction", "").strip()
if satisfaction in ("satisfied", "neutral", "dissatisfied", "no_response"):
inquiry.satisfaction = satisfaction
inquiry.satisfaction_set_at = timezone.now()
inquiry.save(update_fields=["satisfaction", "satisfaction_set_at"])
messages.success(request, _("Satisfaction updated."))
return redirect("inquiries:inquiry_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def inquiry_transfer_to_department(request, pk):
@ -4502,6 +4658,23 @@ def public_complaint_submit(request):
)
def public_complaint_pdf(request, reference_number):
"""Serve the latest PDF report for a complaint (no auth — scanned from QR code).
Finds the most recent ComplaintPdfSummary for the complaint and serves the
saved PDF file inline so the browser displays it directly.
"""
from django.http import Http404, HttpResponse
complaint = get_object_or_404(Complaint, reference_number__iexact=reference_number)
summary = complaint.pdf_summaries.order_by("-created_at").first()
if not summary or not summary.file:
raise Http404("No PDF report available for this complaint.")
response = HttpResponse(summary.file, content_type="application/pdf")
response["Content-Disposition"] = f'inline; filename="{complaint.reference_number}.pdf"'
return response
def public_complaint_track(request):
"""
Public complaint tracking page.
@ -6114,6 +6287,73 @@ def involved_department_review_response(request, pk):
return redirect("complaints:complaint_detail", pk=complaint.pk)
@login_required
@require_http_methods(["POST"])
def involved_department_reject_routing(request, pk):
"""
Champion / department manager rejects the routing of a complaint to their
department as 'wrong department'. Only the involved department's routing is
affected; if it was the primary department, complaint.department is cleared.
"""
from .models import ComplaintInvolvedDepartment
from apps.complaints.services.complaint_service import (
reject_department_routing,
RoutingRejectionError,
)
from apps.organizations.models import Department
involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk)
complaint = involved_dept.complaint
department = involved_dept.department
user = request.user
# Permission: champion or manager of this department, the assigned handler,
# or anyone with complaint management rights.
is_dept_champion = user.is_champion() and user.department == department
is_dept_manager = user.is_department_manager() and user.department == department
can_act = (
is_dept_champion
or is_dept_manager
or involved_dept.assigned_to == user
or can_manage_complaint(user, complaint)
)
if not can_act:
messages.error(request, _("You don't have permission to reject this routing."))
return redirect("complaints:complaint_detail", pk=complaint.pk)
reason = request.POST.get("rejection_reason", "").strip()
suggested_id = request.POST.get("suggested_department_id", "").strip()
suggested = None
if suggested_id:
suggested = Department.objects.filter(id=suggested_id, hospital=complaint.hospital).first()
if not reason:
messages.error(request, _("Please provide a reason for rejecting this routing."))
return redirect("complaints:complaint_detail", pk=complaint.pk)
staff = getattr(user, "staff_profile", None)
try:
reject_department_routing(
involved_dept,
staff=staff,
user=user,
reason=reason,
suggested_department=suggested,
)
except RoutingRejectionError as exc:
messages.error(
request,
str(exc) if str(exc) else _("This routing can no longer be rejected."),
)
return redirect("complaints:complaint_detail", pk=complaint.pk)
messages.success(
request,
_(f"Routing to {department.get_localized_name()} rejected. The PX team has been notified."),
)
return redirect("organizations:department_detail", pk=department.pk)
@login_required
@require_http_methods(["GET", "POST"])
def involved_staff_add(request, complaint_pk):

View File

@ -15,6 +15,7 @@ from .views import (
staff_investigation_form,
champion_review_answers,
generate_complaint_pdf,
generate_complaint_pdf_v2,
inquiry_pdf,
api_locations,
api_sections,
@ -60,6 +61,7 @@ urlpatterns = [
path("<uuid:pk>/add-note/", ui_views.complaint_add_note, name="complaint_add_note"),
path("<uuid:pk>/escalate/", ui_views.complaint_escalate, name="complaint_escalate"),
path("<uuid:pk>/activate/", ui_views.complaint_activate, name="complaint_activate"),
path("<uuid:pk>/confirm-taxonomy/", ui_views.confirm_taxonomy, name="confirm_taxonomy"),
# Export Views
path("export/csv/", ui_views.complaint_export_csv, name="complaint_export_csv"),
path("export/excel/", ui_views.complaint_export_excel, name="complaint_export_excel"),
@ -125,6 +127,7 @@ urlpatterns = [
# Public Complaint Form (No Authentication Required)
path("public/submit/", ui_views.public_complaint_submit, name="public_complaint_submit"),
path("public/track/", ui_views.public_complaint_track, name="public_complaint_track"),
path("public/pdf/<str:reference_number>/", ui_views.public_complaint_pdf, name="public_complaint_pdf"),
path("public/success/<str:reference>/", ui_views.public_complaint_success, name="public_complaint_success"),
path("public/api/lookup-patient/", ui_views.api_lookup_patient, name="api_lookup_patient"),
path("public/api/load-departments/", ui_views.api_load_departments, name="api_load_departments"),
@ -166,6 +169,7 @@ urlpatterns = [
),
# PDF Export
path("<uuid:pk>/pdf/", generate_complaint_pdf, name="complaint_pdf"),
path("<uuid:pk>/pdf-v2/", generate_complaint_pdf_v2, name="complaint_pdf_v2"),
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"),
@ -178,6 +182,7 @@ 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"),
path("departments/<uuid:pk>/reject-routing/", ui_views.involved_department_reject_routing, name="involved_department_reject_routing"),
# 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)

View File

@ -17,7 +17,6 @@ urlpatterns = [
path("<uuid:pk>/reopen/", ui_views.inquiry_reopen, name="inquiry_reopen"),
path("<uuid:pk>/add-note/", ui_views.inquiry_add_note, name="inquiry_add_note"),
path("<uuid:pk>/respond/", ui_views.inquiry_respond, name="inquiry_respond"),
path("<uuid:pk>/update-satisfaction/", ui_views.inquiry_update_satisfaction, name="inquiry_update_satisfaction"),
path("<uuid:pk>/transfer-to-department/", ui_views.inquiry_transfer_to_department, name="inquiry_transfer_to_department"),
path("<uuid:pk>/department-response/", ui_views.inquiry_department_response, name="inquiry_department_response"),
path("<uuid:pk>/review-dept-response/", ui_views.inquiry_review_dept_response, name="inquiry_review_dept_response"),

View File

@ -1945,6 +1945,7 @@ The matter has been addressed through appropriate channels. Appropriate measures
try:
from apps.core.ai_service import AIService
from django.utils import timezone
import json
explanations_text = ""
@ -1952,7 +1953,7 @@ The matter has been addressed through appropriate channels. Appropriate measures
name = exp.staff.get_full_name() if exp.staff else "Staff"
explanations_text += f"\n- {name}: {exp.explanation[:500]}"
prompt = f"""You are a patient experience resolution advisor. Based on this complaint, provide actionable suggestions for resolving it.
prompt = f"""You are a patient experience resolution advisor. Based on this complaint, provide actionable suggestions for responding to the patient.
Complaint: {complaint.description[:1500]}
Severity: {complaint.get_severity_display()}
@ -1961,10 +1962,20 @@ Priority: {complaint.get_priority_display()}
Status: {complaint.get_status_display()}
Staff explanations received: {explanations_text or 'None yet'}
Generate a JSON response with:
- "suggestions": array of 3-5 actionable suggestions, each with "title" and "description"
- "recommended_actions": array of immediate next steps
- "communication_tip": a tip for communicating with the patient"""
IMPORTANT: ALL text fields must be provided in BOTH English and Arabic.
Generate a JSON response with this exact structure:
{{
"suggestions": [
{{"title_en": "Short title", "title_ar": "عنوان قصير", "description_en": "Description", "description_ar": "الوصف"}}
],
"recommended_actions_en": ["Action 1", "Action 2"],
"recommended_actions_ar": ["الإجراء 1", "الإجراء 2"],
"communication_tip_en": "Tip text",
"communication_tip_ar": "نصيحة"
}}
Provide 3-5 suggestions. Keep them concise and focused on how to respond to the patient."""
result = AIService.chat_completion(
prompt=prompt,
@ -1972,7 +1983,12 @@ Generate a JSON response with:
)
parsed = json.loads(result)
return Response({"success": True, "suggestions": parsed})
# Persist to complaint
complaint.ai_response_suggestions = parsed
complaint.ai_response_suggestions_at = timezone.now()
complaint.save(update_fields=["ai_response_suggestions", "ai_response_suggestions_at", "updated_at"])
return Response({"success": True})
except Exception as e:
logger.error(f"AI helper suggestion failed: {e}")
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@ -2221,12 +2237,54 @@ Generate JSON with exactly these fields:
import io
from PIL import Image as PILImage
def _img_to_data_uri(path):
try:
img = PILImage.open(path)
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception:
return None
_logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png")
_logo_img.thumbnail((600, 600), PILImage.LANCZOS)
_logo_buf = io.BytesIO()
_logo_img.save(_logo_buf, format="PNG", optimize=True)
logo_path = "data:image/png;base64," + base64.b64encode(_logo_buf.getvalue()).decode()
# Per-hospital letterhead + stamp (with static fallback)
letterhead_path = None
if complaint.hospital and complaint.hospital.letterhead:
letterhead_path = _img_to_data_uri(complaint.hospital.letterhead.path)
if not letterhead_path:
letterhead_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "artboard" / "Artboard 1@3x.png")
stamp_path = None
if complaint.hospital and complaint.hospital.stamp:
stamp_path = _img_to_data_uri(complaint.hospital.stamp.path)
if not stamp_path:
stamp_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "stamps" / "stamp.png")
# QR code encoding the public tracking URL for authenticity verification
qr_code_path = None
try:
import qrcode
from django.urls import reverse
pdf_url = request.build_absolute_uri(
reverse("complaints:public_complaint_pdf", kwargs={"reference_number": complaint.reference_number})
)
qr = qrcode.QRCode(version=1, box_size=4, border=1)
qr.add_data(pdf_url)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="black", back_color="white")
qr_buf = io.BytesIO()
qr_img.save(qr_buf, format="PNG")
qr_code_path = "data:image/png;base64," + base64.b64encode(qr_buf.getvalue()).decode()
except Exception:
pass
html_string = render_to_string(
"complaints/complaint_summary_pdf.html",
{
@ -2235,6 +2293,9 @@ Generate JSON with exactly these fields:
"content_summary": content_summary,
"dept_response_summary": dept_response_summary,
"logo_path": logo_path,
"letterhead_path": letterhead_path,
"stamp_path": stamp_path,
"qr_code_path": qr_code_path,
"hospital_name_en": complaint.hospital.get_display_name() if complaint.hospital else "",
"hospital_address": complaint.hospital.address if complaint.hospital else "",
"hospital_phone": complaint.hospital.phone if complaint.hospital else "",
@ -3816,6 +3877,18 @@ def complaint_review_pdf(request, complaint_id, token):
return response
def _hospital_departments(complaint):
"""Active departments in the complaint's hospital (for the suggest-dept dropdown)."""
from apps.organizations.models import Department
from django.db.models import Q
if not complaint.hospital:
return Department.objects.none()
return Department.objects.filter(
hospital=complaint.hospital, status="active",
).filter(Q(champion__isnull=False) | Q(manager__isnull=False)).order_by("name")
def complaint_explanation_form(request, complaint_id, token):
"""
Public-facing form for staff to submit explanation.
@ -3889,6 +3962,7 @@ def complaint_explanation_form(request, complaint_id, token):
"token": explanation.token,
})
),
"hospital_departments": _hospital_departments(complaint),
}
# --- Step 1: Send verification code ---
@ -3975,6 +4049,19 @@ def complaint_explanation_form(request, complaint_id, token):
"otp_email": "email" in sent_channels,
})
# --- Cancel OTP: user clicked "Edit response" — re-enable the form ---
if action == "cancel_otp":
investigation = ChampionInvestigation.objects.filter(
explanation=explanation,
status=InvestigationStatus.DIRECT_REPLY_IN_PROGRESS,
).first()
if investigation:
investigation.otp_code = ""
investigation.otp_sent_at = None
investigation.save(update_fields=["otp_code", "otp_sent_at"])
# Re-render with otp_sent=False; base_ctx preserves all form values
return render(request, "complaints/explanation_form.html", {**base_ctx})
# --- Step 2: Verify code + submit ---
if action == "verify_submit":
entered_code = request.POST.get("otp_code", "").strip()
@ -4047,11 +4134,14 @@ def complaint_explanation_form(request, complaint_id, token):
file_size=uploaded_file.size,
)
# Mark ALL other explanations for this complaint as used (first responder wins)
ComplaintExplanation.objects.filter(
complaint=complaint,
is_used=False,
).update(is_used=True)
# First-responder-wins WITHIN the same department only.
# Other departments' explanations stay valid (parallel collection).
if explanation.staff and explanation.staff.department_id:
ComplaintExplanation.objects.filter(
complaint=complaint,
is_used=False,
staff__department_id=explanation.staff.department_id,
).exclude(pk=explanation.pk).update(is_used=True)
# Update the linked ComplaintInvolvedDepartment
from apps.complaints.models import ComplaintInvolvedDepartment
@ -4134,6 +4224,69 @@ def complaint_explanation_form(request, complaint_id, token):
{"complaint": complaint, "explanation": explanation, "attachment_count": len(files)},
)
# --- Reject routing (wrong department) ---
if action == "reject_routing":
from apps.complaints.services.complaint_service import (
reject_department_routing,
RoutingRejectionError,
)
from apps.complaints.models import ComplaintInvolvedDepartment
from apps.organizations.models import Department
reason = request.POST.get("rejection_reason", "").strip()
suggested_id = request.POST.get("suggested_department_id", "").strip()
suggested = None
if suggested_id:
suggested = Department.objects.filter(id=suggested_id, hospital=complaint.hospital).first()
if not reason:
return render(request, "complaints/explanation_form.html", {
**base_ctx,
"show_reject_form": True,
"error": _("Please provide a reason for rejecting this routing."),
})
# Resolve the involved department this champion belongs to
involved_dept = None
if explanation.staff and explanation.staff.department_id:
involved_dept = ComplaintInvolvedDepartment.objects.filter(
complaint=complaint, department_id=explanation.staff.department_id,
).first()
if not involved_dept:
involved_dept = ComplaintInvolvedDepartment.objects.filter(
complaint=complaint, sent=True,
).first()
if not involved_dept:
return render(request, "complaints/explanation_form.html", {
**base_ctx, "show_reject_form": True,
"error": _("Could not find the department routing to reject."),
})
try:
reject_department_routing(
involved_dept,
staff=explanation.staff,
reason=reason,
suggested_department=suggested,
)
except RoutingRejectionError as exc:
return render(request, "complaints/explanation_form.html", {
**base_ctx, "show_reject_form": True,
"error": str(exc) if str(exc) else _("This routing can no longer be rejected."),
})
# Consume the token so it can't be reused
explanation.is_used = True
explanation.responded_at = timezone.now()
explanation.save(update_fields=["is_used", "responded_at"])
return render(
request,
"complaints/explanation_routing_rejected.html",
{"complaint": complaint, "explanation": explanation, "involved_dept": involved_dept},
)
# Unknown / missing action — show form with error
return render(request, "complaints/explanation_form.html", {
**base_ctx,
@ -4178,6 +4331,7 @@ def complaint_explanation_form(request, complaint_id, token):
"accused_staff": accused_staff,
"existing_investigation": existing_investigation,
"otp_sent": otp_sent_on_get,
"hospital_departments": _hospital_departments(complaint),
"investigate_url": request.build_absolute_uri(
reverse("complaints:champion_start_investigation", kwargs={
"complaint_id": complaint.id,
@ -4498,6 +4652,8 @@ def staff_search_for_investigation(request, complaint_id, token):
| Q(name__icontains=q)
| Q(name_ar__icontains=q)
| Q(employee_id__icontains=q)
| Q(civil_id__icontains=q)
| Q(license_number__icontains=q)
)
.select_related("department")[:20]
)
@ -4507,6 +4663,7 @@ def staff_search_for_investigation(request, complaint_id, token):
"id": str(s.id),
"name": s.get_full_name(),
"department_name": s.department.get_localized_name() if s.department else "",
"employee_id": s.employee_id or "",
"already_added": s.id in already_linked,
}
for s in qs
@ -4810,6 +4967,15 @@ def champion_review_answers(request, complaint_id, token):
"otp_email": "email" in sent_channels,
})
# --- Cancel OTP: user clicked "Edit response" — re-enable the form ---
if action == "cancel_otp":
investigation.otp_code = ""
investigation.otp_sent_at = None
investigation.save(update_fields=["otp_code", "otp_sent_at"])
return render(request, "complaints/investigation_review.html", {
**base_ctx, "final_reply": final_reply, "consent_checked": True,
})
# --- Step 2: Verify code + submit ---
if action == "verify_submit":
entered_code = request.POST.get("otp_code", "").strip()
@ -4874,10 +5040,14 @@ def champion_review_answers(request, complaint_id, token):
file_size=f.size,
)
ComplaintExplanation.objects.filter(
complaint=complaint,
is_used=False,
).update(is_used=True)
# First-responder-wins WITHIN the same department only.
# Other departments' explanations stay valid (parallel collection).
if explanation.staff and explanation.staff.department_id:
ComplaintExplanation.objects.filter(
complaint=complaint,
is_used=False,
staff__department_id=explanation.staff.department_id,
).exclude(pk=explanation.pk).update(is_used=True)
involved_dept = investigation.involved_department or explanation.linked_involved_department
if involved_dept:
@ -5069,6 +5239,112 @@ def generate_complaint_pdf(request, pk):
return HttpResponse(f"Error generating PDF: {str(e)}", status=500)
def generate_complaint_pdf_v2(request, pk):
"""Generate complaint PDF using the Artboard 1 PNG letterhead (v2)."""
complaint = get_object_or_404(Complaint, id=pk)
if not request.user.is_authenticated:
return HttpResponse("Unauthorized", status=401)
if not (
request.user.is_px_admin()
or (request.user.is_hospital_admin() and request.user.hospital == complaint.hospital)
or (request.user.is_department_manager() and request.user.department == complaint.department)
or (request.user.hospital == complaint.hospital)
):
return HttpResponse("Forbidden", status=403)
from django.template.loader import render_to_string
import io
import base64
from PIL import Image as PILImage
logo_path = None
try:
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)
logo_path = "data:image/png;base64," + base64.b64encode(_buf.getvalue()).decode()
except Exception:
pass
def _img_to_data_uri(path):
"""Open an image file and return it as a base64 data URI."""
try:
img = PILImage.open(path)
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception:
return None
# Letterhead: hospital-specific upload → static fallback
letterhead_path = None
if complaint.hospital and complaint.hospital.letterhead:
letterhead_path = _img_to_data_uri(complaint.hospital.letterhead.path)
if not letterhead_path:
letterhead_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "artboard" / "Artboard 1@3x.png")
# Stamp: hospital-specific upload → static fallback
stamp_path = None
if complaint.hospital and complaint.hospital.stamp:
stamp_path = _img_to_data_uri(complaint.hospital.stamp.path)
if not stamp_path:
stamp_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "stamps" / "stamp.png")
explanations = complaint.explanations.all().select_related("staff", "accepted_by").prefetch_related("attachments")
timeline = complaint.updates.all().select_related("created_by")[:20]
from apps.px_action_center.models import PXAction
from django.contrib.contenttypes.models import ContentType
complaint_ct = ContentType.objects.get_for_model(Complaint)
px_actions = PXAction.objects.filter(content_type=complaint_ct, object_id=complaint.id).order_by("-created_at")[:5]
html_string = render_to_string(
"complaints/complaint_pdf_v2.html",
{
"complaint": complaint,
"explanations": explanations,
"timeline": timeline,
"px_actions": px_actions,
"generated_at": timezone.now(),
"logo_path": logo_path,
"letterhead_path": letterhead_path,
"stamp_path": stamp_path,
},
)
try:
from weasyprint import HTML
pdf_file = HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf()
response = HttpResponse(pdf_file, content_type="application/pdf")
response["X-Frame-Options"] = "SAMEORIGIN"
view_mode = request.GET.get("view", "download")
if view_mode == "inline":
response["Content-Disposition"] = "inline"
else:
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"complaint_{complaint.reference_number}_{timestamp}.pdf"
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
except ImportError:
return HttpResponse("WeasyPrint is not installed.", status=500)
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Error generating v2 PDF for complaint {pk}: {e}")
return HttpResponse(f"Error generating PDF: {str(e)}", status=500)
def inquiry_explanation_form(request, inquiry_id, token):
"""
Public-facing form for staff to submit response to an inquiry.

View File

@ -1,6 +1,7 @@
"""
Test-only helper: seed a QI project in E2E-HOSP with PDCA+FOCUS phases,
team members from 2 different departments, and tasks assigned to them.
two linked departments (whose champion + manager form the cross-dept team),
and tasks assigned to those team members.
Usage:
manage.py seed_e2e_project
@ -24,7 +25,7 @@ class Command(BaseCommand):
e2e = Hospital.objects.get(code="E2E-HOSP")
depts = list(Department.objects.filter(hospital=e2e))
dept_a = depts[0] # Contact Center (champion's dept)
dept_a = depts[0] # Contact Center (staff_a's dept)
dept_b = depts[1] if len(depts) > 1 else depts[0] # a different dept
# Ensure Staff profiles exist for e2e-staff (dept_a) and e2e-nurse (dept_b)
@ -49,16 +50,24 @@ class Command(BaseCommand):
if staff_b.department_id != dept_b.id:
staff_b.department = dept_b; staff_b.save(update_fields=["department"])
# Make staff_a the champion of dept_a and staff_b the champion of dept_b,
# so they become derived team members of the project (ACL requires it).
if dept_a.champion_id != staff_a.id:
dept_a.champion = staff_a; dept_a.save(update_fields=["champion"])
if dept_b.champion_id != staff_b.id:
dept_b.champion = staff_b; dept_b.save(update_fields=["champion"])
n = QIProject.objects.count()
project = QIProject.objects.create(
hospital=e2e,
department=dept_a,
name=f"E2E QI Project #{n}",
description=f"E2E cross-department QI project #{n}. Automated - please ignore.",
status="pending",
focus_enabled=True,
)
project.team_members.add(staff_a, staff_b)
# Link both departments; the m2m_changed signal derives the team
# (champions staff_a + staff_b) automatically.
project.departments.set([dept_a, dept_b])
# Create PDCA phases
plan_phase = None

View File

@ -1,41 +1,43 @@
"""
Unified reference-number generator.
Format: {PREFIX}-{YYYYMM}-{HOSPITAL_TOKEN}-{SEQ:04d}
e.g. CMP-202606-HHN-0001
Format: {PREFIX}-{YYYYMM}-{SEQ:04d}
e.g. CMP-202607-0001
PREFIX module prefix (CMP/INQ/OBS/APR/SGT)
YYYYMM creation month
HOSPITAL_TOKEN hospital.code sanitized to uppercase alphanumerics
(HH-N -> HHN, E2E-HOSP -> E2EHOSP); "GEN" when no hospital
SEQ 4-digit monthly sequence per (prefix, hospital, month)
SEQ 4-digit monthly sequence (global across all hospitals)
The sequence is allocated atomically via ReferenceSequence so it is safe under
concurrent submissions. Legacy references keep their old format (no migration
of historical data); only new records use this generator.
concurrent submissions. Legacy references keep their old format
({PREFIX}-{YYYYMM}-{HOSPITAL_TOKEN}-{SEQ}) only new records use this generator.
"""
import re
from datetime import datetime
_TOKEN_RE = re.compile(r"[^A-Z0-9]")
def sanitize_hospital_token(hospital) -> str:
"""Derive a clean, format-safe token from a hospital instance."""
"""Derive a clean, format-safe token from a hospital instance.
Kept for backward compatibility but no longer used in the output format.
"""
code = getattr(hospital, "code", None) if hospital else None
if not code:
return "GEN"
token = _TOKEN_RE.sub("", str(code).upper())
token = re.sub(r"[^A-Z0-9]", "", str(code).upper())
return token or "GEN"
def generate_reference(prefix: str, hospital) -> str:
"""Generate a unified reference number for the given module prefix."""
"""Generate a unified reference number for the given module prefix.
Format: CMP-202607-0001 (no hospital token in the output).
All hospitals share a single global sequence per (prefix, month).
"""
from apps.core.models import ReferenceSequence
prefix = (prefix or "").upper()
token = sanitize_hospital_token(hospital)
year_month = datetime.now().strftime("%Y%m")
number = ReferenceSequence.next_number(prefix, token, year_month)
return f"{prefix}-{year_month}-{token}-{number:04d}"
number = ReferenceSequence.next_number(prefix, "GLOBAL", year_month)
return f"{prefix}-{year_month}-{number:04d}"

View File

@ -585,7 +585,6 @@ def _track_inquiry(reference):
"en": inquiry.response_en or inquiry.response or "",
"ar": inquiry.response_ar or "",
},
"satisfaction": inquiry.satisfaction or "",
})
@ -630,7 +629,6 @@ def _track_observation(reference):
"en": observation.response_en or observation.response or "",
"ar": observation.response_ar or "",
},
"satisfaction": observation.satisfaction or "",
})
@ -784,14 +782,9 @@ def public_set_satisfaction(request):
if upper.startswith("CMP-"):
from apps.complaints.models import Complaint
obj = Complaint.objects.get(reference_number__iexact=reference)
elif upper.startswith("INQ-"):
from apps.complaints.models import Inquiry
obj = Inquiry.objects.get(reference_number__iexact=reference)
elif upper.startswith("OBS-"):
from apps.observations.models import Observation
obj = Observation.objects.get(tracking_code__iexact=reference)
else:
return JsonResponse({"success": False, "error": "Unrecognized reference format."}, status=400)
# Satisfaction is only collected for complaints.
return JsonResponse({"success": False, "error": "Satisfaction is only available for complaints."}, status=400)
except Exception:
return JsonResponse({"success": False, "error": "Not found."}, status=404)

View File

@ -274,6 +274,19 @@ def feedback_detail(request, pk):
"notes_count": generic_notes.count(),
}
# Workflow stepper
_fb_steps = [
{"label": "Submitted", "icon": "plus-circle", "done": True},
{"label": "Assigned", "icon": "user-plus", "done": feedback.assigned_to is not None},
{"label": "Reviewed", "icon": "eye", "done": feedback.status in ("reviewed", "acknowledged", "closed")},
{"label": "Acknowledged", "icon": "thumbs-up", "done": feedback.status in ("acknowledged", "closed")},
{"label": "Closed", "icon": "check-circle-2", "done": feedback.status == "closed"},
]
_fb_next = next((i for i, s in enumerate(_fb_steps) if not s["done"]), len(_fb_steps))
if _fb_next < len(_fb_steps):
_fb_steps[_fb_next]["current"] = True
context["workflow_steps"] = _fb_steps
return render(request, "feedback/feedback_detail.html", context)

View File

@ -0,0 +1,21 @@
# Generated by Django 6.0.1 on 2026-07-04 19:55
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('observations', '0018_observation_satisfaction_and_more'),
]
operations = [
migrations.RemoveField(
model_name='observation',
name='satisfaction',
),
migrations.RemoveField(
model_name='observation',
name='satisfaction_set_at',
),
]

View File

@ -598,13 +598,6 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel):
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="responded_observations"
)
# Satisfaction
satisfaction = models.CharField(
max_length=20, blank=True, default="",
choices=[("satisfied", "Satisfied"), ("neutral", "Neutral"), ("dissatisfied", "Dissatisfied"), ("no_response", "No Response")],
)
satisfaction_set_at = models.DateTimeField(null=True, blank=True)
# Closure
closed_at = models.DateTimeField(null=True, blank=True)
closed_by = models.ForeignKey(

View File

@ -57,7 +57,6 @@ urlpatterns = [
path("<uuid:pk>/note/", views.observation_add_note, name="observation_add_note"),
# Respond (patient-facing response)
path("<uuid:pk>/respond/", views.observation_respond, name="observation_respond"),
path("<uuid:pk>/update-satisfaction/", views.observation_update_satisfaction, name="observation_update_satisfaction"),
path("<uuid:pk>/generate-ai-response/", views.observation_generate_ai_response, name="observation_generate_ai_response"),
# Send to Department
path("<uuid:pk>/send-to-department/", views.observation_send_to_department, name="observation_send_to_department"),

View File

@ -702,6 +702,19 @@ def observation_detail(request, pk):
context["escalation_email_subject"] = escalation_email_subject
context["escalation_email_body"] = escalation_email_body
# Workflow stepper
_obs_steps = [
{"label": "Created", "icon": "plus-circle", "done": True},
{"label": "Assigned", "icon": "user-plus", "done": observation.assigned_at is not None},
{"label": "Sent to Dept", "icon": "send", "done": observation.forwarded_to_dept_at is not None},
{"label": "Dept Response", "icon": "message-square", "done": bool(observation.department_response_en or observation.department_response_ar)},
{"label": "Resolved", "icon": "check-circle-2", "done": observation.status in ("resolved", "closed")},
]
_obs_next = next((i for i, s in enumerate(_obs_steps) if not s["done"]), len(_obs_steps))
if _obs_next < len(_obs_steps):
_obs_steps[_obs_next]["current"] = True
context["workflow_steps"] = _obs_steps
return render(request, "observations/observation_detail.html", context)
@ -1089,20 +1102,6 @@ Generate comprehensive, professional responses in both languages. Use Modern Sta
return JsonResponse({"success": False, "error": f"Failed to generate response: {str(e)}"}, status=500)
@login_required
@require_http_methods(["POST"])
def observation_update_satisfaction(request, pk):
"""Update observation satisfaction."""
observation = get_object_or_404(Observation, pk=pk)
satisfaction = request.POST.get("satisfaction", "").strip()
if satisfaction in ("satisfied", "neutral", "dissatisfied", "no_response"):
observation.satisfaction = satisfaction
observation.satisfaction_set_at = timezone.now()
observation.save(update_fields=["satisfaction", "satisfaction_set_at"])
messages.success(request, _("Satisfaction updated."))
return redirect("observations:observation_detail", pk=pk)
@login_required
@require_http_methods(["GET", "POST"])
def observation_convert_to_action(request, pk):

View File

@ -54,6 +54,7 @@ class HospitalAdmin(admin.ModelAdmin):
("Contact Information", {"fields": ("address", "city", "phone", "email")}),
("Executive Leadership", {"fields": ("ceo", "medical_director", "coo", "cfo")}),
("Details", {"fields": ("license_number", "capacity", "status")}),
("Branding", {"fields": ("letterhead", "stamp")}),
("Metadata", {"fields": ("created_at", "updated_at")}),
)
autocomplete_fields = ["organization", "ceo", "medical_director", "coo", "cfo"]

View File

@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-07-01 17:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('organizations', '0014_remove_department_manager_1st'),
]
operations = [
migrations.AddField(
model_name='hospital',
name='letterhead',
field=models.ImageField(blank=True, help_text='A4 transparent PNG with hospital header/footer artwork for PDF letterhead', null=True, upload_to='hospital_branding/'),
),
migrations.AddField(
model_name='hospital',
name='stamp',
field=models.ImageField(blank=True, help_text='Transparent stamp PNG placed at bottom-right of PDF pages', null=True, upload_to='hospital_branding/'),
),
]

View File

@ -84,6 +84,20 @@ class Hospital(UUIDModel, TimeStampedModel):
# Status
status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE, db_index=True)
# Branding — per-hospital PDF letterhead and stamp images
letterhead = models.ImageField(
upload_to="hospital_branding/",
blank=True,
null=True,
help_text="A4 transparent PNG with hospital header/footer artwork for PDF letterhead",
)
stamp = models.ImageField(
upload_to="hospital_branding/",
blank=True,
null=True,
help_text="Transparent stamp PNG placed at bottom-right of PDF pages",
)
# Executive leadership
ceo = models.ForeignKey(
"accounts.User",

View File

@ -2248,6 +2248,17 @@ def department_detail(request, pk):
status__in=["open", "in_progress"],
involved_departments__department=department,
).distinct().select_related("department", "assigned_to")[:5],
"pending_routing_involvements": ComplaintInvolvedDepartment.objects.filter(
department=department,
sent=True,
response_submitted=False,
routing_status="sent",
).select_related("complaint", "complaint__department", "department").order_by("-sent_at")[:10],
"hospital_departments": (
Department.objects.filter(hospital=department.hospital, status="active")
.filter(Q(champion__isnull=False) | Q(manager__isnull=False))
.order_by("name")
),
}
return render(request, "organizations/department_detail.html", context)
@ -2256,6 +2267,8 @@ def _check_department_access(user, department):
return True
if user.is_hospital_admin() and user.hospital == department.hospital:
return True
if department.manager_id == user.id:
return True
if user.is_champion() and user.department == department:
return True
if user.is_department_manager() and user.department == department:

View File

@ -18,21 +18,21 @@ class QIProjectTaskInline(admin.TabularInline):
class QIProjectAdmin(admin.ModelAdmin):
"""QI Project admin"""
list_display = [
'name', 'hospital', 'department', 'project_lead',
'name', 'hospital', 'get_departments', 'project_lead',
'status', 'start_date', 'target_completion_date'
]
list_filter = ['status', 'hospital', 'department', 'start_date']
list_filter = ['status', 'hospital', 'departments', 'start_date']
search_fields = ['name', 'name_ar', 'description']
ordering = ['-created_at']
inlines = [QIProjectTaskInline]
filter_horizontal = ['team_members', 'related_actions']
filter_horizontal = ['team_members', 'related_actions', 'departments']
fieldsets = (
(None, {
'fields': ('name', 'name_ar', 'description')
}),
('Organization', {
'fields': ('hospital', 'department')
'fields': ('hospital', 'departments')
}),
('Team', {
'fields': ('project_lead', 'team_members')
@ -53,12 +53,19 @@ class QIProjectAdmin(admin.ModelAdmin):
'classes': ('collapse',)
}),
)
readonly_fields = ['created_at', 'updated_at']
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('hospital', 'department', 'project_lead')
return qs.select_related('hospital', 'project_lead').prefetch_related('departments')
@admin.display(description='Departments')
def get_departments(self, obj):
depts = list(obj.departments.all())
if not depts:
return ""
return ", ".join(d.name for d in depts)
@admin.register(QIProjectTask)

View File

@ -61,7 +61,7 @@ def export_project_excel(project):
info_data = [
("Description", project.description),
("Hospital", project.hospital.name if project.hospital else ""),
("Department", project.department.name if project.department else ""),
("Department", ", ".join(d.name for d in project.departments.all()) if project.departments.exists() else ""),
("Project Lead", project.project_lead.get_full_name() if project.project_lead else ""),
("Created By", project.created_by.get_full_name() if project.created_by else ""),
("Status", project.get_status_display()),

View File

@ -8,11 +8,11 @@ from django import forms
from django.forms import inlineformset_factory
from django.utils.translation import gettext_lazy as _
from apps.core.form_mixins import HospitalFieldMixin
from apps.accounts.models import User
from apps.organizations.models import Department, Hospital
from apps.core.form_mixins import HospitalFieldMixin
from apps.organizations.models import Department
from .models import QIProject, QIProjectTask, PDCAPhase, FOCUSPhase
from .models import FOCUSPhase, PDCAPhase, QIProject, QIProjectTask
class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
@ -31,9 +31,8 @@ class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
"name_ar",
"description",
"hospital",
"department",
"departments",
"project_lead",
"team_members",
"status",
"start_date",
"target_completion_date",
@ -65,18 +64,14 @@ class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"department": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"project_lead": forms.Select(
"departments": forms.SelectMultiple(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white",
"data-tomselect": "",
"size": "6",
}
),
"team_members": forms.SelectMultiple(
"project_lead": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white",
"data-tomselect": "",
@ -127,21 +122,25 @@ class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
hospital_id = self.user.hospital.id
if hospital_id:
self.fields["department"].queryset = Department.objects.filter(
self.fields["departments"].queryset = Department.objects.filter(
hospital_id=hospital_id, status="active"
).order_by("name")
# Filter staff choices based on hospital
# Filter project lead based on hospital
from apps.organizations.models import Staff
staff_qs = Staff.objects.filter(
hospital_id=hospital_id, status="active"
).order_by("first_name", "last_name")
self.fields["project_lead"].queryset = staff_qs
self.fields["team_members"].queryset = staff_qs
else:
self.fields["department"].queryset = Department.objects.none()
self.fields["project_lead"].queryset = Staff.objects.none() if False else []
self.fields["team_members"].queryset = []
self.fields["departments"].queryset = Department.objects.none()
self.fields["project_lead"].queryset = Department.objects.none()
def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)
# Team membership is derived from the selected departments' champion + manager.
instance.sync_team_members_from_departments()
return instance
class QIProjectTaskForm(forms.ModelForm):
@ -244,11 +243,12 @@ class QIProjectTaskForm(forms.ModelForm):
self.fields["focus_phase"].queryset = FOCUSPhase.objects.none()
self.fields["focus_phase"].widget = forms.HiddenInput()
# Filter assigned_to choices based on project hospital
if self.project and self.project.hospital:
# Filter assigned_to choices based on project team members
# (derived from each department's champion + manager, plus the project lead).
if self.project and self.project.pk:
from apps.organizations.models import Staff
self.fields["assigned_to"].queryset = Staff.objects.filter(
hospital=self.project.hospital, status="active"
pk__in=self.project.get_team_members_staff_ids()
).order_by("first_name", "last_name")
else:
self.fields["assigned_to"].queryset = User.objects.none()
@ -269,7 +269,7 @@ class QIProjectTemplateForm(HospitalFieldMixin, forms.ModelForm):
class Meta:
model = QIProject
fields = ["name", "name_ar", "description", "hospital", "department", "target_completion_date"]
fields = ["name", "name_ar", "description", "hospital", "departments", "target_completion_date"]
widgets = {
"name": forms.TextInput(
attrs={
@ -296,9 +296,11 @@ class QIProjectTemplateForm(HospitalFieldMixin, forms.ModelForm):
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"department": forms.Select(
"departments": forms.SelectMultiple(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white",
"data-tomselect": "",
"size": "6",
}
),
"target_completion_date": forms.DateInput(
@ -332,11 +334,11 @@ class QIProjectTemplateForm(HospitalFieldMixin, forms.ModelForm):
hospital_id = self.user.hospital.id
if hospital_id:
self.fields["department"].queryset = Department.objects.filter(
self.fields["departments"].queryset = Department.objects.filter(
hospital_id=hospital_id, status="active"
).order_by("name")
else:
self.fields["department"].queryset = Department.objects.none()
self.fields["departments"].queryset = Department.objects.none()
class ConvertToProjectForm(forms.Form):

View File

@ -0,0 +1,81 @@
# Generated for QI multi-department support.
#
# Order matters:
# 1. Add the new `departments` M2M (blank).
# 2. Copy each existing `department_id` into the M2M, then rebuild the derived
# `team_members` set from each department's champion + manager (and the
# project lead). The sync logic is inlined here (not imported from the
# model) so the migration stays stable as the model method evolves.
# 3. Remove the old single `department` FK.
from django.db import migrations, models
def copy_department_and_sync_team(apps, schema_editor):
QIProject = apps.get_model("projects", "QIProject")
Staff = apps.get_model("organizations", "Staff")
missing_manager_profile = 0
for project in QIProject.objects.exclude(department__isnull=True):
old_dept = project.department
# M2M add via the through manager (project has just been saved with the new field).
if old_dept and old_dept not in project.departments.all():
project.departments.add(old_dept)
# Rebuild derived team_members: champion (Staff) + manager.staff_profile (Staff),
# plus the project lead. De-duped; nulls skipped.
pks = set()
for dept in project.departments.all():
champion_id = getattr(dept, "champion_id", None)
if champion_id:
pks.add(champion_id)
manager_user_id = getattr(dept, "manager_id", None)
if manager_user_id:
manager_user = dept.manager
if manager_user is not None and hasattr(manager_user, "staff_profile"):
sp = manager_user.staff_profile
if sp is not None and sp.id:
pks.add(sp.id)
else:
missing_manager_profile += 1
if project.project_lead_id:
pks.add(project.project_lead_id)
if pks:
project.team_members.set(Staff.objects.filter(pk__in=pks))
if missing_manager_profile:
# Surface how many manager links could not be added (no staff_profile).
print(f" [qi_multi_department] {missing_manager_profile} department manager(s) "
f"had no staff_profile and were excluded from the derived team.")
def remove_team_backreference(apps, schema_editor):
# Reverse is a no-op: we cannot reconstruct the single FK from the M2M losslessly.
pass
class Migration(migrations.Migration):
dependencies = [
("organizations", "0015_hospital_branding"),
("projects", "0003_alter_qiproject_project_lead_and_more"),
]
operations = [
migrations.AddField(
model_name="qiproject",
name="departments",
field=models.ManyToManyField(
blank=True,
help_text="Departments involved in this QI project. Team membership is derived from each department's champion and manager.",
related_name="qi_projects",
to="organizations.department",
),
),
migrations.RunPython(copy_department_and_sync_team, remove_team_backreference),
migrations.RemoveField(
model_name="qiproject",
name="department",
),
]

View File

@ -56,8 +56,11 @@ class QIProject(UUIDModel, TimeStampedModel):
blank=True,
help_text="Null for global templates available to all hospitals",
)
department = models.ForeignKey(
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="qi_projects"
departments = models.ManyToManyField(
"organizations.Department",
blank=True,
related_name="qi_projects",
help_text="Departments involved in this QI project. Team membership is derived from each department's champion and manager.",
)
# Project lead
@ -112,6 +115,44 @@ class QIProject(UUIDModel, TimeStampedModel):
return f"[Template] {self.name}"
return f"{self.name} ({self.status})"
def get_team_members_staff_ids(self):
"""Return the set of Staff PKs derived from each linked department's
champion (a Staff) and manager (a User -> staff_profile). De-duped; nulls skipped."""
pks = set()
for dept in self.departments.all():
if dept.champion_id:
pks.add(dept.champion_id)
# `Department.manager` is a FK to User; resolve to its Staff profile if present.
manager_user_id = getattr(dept, "manager_id", None)
if manager_user_id:
manager_user = dept.manager
if manager_user is not None and hasattr(manager_user, "staff_profile"):
sp = manager_user.staff_profile
if sp is not None and sp.id:
pks.add(sp.id)
# Ensure project lead is always considered part of the team
if self.project_lead_id:
pks.add(self.project_lead_id)
return pks
def sync_team_members_from_departments(self):
"""Rebuild the `team_members` M2M from the champion + manager of each
linked department (plus the project lead). Idempotent."""
if not self.pk:
return
from apps.organizations.models import Staff
self.team_members.set(Staff.objects.filter(pk__in=self.get_team_members_staff_ids()))
def is_team_member(self, user):
"""True if the given user's staff_profile is a derived team member or the project lead."""
staff_profile = getattr(user, "staff_profile", None)
if not staff_profile:
return False
if self.project_lead_id and self.project_lead_id == staff_profile.id:
return True
return self.team_members.filter(pk=staff_profile.id).exists()
class QIProjectTask(UUIDModel, TimeStampedModel):
"""

View File

@ -1,12 +1,18 @@
"""
Signals for QI Projects sends a notification when a task is assigned.
Signals for QI Projects.
- Sends a notification when a task is assigned.
- Keeps `team_members` in sync with the champion + manager of each linked
department whenever the `departments` M2M changes.
"""
import logging
from django.db.models.signals import post_save
from django.db.models.signals import m2m_changed, post_save
from django.dispatch import receiver
from apps.projects.models import QIProject
logger = logging.getLogger(__name__)
@ -36,3 +42,20 @@ def notify_task_assignment(sender, instance, created, **kwargs):
)
except Exception as e:
logger.warning(f"Failed to send QI task notification: {e}")
@receiver(m2m_changed, sender=QIProject.departments.through)
def sync_team_on_departments_change(sender, instance, action, **kwargs):
"""Rebuild the derived `team_members` set whenever the project's departments change.
Fires on `post_add` / `post_remove` / `post_clear` so that programmatic,
admin, and form-driven edits all stay consistent. The sync is idempotent.
"""
if action not in ("post_add", "post_remove", "post_clear"):
return
if not instance.pk:
return
try:
instance.sync_team_members_from_departments()
except Exception as e: # defensive: never break an M2M write on sync failure
logger.warning(f"Failed to sync QI team members for project {instance.pk}: {e}")

157
apps/projects/tests.py Normal file
View File

@ -0,0 +1,157 @@
"""
Tests for the QI Projects app.
Covers the multi-department + derived-team behaviour:
- Team membership is derived from each department's champion + manager (+ lead).
- Sync fires on M2M changes to `departments`.
- ACL: only admins / project lead / team members can view & manage.
- Task `assigned_to` is limited to derived team members.
"""
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from apps.organizations.models import Department, Hospital, Staff
from apps.projects.models import QIProject, QIProjectTask
User = get_user_model()
def _make_staff(hospital, department=None, first="Staff", email=None):
return Staff.objects.create(
hospital=hospital,
department=department,
first_name=first,
last_name="Test",
status="active",
staff_type="other",
job_title="T",
employee_id=f"EMP-{first}-{Hospital.objects.count()}",
user=User.objects.create_user(
username=email or f"{first.lower()}@test.local",
email=email or f"{first.lower()}@test.local",
password="pw",
),
)
class DerivedTeamTests(TestCase):
"""sync_team_members_from_departments + m2m_changed signal."""
def setUp(self):
self.hospital = Hospital.objects.create(name="H1", code="H1", status="active")
self.dept_a = Department.objects.create(hospital=self.hospital, name="Dept A", code="DA", status="active")
self.dept_b = Department.objects.create(hospital=self.hospital, name="Dept B", code="DB", status="active")
self.champ_a = _make_staff(self.hospital, self.dept_a, "ChampA")
self.champ_b = _make_staff(self.hospital, self.dept_b, "ChampB")
self.dept_a.champion = self.champ_a
self.dept_a.save()
self.dept_b.champion = self.champ_b
self.dept_b.save()
self.lead = _make_staff(self.hospital, self.dept_a, "Lead")
def _new_project(self, **kw):
defaults = dict(
name="P", description="d", hospital=self.hospital, status="pending", project_lead=self.lead
)
defaults.update(kw)
return QIProject.objects.create(**defaults)
def test_team_derived_from_department_champions(self):
p = self._new_project()
p.departments.set([self.dept_a, self.dept_b])
# champ_a, champ_b (champions) + lead (project_lead) → 3 members
self.assertEqual(set(p.team_members.values_list("pk", flat=True)),
{self.champ_a.pk, self.champ_b.pk, self.lead.pk})
def test_manager_without_staff_profile_is_skipped(self):
# Manager is a User with no staff_profile → excluded
mgr_user = User.objects.create_user(username="mgr", email="mgr@x", password="p")
self.dept_a.manager = mgr_user
self.dept_a.save()
p = self._new_project()
p.departments.set([self.dept_a])
# Only champ_a + lead; mgr_user has no staff_profile so not added
self.assertEqual(set(p.team_members.values_list("pk", flat=True)),
{self.champ_a.pk, self.lead.pk})
def test_manager_with_staff_profile_is_included(self):
mgr_staff = _make_staff(self.hospital, self.dept_a, "Mgr")
mgr_user = mgr_staff.user
self.dept_a.manager = mgr_user
self.dept_a.save()
p = self._new_project()
p.departments.set([self.dept_a])
self.assertIn(mgr_staff.pk, set(p.team_members.values_list("pk", flat=True)))
def test_removing_department_rebuilds_team(self):
p = self._new_project()
p.departments.set([self.dept_a, self.dept_b])
self.assertIn(self.champ_b.pk, set(p.team_members.values_list("pk", flat=True)))
p.departments.remove(self.dept_b)
# champ_b no longer in team; champ_a + lead remain
self.assertEqual(set(p.team_members.values_list("pk", flat=True)),
{self.champ_a.pk, self.lead.pk})
def test_is_team_member_helper(self):
p = self._new_project()
p.departments.set([self.dept_a])
self.assertTrue(p.is_team_member(self.champ_a.user))
self.assertTrue(p.is_team_member(self.lead.user))
self.assertFalse(p.is_team_member(self.champ_b.user))
class ACLTests(TestCase):
"""_check_project_permission + _get_can_edit via the views."""
def setUp(self):
self.hospital = Hospital.objects.create(name="H1", code="H1", status="active")
self.hospital2 = Hospital.objects.create(name="H2", code="H2", status="active")
self.dept = Department.objects.create(hospital=self.hospital, name="D", code="D", status="active")
self.champ = _make_staff(self.hospital, self.dept, "Champ")
self.outsider = _make_staff(self.hospital, self.dept, "Outsider")
self.dept.champion = self.champ
self.dept.save()
self.lead = _make_staff(self.hospital, self.dept, "Lead")
self.project = QIProject.objects.create(
name="P", description="d", hospital=self.hospital, status="pending", project_lead=self.lead
)
self.project.departments.set([self.dept]) # derives team = {champ, lead}
def test_team_member_can_view_detail(self):
# ACL helper level (view-level rendering is blocked by a pre-existing
# staticfiles-manifest issue in the test env, unrelated to this feature).
from apps.projects.ui_views import _check_project_permission, _get_can_edit
self.assertTrue(_check_project_permission(self.project, self.champ.user))
self.assertFalse(_get_can_edit(self.champ.user, self.project)) # not lead/admin
def test_non_team_same_hospital_user_is_denied(self):
from apps.projects.ui_views import _check_project_permission
# Outsider is same-hospital but NOT a team member → denied
self.assertFalse(_check_project_permission(self.project, self.outsider.user))
def test_project_lead_can_edit(self):
from apps.projects.ui_views import _get_can_edit
# Lead is not an admin but is the project lead → can edit
self.assertTrue(_get_can_edit(self.lead.user, self.project))
def test_task_assigned_to_is_limited_to_team_members(self):
from apps.projects.forms import QIProjectTaskForm
form = QIProjectTaskForm(project=self.project)
qs_ids = set(form.fields["assigned_to"].queryset.values_list("pk", flat=True))
self.assertEqual(qs_ids, {self.champ.pk, self.lead.pk})
self.assertNotIn(self.outsider.pk, qs_ids)
def test_task_toggle_requires_post(self):
task = QIProjectTask.objects.create(project=self.project, title="t", assigned_to=self.champ, status="pending")
self.client.force_login(self.champ.user)
url = reverse("projects:task_toggle_status",
kwargs={"project_pk": self.project.pk, "task_pk": task.pk})
# GET must be rejected (405) — CSRF-safe toggle
resp = self.client.get(url)
self.assertEqual(resp.status_code, 405)

View File

@ -12,6 +12,7 @@ from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_POST
from apps.core.decorators import block_source_user
from apps.core.models import StatusChoices
@ -258,8 +259,8 @@ def project_list(request):
# Exclude templates from the list
queryset = (
QIProject.objects.filter(is_template=False)
.select_related("hospital", "department", "project_lead", "project_lead__department")
.prefetch_related("team_members", "related_actions")
.select_related("hospital", "project_lead", "project_lead__department")
.prefetch_related("departments", "team_members", "related_actions")
)
# Apply RBAC filters
@ -271,8 +272,24 @@ def project_list(request):
# PX Admins see all, but filter by selected hospital if set
if selected_hospital:
queryset = queryset.filter(hospital=selected_hospital)
elif user.is_hospital_admin():
# Hospital admins see projects in their hospital, scoped by team membership
if user.hospital:
queryset = queryset.filter(hospital=user.hospital)
staff_profile = getattr(user, "staff_profile", None)
if staff_profile:
queryset = queryset.filter(
Q(team_members=staff_profile) | Q(project_lead=staff_profile)
).distinct()
elif user.hospital:
queryset = queryset.filter(hospital=user.hospital)
# Other users: same hospital AND must be a team member or project lead
staff_profile = getattr(user, "staff_profile", None)
if staff_profile:
queryset = queryset.filter(hospital=user.hospital).filter(
Q(team_members=staff_profile) | Q(project_lead=staff_profile)
).distinct()
else:
queryset = queryset.none()
# Apply filters
status_filter = request.GET.get("status")
@ -283,6 +300,10 @@ def project_list(request):
if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter)
department_filter = request.GET.get("department")
if department_filter:
queryset = queryset.filter(departments__id=department_filter).distinct()
# Search
search_query = request.GET.get("search")
if search_query:
@ -332,14 +353,17 @@ def project_detail(request, pk):
project = get_object_or_404(
QIProject.objects.filter(is_template=False)
.select_related("hospital", "department", "project_lead", "project_lead__department")
.prefetch_related("team_members", "related_actions", "tasks", "pdca_phases", "focus_phases"),
.select_related("hospital", "project_lead", "project_lead__department")
.prefetch_related(
"departments", "team_members", "related_actions", "tasks",
"pdca_phases", "focus_phases",
),
pk=pk,
)
# Check permission
# Check permission (admins, project lead, or team members)
user = request.user
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
if not _check_project_permission(project, user):
messages.error(request, _("You don't have permission to view this project."))
return redirect("projects:project_list")
@ -412,7 +436,7 @@ def project_create(request, template_pk=None):
"name": template.name,
"name_ar": template.name_ar,
"description": template.description,
"department": template.department,
"departments": template.departments.all(),
"target_completion_date": template.target_completion_date,
}
if not user.is_px_admin() and user.hospital:
@ -453,7 +477,7 @@ def project_create(request, template_pk=None):
if user.is_px_admin():
initial_data["hospital"] = obj.hospital_id
if hasattr(obj, "department_id") and obj.department_id:
initial_data["department"] = obj.department_id
initial_data["departments"] = [obj.department_id]
except Exception:
pass
@ -569,11 +593,11 @@ def project_delete(request, pk):
project = get_object_or_404(QIProject, pk=pk, is_template=False)
# Check permission (only PX Admin or Hospital Admin can delete)
if not (user.is_px_admin() or user.is_hospital_admin):
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, _("You don't have permission to delete projects."))
return redirect("projects:project_detail", pk=project.pk)
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
if not _check_project_permission(project, user):
messages.error(request, _("You don't have permission to delete this project."))
return redirect("projects:project_list")
@ -598,12 +622,12 @@ def project_save_as_template(request, pk):
project = get_object_or_404(QIProject, pk=pk, is_template=False)
# Check permission (only PX Admin or Hospital Admin can create templates)
if not (user.is_px_admin() or user.is_hospital_admin):
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, _("You don't have permission to create templates."))
return redirect("projects:project_detail", pk=project.pk)
# Check hospital access
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
if not _check_project_permission(project, user):
messages.error(request, _("You don't have permission to create templates from this project."))
return redirect("projects:project_list")
@ -624,10 +648,11 @@ def project_save_as_template(request, pk):
is_template=True,
# If global, hospital is None; otherwise use project's hospital
hospital=None if make_global else project.hospital,
department=project.department,
status="pending", # Default status for templates
created_by=user,
)
# Copy linked departments from project to template
template.departments.set(project.departments.all())
# Copy tasks from project to template
for task in project.tasks.all():
@ -683,8 +708,8 @@ def task_create(request, project_pk, phase=None):
current_phase = get_object_or_404(FOCUSPhase, phase=phase, project=project)
phase_type = "focus"
# Check permission — admin only
if not _get_can_edit(user):
# Check permission — must have project access + edit rights
if not _check_project_permission(project, user) or not _get_can_edit(user, project):
messages.error(request, _("You don't have permission to add tasks to this project."))
return redirect("projects:project_detail", pk=project.pk)
@ -726,8 +751,8 @@ def task_edit(request, project_pk, task_pk, phase=None):
current_phase = get_object_or_404(FOCUSPhase, phase=phase, project=project)
phase_type = "focus"
# Check permission — admin only
if not _get_can_edit(user):
# Check permission — must have project access + edit rights
if not _check_project_permission(project, user) or not _get_can_edit(user, project):
messages.error(request, _("You don't have permission to edit tasks in this project."))
return redirect("projects:project_detail", pk=project.pk)
@ -766,8 +791,8 @@ def task_delete(request, project_pk, task_pk, phase=None):
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
# Check permission — admin only
if not _get_can_edit(user):
# Check permission — must have project access + edit rights
if not _check_project_permission(project, user) or not _get_can_edit(user, project):
messages.error(request, _("You don't have permission to delete tasks in this project."))
return redirect("projects:project_detail", pk=project.pk)
@ -790,21 +815,17 @@ def task_delete(request, project_pk, task_pk, phase=None):
@block_source_user
@login_required
@require_POST
def task_toggle_status(request, project_pk, task_pk, phase=None):
"""Quick toggle task status between pending and completed"""
user = request.user
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
if not _can_manage_task(project, task, user):
if not _check_project_permission(project, user) or not _can_manage_task(project, task, user):
messages.error(request, _("You don't have permission to update task status."))
return redirect("projects:project_detail", pk=project.pk)
# Check hospital access
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to update tasks in this project."))
return redirect("projects:project_detail", pk=project.pk)
from django.utils import timezone
if task.status == "completed":
@ -831,16 +852,14 @@ def template_list(request):
user = request.user
# Only admins can manage templates
if not (user.is_px_admin() or user.is_hospital_admin):
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, _("You don't have permission to view templates."))
return redirect("projects:project_list")
queryset = QIProject.objects.filter(is_template=True).select_related("hospital", "department")
queryset = QIProject.objects.filter(is_template=True).select_related("hospital").prefetch_related("departments")
# Apply RBAC filters
if not user.is_px_admin():
from django.db.models import Q
queryset = queryset.filter(Q(hospital=user.hospital) | Q(hospital__isnull=True))
# Search
@ -856,7 +875,7 @@ def template_list(request):
context = {
"templates": queryset,
"can_create": user.is_px_admin() or user.is_hospital_admin,
"can_create": user.is_px_admin() or user.is_hospital_admin(),
"can_edit": user.is_px_admin() or user.is_hospital_admin(),
}
@ -870,18 +889,17 @@ def template_detail(request, pk):
user = request.user
# Only admins can view templates
if not (user.is_px_admin() or user.is_hospital_admin):
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, _("You don't have permission to view templates."))
return redirect("projects:project_list")
template = get_object_or_404(
QIProject.objects.filter(is_template=True).select_related("hospital", "department"), pk=pk
QIProject.objects.filter(is_template=True).select_related("hospital").prefetch_related("departments"),
pk=pk,
)
# Check permission for hospital-specific templates
if not user.is_px_admin():
from django.db.models import Q
if template.hospital and template.hospital != user.hospital:
messages.error(request, _("You don't have permission to view this template."))
return redirect("projects:template_list")
@ -904,7 +922,7 @@ def template_create(request):
user = request.user
# Only admins can create templates
if not (user.is_px_admin() or user.is_hospital_admin):
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, _("You don't have permission to create templates."))
return redirect("projects:project_list")
@ -1036,13 +1054,14 @@ def convert_action_to_project(request, action_pk):
name_ar=template.name_ar,
description=template.description,
hospital=action.hospital,
department=template.department,
project_lead=form.cleaned_data["project_lead"],
target_completion_date=form.cleaned_data["target_completion_date"],
status="pending",
created_by=user,
)
# Copy tasks from template
# Copy linked departments + tasks from template
project.departments.set(template.departments.all())
project.sync_team_members_from_departments()
for template_task in template.tasks.all():
QIProjectTask.objects.create(
project=project,
@ -1062,6 +1081,8 @@ def convert_action_to_project(request, action_pk):
status="pending",
created_by=user,
)
# Sync team from project lead (no departments in blank conversion)
project.sync_team_members_from_departments()
# Link to the action
project.related_actions.add(action)
@ -1177,16 +1198,13 @@ def pdca_phase_edit(request, pk, phase):
from apps.accounts.models import User
team_members = project.team_members.all()
if project.project_lead:
if project.project_lead not in team_members:
team_members = list(team_members) + [project.project_lead]
context = {
"project": project,
"pdca_phase": pdca_phase,
"phase_key": phase,
"phase_label": PDCAPhaseChoices(phase).label,
"team_members": team_members.distinct(),
"team_members": team_members,
"status_choices": StatusChoices.choices,
}
@ -1290,16 +1308,13 @@ def focus_phase_edit(request, pk, phase):
from apps.accounts.models import User
team_members = project.team_members.all()
if project.project_lead:
if project.project_lead not in team_members:
team_members = list(team_members) + [project.project_lead]
context = {
"project": project,
"focus_phase": focus_phase,
"phase_key": phase,
"phase_label": FOCUSPhaseChoices(phase).label,
"team_members": team_members.distinct(),
"team_members": team_members,
"status_choices": StatusChoices.choices,
}
@ -1335,23 +1350,39 @@ def project_export_excel(request, pk):
def _check_project_permission(project, user):
"""Helper to check if user can access project."""
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
"""Check if user can access (view) a project.
Granted to: PX admins, hospital admins (same hospital), the project lead,
or any derived team member (champion/manager of a linked department).
"""
if user.is_px_admin():
return True
# Hospital admins are scoped to their own hospital
if user.is_hospital_admin():
return not (user.hospital and project.hospital and user.hospital != project.hospital)
# Cross-hospital access is always denied for regular users
if user.hospital and project.hospital and user.hospital != project.hospital:
return False
return True
return project.is_team_member(user)
def _get_can_edit(user):
"""Helper to check edit permissions — admin only."""
return user.is_px_admin() or user.is_hospital_admin()
def _get_can_edit(user, project):
"""Check edit (structural) permissions: admins of the same hospital, or the project lead."""
if user.is_px_admin():
return True
if user.is_hospital_admin():
return not (user.hospital and project.hospital and user.hospital != project.hospital)
staff_profile = getattr(user, "staff_profile", None)
return bool(staff_profile and project.project_lead_id == staff_profile.id)
def _can_manage_task(project, task, user):
"""Check if user can toggle a task status.
True for admins, or the task's assignee (so they can check/uncheck their own tasks).
True for admins (same hospital), the project lead, or the task's assignee
(so they can check/uncheck their own tasks).
"""
if _get_can_edit(user):
if _get_can_edit(user, project):
return True
staff_profile = getattr(user, "staff_profile", None)
if staff_profile and task.assigned_to_id == staff_profile.id:
@ -1361,6 +1392,7 @@ def _can_manage_task(project, task, user):
@block_source_user
@login_required
@require_POST
def htmx_task_toggle_status(request, project_pk, task_pk):
"""Toggle task status via HTMX - returns updated task row"""
from django.utils import timezone
@ -1385,7 +1417,7 @@ def htmx_task_toggle_status(request, project_pk, task_pk):
task.save()
can_edit = _get_can_edit(user)
can_edit = _get_can_edit(user, project)
can_toggle = _can_manage_task(project, task, user)
today = timezone.now().date()
@ -1409,7 +1441,7 @@ def htmx_task_delete(request, project_pk, task_pk):
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
if not _get_can_edit(user):
if not _get_can_edit(user, project):
return HttpResponse(_("You don't have permission to delete tasks."), status=403)
# Determine which column to refresh
@ -1436,7 +1468,7 @@ def htmx_task_create(request, project_pk, phase_type, phase):
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
can_edit = _get_can_edit(user)
can_edit = _get_can_edit(user, project)
if not can_edit:
return HttpResponse(_("You don't have permission to add tasks."), status=403)
@ -1544,7 +1576,7 @@ def htmx_task_edit_form(request, project_pk, task_pk):
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
can_edit = _get_can_edit(user)
can_edit = _get_can_edit(user, project)
if not can_edit:
return HttpResponse(_("You don't have permission to edit tasks."), status=403)
@ -1622,7 +1654,7 @@ def htmx_pdca_column(request, project_pk, phase):
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
can_edit = _get_can_edit(user)
can_edit = _get_can_edit(user, project)
today = timezone.now().date()
phase_obj, _ = PDCAPhase.objects.get_or_create(
@ -1666,7 +1698,7 @@ def htmx_focus_column(request, project_pk, phase):
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
can_edit = _get_can_edit(user)
can_edit = _get_can_edit(user, project)
today = timezone.now().date()
phase_obj, _ = FOCUSPhase.objects.get_or_create(
@ -1708,7 +1740,7 @@ def htmx_phase_edit_form(request, project_pk, phase_type, phase):
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
can_edit = _get_can_edit(user)
can_edit = _get_can_edit(user, project)
if not can_edit:
return HttpResponse(_("You don't have permission to edit phases."), status=403)
@ -1770,9 +1802,6 @@ def htmx_phase_edit_form(request, project_pk, phase_type, phase):
# GET - return form
team_members = project.team_members.all()
if project.project_lead:
if project.project_lead not in team_members:
team_members = list(team_members) + [project.project_lead]
return render(
request,
@ -1783,7 +1812,7 @@ def htmx_phase_edit_form(request, project_pk, phase_type, phase):
"phase_key": phase,
"phase_label": phase_label,
"phase_type": phase_type,
"team_members": team_members.distinct(),
"team_members": team_members,
"status_choices": StatusChoices.choices,
},
)

View File

@ -244,6 +244,37 @@ def action_detail(request, pk):
return render(request, "actions/action_detail.html", context)
@login_required
@require_http_methods(["POST"])
def action_upload_attachment(request, pk):
"""Upload an attachment or evidence file to a PX Action."""
action = get_object_or_404(PXAction, pk=pk)
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin() or action.assigned_to == user):
messages.error(request, "You don't have permission to upload attachments.")
return redirect("actions:action_detail", pk=pk)
uploaded = request.FILES.get("file")
if not uploaded:
messages.error(request, "Please select a file to upload.")
return redirect("actions:action_detail", pk=pk)
PXActionAttachment.objects.create(
action=action,
file=uploaded,
filename=uploaded.name,
file_type=uploaded.content_type,
file_size=uploaded.size,
uploaded_by=user,
description=request.POST.get("description", "").strip(),
is_evidence=request.POST.get("is_evidence") == "on",
)
messages.success(request, "File uploaded successfully.")
return redirect("actions:action_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def action_assign(request, pk):
@ -474,7 +505,18 @@ def action_create(request):
return redirect("actions:action_list")
if request.method == "POST":
form = ManualActionForm(request.POST, request=request)
post = request.POST.copy()
# Inject required fields the template doesn't render as visible inputs
if not post.get("source_type"):
post["source_type"] = (
ActionSource.COMPLAINT if post.get("complaint_id") else ActionSource.MANUAL
)
if not post.get("hospital"):
if user.hospital:
post["hospital"] = str(user.hospital.id)
elif hasattr(request, "tenant_hospital") and request.tenant_hospital:
post["hospital"] = str(request.tenant_hospital.id)
form = ManualActionForm(post, request=request)
if form.is_valid():
action = form.save(commit=False)
action.created_by = user
@ -486,6 +528,17 @@ def action_create(request):
if action.assigned_to:
action.assigned_at = timezone.now()
# Link to complaint if provided (from complaint detail → "Create Action" button)
complaint_id = request.POST.get("complaint_id", "").strip()
if complaint_id:
from apps.complaints.models import Complaint
from django.contrib.contenttypes.models import ContentType
complaint = Complaint.objects.filter(pk=complaint_id).first()
if complaint:
action.source_type = ActionSource.COMPLAINT
action.content_type = ContentType.objects.get_for_model(Complaint)
action.object_id = complaint.id
action.save()
# Create log
@ -526,10 +579,19 @@ def action_create(request):
form = ManualActionForm(request=request)
# Complaint linkage (from complaint detail → "Create Action" button)
linked_complaint_id = request.GET.get("complaint_id", "").strip()
linked_complaint = None
if linked_complaint_id:
from apps.complaints.models import Complaint
linked_complaint = Complaint.objects.filter(pk=linked_complaint_id).first()
context = {
"form": form,
"source_choices": ActionSource.choices,
"status_choices": ActionStatus.choices,
"linked_complaint_id": linked_complaint_id,
"linked_complaint": linked_complaint,
}
return render(request, "actions/action_create.html", context)

View File

@ -23,6 +23,7 @@ urlpatterns = [
),
path("<uuid:pk>/", ui_views.action_detail, name="action_detail"),
path("<uuid:pk>/assign/", ui_views.action_assign, name="action_assign"),
path("<uuid:pk>/upload-attachment/", ui_views.action_upload_attachment, name="action_upload_attachment"),
path("<uuid:pk>/change-status/", ui_views.action_change_status, name="action_change_status"),
path("<uuid:pk>/add-note/", ui_views.action_add_note, name="action_add_note"),
path("<uuid:pk>/escalate/", ui_views.action_escalate, name="action_escalate"),

View File

@ -189,7 +189,7 @@ LOCALE_PATHS = [
# AI-powered translation configuration
DJANGO_AI_PO = {
"MODEL": "openrouter/deepseek/deepseek-v4-flash",
"API_KEY": env("OPENROUTER_API_KEY"),
"API_KEY": env("OPENROUTER_API_KEY", default=""),
"TEMPERATURE": 0.2,
"BATCH_SIZE": 5,
"WORKERS": 2,
@ -370,9 +370,7 @@ SLA_DEFAULTS = {
COMPLAINT_LINK_EXPIRY_DAYS = env.int("COMPLAINT_LINK_EXPIRY_DAYS", default=7)
# AI Configuration (OpenRouter)
OPENROUTER_API_KEY = env(
"OPENROUTER_API_KEY", default="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"
)
OPENROUTER_API_KEY = env("OPENROUTER_API_KEY", default="")
AI_MODEL = env("AI_MODEL", default="z-ai/glm-4.5-air:free")
AI_TEMPERATURE = env.float("AI_TEMPERATURE", default=0.3)
AI_MAX_TOKENS = env.int("AI_MAX_TOKENS", default=500)
@ -445,20 +443,10 @@ EMAIL_LOGO_URL = env("EMAIL_LOGO_URL", default=f"{SITE_URL}{STATIC_URL}img/HH_P_
# HIS Integration Settings
HIS_API_URL = env("HIS_API_URL", default="https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps")
HIS_API_USERNAME = env("HIS_API_USERNAME", default="AlhhSUNZHippo")
HIS_API_USERNAME = env("HIS_API_USERNAME", default="")
HIS_API_PASSWORD = env("HIS_API_PASSWORD", default="")
HIS_RATINGS_API_URL = env("HIS_RATINGS_API_URL", default="")
# Password contains special characters (*#$@PAlhh^2106) - set directly to avoid parsing issues
# Check .env first, fall back to hardcoded default
try:
_env_password = env("HIS_API_PASSWORD")
if _env_password and _env_password != "*":
HIS_API_PASSWORD = _env_password
else:
HIS_API_PASSWORD = "*#$@PAlhh^2106"
except Exception:
HIS_API_PASSWORD = "*#$@PAlhh^2106"
# Security Settings
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
@ -497,20 +485,14 @@ TENANT_ISOLATION_LEVEL = "strict"
# Social Media API Configuration
YOUTUBE_API_KEY = env("YOUTUBE_API_KEY", default="AIzaSyAem20etP6GkRNMmCyI1pRJF7v8U_xDyMM")
YOUTUBE_CHANNEL_ID = env("YOUTUBE_CHANNEL_ID", default="UCKoEfCXsm4_cQMtqJTvZUVQ")
YOUTUBE_API_KEY = env("YOUTUBE_API_KEY", default="")
YOUTUBE_CHANNEL_ID = env("YOUTUBE_CHANNEL_ID", default="")
FACEBOOK_PAGE_ID = env("FACEBOOK_PAGE_ID", default="938104059393026")
FACEBOOK_ACCESS_TOKEN = env(
"FACEBOOK_ACCESS_TOKEN",
default="EAATrDf0UAS8BQWSKbljCUDMbluZBbxZCSWLJkZBGIviBtK8IQ7FDHfGQZBHHm7lsgLhZBL2trT3ZBGPtsWRjntFWQovhkhx726ZBexRZCKitEMhxAiZBmls7uX946432k963Myl6aYBzJzwLhSyygZAFOGP7iIIZANVf6GtLlvAnWn0NXRwZAYR0CNNUwCEEsZAAc",
)
FACEBOOK_PAGE_ID = env("FACEBOOK_PAGE_ID", default="")
FACEBOOK_ACCESS_TOKEN = env("FACEBOOK_ACCESS_TOKEN", default="")
INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID", default="17841431861985364")
INSTAGRAM_ACCESS_TOKEN = env(
"INSTAGRAM_ACCESS_TOKEN",
default="EAATrDf0UAS8BQWSKbljCUDMbluZBbxZCSWLJkZBGIviBtK8IQ7FDHfGQZBHHm7lsgLhZBL2trT3ZBGPtsWRjntFWQovhkhx726ZBexRZCKitEMhxAiZBmls7uX946432k963Myl6aYBzJzwLhSyygZAFOGP7iIIZANVf6GtLlvAnWn0NXRwZAYR0CNNUwCEEsZAAc",
)
INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID", default="")
INSTAGRAM_ACCESS_TOKEN = env("INSTAGRAM_ACCESS_TOKEN", default="")
# Twitter/X Configuration
TWITTER_BEARER_TOKEN = env("TWITTER_BEARER_TOKEN", default=None)
@ -526,9 +508,6 @@ GOOGLE_TOKEN_FILE = env("GOOGLE_TOKEN_FILE", default="token.json")
GOOGLE_LOCATIONS = env.list("GOOGLE_LOCATIONS", default=[])
# OpenRouter Configuration for AI Comment Analysis
OPENROUTER_API_KEY = env(
"OPENROUTER_API_KEY", default="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"
)
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)

View File

@ -6,7 +6,7 @@ from .base import * # noqa
DEBUG = True
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0", ".ngrok-free.app", "micha-nonparabolic-lovie.ngrok-free.dev"]
ALLOWED_HOSTS = ["192.168.8.13","localhost", "127.0.0.1", "0.0.0.0", ".ngrok-free.app", "micha-nonparabolic-lovie.ngrok-free.dev"]
# Database - Use PostgreSQL even in dev for consistency
# Override with SQLite if needed for quick local testing
@ -41,9 +41,6 @@ CSRF_TRUSTED_ORIGINS = [
CELERY_TASK_ALWAYS_EAGER = env.bool("CELERY_TASK_ALWAYS_EAGER", default=False)
CELERY_TASK_EAGER_PROPAGATES = True
# CORS for development (if needed for frontend)
CORS_ALLOW_ALL_ORIGINS = True
# Django Debug Toolbar (optional)
if DEBUG:
INSTALLED_APPS += ["django_extensions"] # noqa

View File

@ -3,19 +3,21 @@ Production settings for PX360 project.
"""
from .base import * # noqa
DEBUG = False
DEBUG = env.bool("DEBUG", default=False)
# Hosts and trusted origins (driven by environment, works for any deploy target)
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[]) + ["localhost", "127.0.0.1"]
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
# Caddy handles SSL termination, so trust the X-Forwarded-Proto header
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = env.bool('SECURE_SSL_REDIRECT', default=False)
SECURE_SSL_REDIRECT = env.bool('SECURE_SSL_REDIRECT', default=True)
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[]) + ['localhost', '127.0.0.1']
DATABASES = {
'default': env.db('DATABASE_URL')
}

4
deploy/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# Real env files contain secrets — never commit them.
# The *.example files are committed templates.
env.dev
env.prod

78
deploy/compose.dev.yml Normal file
View File

@ -0,0 +1,78 @@
# PX360 — DEV compose for `target deploy` (bundled postgres + redis)
#
# target injects into the `web_app` service on deploy:
# - container_name: <app>-web_app (so the shared Caddy can route to it)
# - networks: [target-proxy, default]
# - removes any `ports:` (the shared Caddy handles ingress)
#
# The web service MUST be named `web_app` — target's Caddy injection looks for it.
# `{{ .ImageTag }}` is replaced by target with the freshly built image tag.
#
# env file (in the deploy dir ~/.target/hh-dev/):
# .env — ALL config + secrets, shipped by `target deploy` (deploy/env.dev)
services:
web_app:
image: {{ .ImageTag }}
restart: unless-stopped
env_file:
- .env
volumes:
- media_volume:/app/media
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "8888:8000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
celery:
image: {{ .ImageTag }}
restart: unless-stopped
command: celery -A config worker -l info --concurrency=2
env_file:
- .env
# celery-beat:
# image: {{ .ImageTag }}
# restart: unless-stopped
# command: celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
# env_file:
# - .env
db:
image: postgres:15-alpine
restart: unless-stopped
ports:
- "5432:5432"
env_file:
- .env
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-px360}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pg_data:
redis_data:
media_volume:

53
deploy/compose.prod.yml Normal file
View File

@ -0,0 +1,53 @@
# PX360 — PROD compose for `target deploy` (external PostgreSQL, bundled redis)
#
# Same injection rules as compose.dev.yml: web service must be named `web_app`,
# target adds container_name + networks and removes ports.
#
# The database is EXTERNAL (managed elsewhere). DATABASE_URL (in .env) is used by
# Django; DB_HOST/DB_PORT/DB_USER (in .env) are used by the entrypoint's pg_isready.
#
# env file (in the deploy dir ~/.target/hh-prod/):
# .env — ALL config + secrets, shipped by `target deploy` (deploy/env.prod)
services:
web_app:
image: {{ .ImageTag }}
restart: unless-stopped
env_file:
- .env
volumes:
- media_volume:/app/media
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
celery:
image: {{ .ImageTag }}
restart: unless-stopped
command: celery -A config worker -l info --concurrency=4
env_file:
- .env
celery-beat:
image: {{ .ImageTag }}
restart: unless-stopped
command: celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
env_file:
- .env
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
redis_data:
media_volume:

100
deploy/env.dev.example Normal file
View File

@ -0,0 +1,100 @@
# ============================================================
# PX360 DEV environment — ALL config + secrets in ONE file.
# Shipped to the server as ~/.target/hh-dev/.env by `target deploy`
# (see deploy/target.dev.yaml → env_file).
#
# This is the committed TEMPLATE. To create your real (gitignored) file:
# make init-dev # copies this to deploy/env.dev
# $EDITOR deploy/env.dev # fill in the secrets marked below
# ============================================================
# --- Django ---
DJANGO_SETTINGS_MODULE=config.settings.prod
DEBUG=True
ALLOWED_HOSTS=django.ismailmosaibrahim.com,.ismailmosaibrahim.com
CSRF_TRUSTED_ORIGINS=https://django.ismailmosaibrahim.com,
ADMIN_URL=admin/
SECRET_KEY=CHANGE-ME-run: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
# --- Database (dev: bundled postgres in compose.dev.yml) ---
DB_HOST=db
DB_PORT=5432
DB_USER=px360
POSTGRES_DB=px360
POSTGRES_USER=px360
# IMPORTANT: POSTGRES_PASSWORD must match the password embedded in DATABASE_URL.
DATABASE_URL=postgresql://px360:CHANGE-ME@db:5432/px360
POSTGRES_PASSWORD=CHANGE-ME
# --- Celery ---
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
CELERY_TASK_ALWAYS_EAGER=False
# --- AI ---
AI_MODEL=openrouter/deepseek/deepseek-v4-flash
AI_TEMPERATURE=0.3
AI_MAX_TOKENS=500
#OPENROUTER_MODEL=google/gemma-3-27b-it:free
ANALYSIS_ENABLED=True
ANALYSIS_BATCH_SIZE=2
OPENROUTER_API_KEY=
# --- Notifications (dev: console providers) ---
SMS_ENABLED=False
SMS_PROVIDER=console
WHATSAPP_ENABLED=False
WHATSAPP_PROVIDER=console
EMAIL_ENABLED=True
EMAIL_PROVIDER=console
# --- Email (dev: console) ---
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
DEFAULT_FROM_EMAIL=noreply@px360.sa
# --- HIS API ---
HIS_API_URL=https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps
HIS_API_USERNAME=
HIS_API_PASSWORD=
HIS_API_KEY=
# --- Twilio ---
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_PHONE_NUMBER=
TWILIO_MESSAGING_SERVICE_SID=
# --- Mshastra SMS ---
MSHASTRA_USERNAME=
MSHASTRA_PASSWORD=
MSHASTRA_SENDER_ID=
# --- External notification APIs ---
EMAIL_API_KEY=
SMS_API_KEY=
# --- Social media ---
YOUTUBE_API_KEY=
YOUTUBE_CHANNEL_ID=
FACEBOOK_PAGE_ID=
FACEBOOK_ACCESS_TOKEN=
INSTAGRAM_ACCOUNT_ID=
INSTAGRAM_ACCESS_TOKEN=
TWITTER_BEARER_TOKEN=
TWITTER_USERNAME=
LINKEDIN_ACCESS_TOKEN=
LINKEDIN_ORGANIZATION_ID=
# --- Google Reviews ---
GOOGLE_CREDENTIALS_FILE=client_secret.json
GOOGLE_TOKEN_FILE=token.json
GOOGLE_LOCATIONS=
# --- Other integration APIs ---
MOH_API_URL=
MOH_API_KEY=
CHI_API_URL=
CHI_API_KEY=
# --- Dev only: redirect all SMS to this number when DEBUG=True ---
DEV_SMS_RECIPIENT=

103
deploy/env.prod.example Normal file
View File

@ -0,0 +1,103 @@
# ============================================================
# PX360 PROD environment — ALL config + secrets in ONE file.
# Shipped to the server as ~/.target/hh-prod/.env by `target deploy`
# (see deploy/target.prod.yaml → env_file).
#
# This is the committed TEMPLATE. To create your real (gitignored) file:
# make init-prod # copies this to deploy/env.prod
# $EDITOR deploy/env.prod # fill in the secrets marked below
# ============================================================
# --- Django ---
DJANGO_SETTINGS_MODULE=config.settings.prod
DEBUG=False
ALLOWED_HOSTS=your-production-domain.com
CSRF_TRUSTED_ORIGINS=https://your-production-domain.com
ADMIN_URL=CHANGE-ME/
SECURE_SSL_REDIRECT=True
SECRET_KEY=CHANGE-ME-run: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
# --- Database (prod: EXTERNAL postgres) ---
DB_HOST=your-db-host
DB_PORT=5432
DB_USER=px360
POSTGRES_DB=px360
POSTGRES_USER=px360
# IMPORTANT: POSTGRES_PASSWORD must match the password embedded in DATABASE_URL.
DATABASE_URL=postgresql://px360:CHANGE-ME@your-db-host:5432/px360
POSTGRES_PASSWORD=CHANGE-ME
# --- Celery ---
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
CELERY_TASK_ALWAYS_EAGER=False
# --- AI ---
AI_MODEL=z-ai/glm-4.5-air:free
AI_TEMPERATURE=0.3
AI_MAX_TOKENS=500
OPENROUTER_MODEL=google/gemma-3-27b-it:free
ANALYSIS_ENABLED=True
ANALYSIS_BATCH_SIZE=10
OPENROUTER_API_KEY=
# --- Notifications ---
SMS_ENABLED=False
SMS_PROVIDER=console
WHATSAPP_ENABLED=False
WHATSAPP_PROVIDER=console
EMAIL_ENABLED=True
EMAIL_PROVIDER=console
# --- Email (prod: SMTP) ---
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
DEFAULT_FROM_EMAIL=noreply@px360.sa
# --- HIS API ---
HIS_API_URL=https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps
HIS_API_USERNAME=
HIS_API_PASSWORD=
HIS_API_KEY=
# --- Twilio ---
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_PHONE_NUMBER=
TWILIO_MESSAGING_SERVICE_SID=
# --- Mshastra SMS ---
MSHASTRA_USERNAME=
MSHASTRA_PASSWORD=
MSHASTRA_SENDER_ID=
# --- External notification APIs ---
EMAIL_API_KEY=
SMS_API_KEY=
# --- Social media ---
YOUTUBE_API_KEY=
YOUTUBE_CHANNEL_ID=
FACEBOOK_PAGE_ID=
FACEBOOK_ACCESS_TOKEN=
INSTAGRAM_ACCOUNT_ID=
INSTAGRAM_ACCESS_TOKEN=
TWITTER_BEARER_TOKEN=
TWITTER_USERNAME=
LINKEDIN_ACCESS_TOKEN=
LINKEDIN_ORGANIZATION_ID=
# --- Google Reviews ---
GOOGLE_CREDENTIALS_FILE=client_secret.json
GOOGLE_TOKEN_FILE=token.json
GOOGLE_LOCATIONS=
# --- Other integration APIs ---
MOH_API_URL=
MOH_API_KEY=
CHI_API_URL=
CHI_API_KEY=

24
deploy/target.dev.yaml Normal file
View File

@ -0,0 +1,24 @@
# PX360 — DEV deploy target.
# Usage: `make deploy-dev` (runs: target deploy --config deploy/target.dev.yaml)
app:
name: "hh-dev" # isolated project dir: ~/.target/hh-dev/
domain: "test.ismailmosaibrahim.com" # shared Caddy routes this domain → web_app
port: 8888
server:
ip: "10.132.16.5"
user: "ahh-ops"
ssh_key: "~/.ssh/id_rsa"
build:
engine: "local"
context: "." # repo root (where the Dockerfile lives)
deploy:
use_caddy: false # shared target-caddy handles HTTPS
caddy_email: "ismail.mosa.ibrahim@gmail.com"
compose_file: "deploy/compose.dev.yml" # user-provided compose (uses {{ .ImageTag }})
env_file: "deploy/env.dev" # shipped to ~/.target/hh-dev/.env each deploy
pre_deploy: "python manage.py migrate --noinput" # runs before the swap (old keeps serving)
cleanup: ["remote", "local"]
keep_versions: 2

26
deploy/target.prod.yaml Normal file
View File

@ -0,0 +1,26 @@
# PX360 — PROD deploy target.
# Usage: `make deploy-prod` (runs: target deploy --config deploy/target.prod.yaml)
#
# TODO: fill in the real prod server IP + domain below before first deploy.
app:
name: "hh-prod" # isolated project dir: ~/.target/hh-prod/
domain: "your-production-domain.com" # TODO: real prod domain
port: 8000
server:
ip: "10.10.1.132" # TODO: real prod server IP
user: "root"
ssh_key: "~/.ssh/id_rsa"
build:
engine: "local"
context: "."
deploy:
use_caddy: true
caddy_email: "ismail.mosa.ibrahim@gmail.com"
compose_file: "deploy/compose.prod.yml"
env_file: "deploy/env.prod" # shipped to ~/.target/hh-prod/.env each deploy
pre_deploy: "python manage.py migrate --noinput" # runs before the swap (old keeps serving)
cleanup: ["remote", "local"]
keep_versions: 3

View File

@ -50,6 +50,10 @@ EXCLUDE_TABLES=(
# Flags
DEPLOY=false
CONTAINER="" # DB container name (e.g. hh-dev-db-1) → restore via docker exec
MEDIA_CONTAINER="" # app container name (e.g. hh-dev-web_app) → media restore via docker exec
PROJECT="" # compose project name (e.g. hh-dev) → for stop/start app containers
NO_MEDIA=false # skip media dump+restore
# ============================================================
# Parse arguments
@ -61,15 +65,39 @@ while [[ $# -gt 0 ]]; do
PROD_SSH="$2"
shift 2
;;
--container)
CONTAINER="$2"
shift 2
;;
--media-container)
MEDIA_CONTAINER="$2"
shift 2
;;
--project)
PROJECT="$2"
shift 2
;;
--no-media)
NO_MEDIA=true
shift
;;
--help|-h)
echo "Usage: ./scripts/db_sync.sh [--deploy user@db-server-ip]"
echo "Usage: ./scripts/db_sync.sh [options] [--deploy user@host]"
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"
echo "Containerized target (DB runs in a Docker container on the server):"
echo " --container <name> DB container (e.g. hh-dev-db-1)"
echo " --media-container <n> app container for media (e.g. hh-dev-web_app)"
echo " --project <name> compose project (e.g. hh-dev) for stop/start app"
echo " --no-media skip media dump+restore"
echo ""
echo "Examples:"
echo " ./scripts/db_sync.sh # local dump"
echo " ./scripts/db_sync.sh --deploy root@10.10.1.100 # host-installed pg tools"
echo " ./scripts/db_sync.sh --deploy root@host \\\\"
echo " --container hh-dev-db-1 --media-container hh-dev-web_app --project hh-dev"
exit 0
;;
*)
@ -89,7 +117,9 @@ if [ "$DEPLOY" = true ]; then
fi
ENV_FILE="$PROJECT_DIR/.env.production"
if [ -f "$ENV_FILE" ]; then
# In container mode the DB lives in the container; no DB_HOST needed.
# Only read .env.production for host-mode (no --container) or if it exists.
if [ -z "$CONTAINER" ] && [ -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")
@ -98,19 +128,30 @@ if [ "$DEPLOY" = true ]; then
fi
fi
if [ -z "$PROD_DB_HOST" ]; then
if [ -z "$CONTAINER" ] && [ -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
# Derive compose project from media container (target names it <project>-web_app) if not given
if [ -n "$MEDIA_CONTAINER" ] && [ -z "$PROJECT" ]; then
PROJECT="${MEDIA_CONTAINER%-web_app}"
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 -e " SSH: $PROD_SSH"
if [ -n "$CONTAINER" ]; then
echo -e " DB container: $CONTAINER"
echo -e " Media target: ${MEDIA_CONTAINER:-<none>}"
echo -e " Project: ${PROJECT:-<none>}"
else
echo -e " DB Host: $PROD_DB_HOST"
fi
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}"
echo -e "${YELLOW}This will OVERWRITE the target database data. Continue? (y/N)${NC}"
read -r CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo "Aborted."
@ -186,7 +227,10 @@ fi
echo ""
echo -e "${BLUE}[2/5] Dumping media files...${NC}"
if [ -d "$PROJECT_DIR/media" ] && [ "$(ls -A "$PROJECT_DIR/media" 2>/dev/null)" ]; then
if [ "$NO_MEDIA" = true ]; then
echo -e "${YELLOW} Skipped (--no-media).${NC}"
MEDIA_DUMP=""
elif [ -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}"
@ -250,49 +294,113 @@ fi
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}"
if [ -n "$CONTAINER" ]; then
# ----- Container mode: restore via docker exec into the DB container -----
echo " Creating database if not exists..."
createdb -U "\$DB_USER" "\$DB_NAME" 2>/dev/null || echo " Database already exists."
# Stop app containers so nothing holds connections during --clean restore
if [ -n "$PROJECT" ]; then
echo -e "${BLUE} Stopping app containers (project $PROJECT)...${NC}"
ssh "$PROD_SSH" "cd \$HOME/.target/${PROJECT} && docker compose -p ${PROJECT} stop web_app celery celery-beat 2>&1 || true"
fi
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
ssh "$PROD_SSH" bash -s << REMOTE_SCRIPT
set -e
DB_USER="${PROD_DB_USER}"
DB_NAME="${PROD_DB_NAME}"
DB_CONTAINER="${CONTAINER}"
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
echo " Copying dump into DB container \$DB_CONTAINER..."
docker cp /tmp/px360_restore.dump "\$DB_CONTAINER":/tmp/px360_restore.dump
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 " Restoring database (this may take a minute)..."
docker exec "\$DB_CONTAINER" 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 " Cleaning up temp files..."
rm -f /tmp/px360_restore.dump /tmp/px360_media.tar.gz
echo " Verifying restore..."
docker exec "\$DB_CONTAINER" psql -U "\$DB_USER" -d "\$DB_NAME" -t -c "
SELECT ' Total tables: ' || count(*) FROM pg_stat_user_tables;
" 2>/dev/null || true
docker exec "\$DB_CONTAINER" 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 " Restore complete."
echo " Cleaning DB container temp..."
docker exec "\$DB_CONTAINER" rm -f /tmp/px360_restore.dump
echo " DB 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'"
# Start app containers back up
if [ -n "$PROJECT" ]; then
echo -e "${BLUE} Starting app containers...${NC}"
ssh "$PROD_SSH" "cd \$HOME/.target/${PROJECT} && docker compose -p ${PROJECT} up -d 2>&1 || true"
fi
# Media restore (web_app must be running, so after `up -d`)
if [ -n "$MEDIA_DUMP" ] && [ -n "$MEDIA_CONTAINER" ]; then
echo ""
echo -e "${BLUE}Restoring media into ${MEDIA_CONTAINER}...${NC}"
ssh "$PROD_SSH" bash -s << REMOTE_MEDIA
set -e
MC="${MEDIA_CONTAINER}"
docker cp /tmp/px360_media.tar.gz "\$MC":/tmp/px360_media.tar.gz
# web_app runs as appuser (UID 1000); chown so the app can write uploads later
docker exec "\$MC" sh -c "mkdir -p /app/media && tar -xzf /tmp/px360_media.tar.gz -C /app/media && chown -R 1000:1000 /app/media"
docker exec "\$MC" rm -f /tmp/px360_media.tar.gz
echo " Media restored to \$MC:/app/media"
REMOTE_MEDIA
fi
# Clean up host temp copies
ssh "$PROD_SSH" "rm -f /tmp/px360_restore.dump /tmp/px360_media.tar.gz" 2>/dev/null || true
else
# ----- Host mode: host-installed pg client tools (original behavior) -----
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
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}Restore manually on the app server:${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
fi
# ============================================================

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@ -17,6 +17,25 @@
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<form method="post">
{% csrf_token %}
{% if linked_complaint_id %}
<input type="hidden" name="complaint_id" value="{{ linked_complaint_id }}">
{% endif %}
{% if form.errors %}
<div class="m-6 p-4 bg-red-50 border border-red-200 rounded-xl">
<p class="text-sm font-bold text-red-700 mb-2">{% trans "Please fix the following errors:" %}</p>
<ul class="text-sm text-red-600 space-y-1">
{% for field in form %}
{% for error in field.errors %}
<li>{{ field.label }}: {{ error }}</li>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="p-6 space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@ -52,42 +71,48 @@
</div>
<div>
<label for="due_date" class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Due Date" %}</label>
<input type="date" class="w-full px-4 py-3 border border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-navy focus:border-transparent transition" id="due_date" name="due_date" value="{{ action.due_date|date:'Y-m-d'|default:'' }}">
<label for="due_at" class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Due Date" %} <span class="text-navy">*</span></label>
<input type="datetime-local" class="w-full px-4 py-3 border border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-navy focus:border-transparent transition" id="due_at" name="due_at" required>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="related_complaint" class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Related Complaint" %}</label>
<select class="w-full px-4 py-3 border border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-navy focus:border-transparent transition" id="related_complaint" name="related_complaint">
<option value="">{% trans "Select Complaint" %}</option>
{% for complaint in complaints %}
<option value="{{ complaint.id }}" {% if action.related_complaint_id == complaint.id %}selected{% endif %}>{{ complaint.ticket_number }} - {{ complaint.category.name }}</option>
{% endfor %}
<label for="category" class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Category" %} <span class="text-navy">*</span></label>
<select class="w-full px-4 py-3 border border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-navy focus:border-transparent transition" id="category" name="category" required>
<option value="process_improvement" selected>{% trans "Process Improvement" %}</option>
<option value="clinical_quality">{% trans "Clinical Quality" %}</option>
<option value="patient_safety">{% trans "Patient Safety" %}</option>
<option value="service_quality">{% trans "Service Quality" %}</option>
<option value="staff_behavior">{% trans "Staff Behavior" %}</option>
<option value="facility">{% trans "Facility & Environment" %}</option>
<option value="other">{% trans "Other" %}</option>
</select>
</div>
<div>
<label for="related_survey" class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Related Survey" %}</label>
<select class="w-full px-4 py-3 border border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-navy focus:border-transparent transition" id="related_survey" name="related_survey">
<option value="">{% trans "Select Survey" %}</option>
{% for survey in surveys %}
<option value="{{ survey.id }}" {% if action.related_survey_id == survey.id %}selected{% endif %}>{{ survey.patient_name }}</option>
{% endfor %}
<label for="severity" class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Severity" %} <span class="text-navy">*</span></label>
<select class="w-full px-4 py-3 border border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-navy focus:border-transparent transition" id="severity" name="severity" required>
<option value="low">{% trans "Low" %}</option>
<option value="medium" selected>{% trans "Medium" %}</option>
<option value="high">{% trans "High" %}</option>
<option value="critical">{% trans "Critical" %}</option>
</select>
</div>
</div>
{% if linked_complaint %}
<div>
<label for="status" class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Status" %}</label>
<select class="w-full px-4 py-3 border border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-navy focus:border-transparent transition" id="status" name="status">
<option value="pending" {% if action.status == 'pending' or not action %}selected{% endif %}>{% trans "Pending" %}</option>
<option value="in_progress" {% if action.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
<option value="completed" {% if action.status == 'completed' %}selected{% endif %}>{% trans "Completed" %}</option>
<option value="cancelled" {% if action.status == 'cancelled' %}selected{% endif %}>{% trans "Cancelled" %}</option>
</select>
<label class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Related Complaint" %}</label>
<div class="flex items-center gap-2 px-4 py-3 bg-blue-50 border border-blue-200 rounded-xl">
<i data-lucide="link" class="w-4 h-4 text-blue-600 shrink-0"></i>
<div class="min-w-0">
<p class="text-sm font-bold text-navy truncate">{{ linked_complaint.reference_number }}</p>
<p class="text-xs text-slate-500 truncate">{{ linked_complaint.title|truncatechars:60 }}</p>
</div>
</div>
</div>
{% endif %}
</div>
<div class="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3">

View File

@ -6,6 +6,16 @@
{% block extra_css %}
<style>
.tab-active {
border-bottom: 3px solid #005696;
color: #005696;
font-weight: 700;
}
.tab-inactive {
color: #64748b;
font-weight: 500;
}
.tab-inactive:hover { color: #005696; }
.timeline {
position: relative;
padding-left: 30px;
@ -28,14 +38,14 @@
position: absolute;
left: -26px;
top: 5px;
width: 16px;
height: 16px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
background: white;
border: 3px solid #f57c00;
z-index: 1;
}
.timeline-item.status_change::before { border-color: #1e3a5f; }
.timeline-item.status_change::before { border-color: #005696; }
.timeline-item.assignment::before { border-color: #388e3c; }
.timeline-item.escalation::before { border-color: #d32f2f; }
.timeline-item.approval::before { border-color: #4caf50; }
@ -44,182 +54,180 @@
{% endblock %}
{% block content %}
<!-- Back Button -->
<div class="mb-6">
<a href="{% url 'actions:action_list' %}" class="inline-flex items-center gap-2 text-slate hover:text-navy transition font-medium">
<i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to Actions" %}
</a>
</div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<!-- Action Header -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 p-6 mb-6">
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
<div class="flex-1">
<div class="flex flex-wrap items-center gap-3 mb-3">
<h1 class="text-2xl font-bold text-navy">{{ action.title }}</h1>
{% if action.status == 'completed' %}
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-green-100 text-green-600">{{ action.get_status_display }}</span>
{% elif action.status == 'in_progress' %}
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-600">{{ action.get_status_display }}</span>
{% elif action.status == 'cancelled' %}
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-gray-100 text-gray-600">{{ action.get_status_display }}</span>
{% else %}
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-yellow-100 text-yellow-600">{{ action.get_status_display }}</span>
{% endif %}
{% if action.priority == 'high' %}
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-red-100 text-red-600">{{ action.get_priority_display }}</span>
{% elif action.priority == 'medium' %}
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-orange-100 text-orange-600">{{ action.get_priority_display }}</span>
{% else %}
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-green-100 text-green-600">{{ action.get_priority_display }}</span>
{% endif %}
{% if action.escalation_level > 0 %}
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-red-500 text-white">
<i data-lucide="arrow-up-circle" class="w-3 h-3 inline me-1"></i>Level {{ action.escalation_level }}
</span>
{% endif %}
</div>
<div class="flex flex-wrap items-center gap-4 text-sm text-slate">
<span class="flex items-center gap-1">
<i data-lucide="hash" class="w-4 h-4"></i>
<strong>ID:</strong> {{ action.id|slice:":8" }}
</span>
<span class="flex items-center gap-1">
<i data-lucide="building" class="w-4 h-4"></i>
{{ action.hospital.get_localized_name }}
</span>
{% if action.department %}
<span class="flex items-center gap-1">
<i data-lucide="door-open" class="w-4 h-4"></i>
{{ action.department.get_localized_name }}
</span>
{% endif %}
</div>
</div>
<!-- SLA Progress -->
<div class="lg:w-72 p-4 rounded-xl {% if action.is_overdue %}bg-red-50 border border-red-200{% else %}bg-slate-50{% endif %}">
<div class="flex items-center gap-2 mb-3 text-sm font-semibold text-slate">
<i data-lucide="clock" class="w-4 h-4"></i> {% trans "SLA Progress" %}
</div>
<div class="w-full h-6 bg-gray-200 rounded-full overflow-hidden mb-2">
<div class="h-full rounded-full flex items-center justify-center text-xs font-bold text-white {% if action.is_overdue %}bg-red-500{% elif sla_progress > 80 %}bg-orange-500{% else %}bg-green-500{% endif %}" style="width: {{ sla_progress }}%">
{{ sla_progress }}%
</div>
</div>
<div class="flex justify-between text-xs text-slate">
<span>{% trans "Due:" %} {{ action.due_at|date:"M d, Y H:i" }}</span>
{% if action.is_overdue %}
<span class="text-red-600 font-bold">{% trans "OVERDUE" %}</span>
{% else %}
<span>{{ action.due_at|timeuntil }} {% trans "left" %}</span>
{% endif %}
</div>
</div>
<!-- Breadcrumb & Header -->
<header class="mb-6">
<div class="flex items-center gap-2 text-sm text-slate mb-2 flex-wrap">
<a href="{% url 'actions:action_list' %}" class="hover:text-navy font-medium">{% trans "Actions" %}</a>
<i data-lucide="chevron-right" class="w-4 h-4"></i>
<span class="font-bold text-navy">{{ action.id|slice:":8" }}</span>
<span class="ml-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider
{% if action.status == 'completed' %}bg-green-100 text-green-700
{% elif action.status == 'in_progress' %}bg-blue-100 text-blue-700
{% elif action.status == 'cancelled' %}bg-slate-100 text-slate-600
{% elif action.status == 'pending_approval' %}bg-purple-100 text-purple-700
{% else %}bg-yellow-100 text-yellow-700{% endif %}">
{{ action.get_status_display }}
</span>
{% if action.is_overdue %}
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase bg-red-100 text-red-700">
{% trans "Overdue" %}
</span>
{% endif %}
{% if action.escalation_level > 0 %}
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase bg-red-500 text-white">
Level {{ action.escalation_level }}
</span>
{% endif %}
</div>
</div>
<h1 class="text-2xl font-bold text-navy mb-2">{{ action.title }}</h1>
<div class="flex flex-wrap items-center gap-4 text-sm text-slate">
{% if action.department %}
<span class="flex items-center gap-1">
<i data-lucide="building-2" class="w-4 h-4"></i>
{{ action.department.get_localized_name }}
</span>
{% endif %}
<span class="flex items-center gap-1">
<i data-lucide="calendar" class="w-4 h-4"></i>
{{ action.created_at|date:"d M Y" }}
</span>
<span class="flex items-center gap-1 {% if action.is_overdue %}text-red-600 font-bold{% endif %}">
<i data-lucide="clock" class="w-4 h-4"></i>
{% trans "Due" %} {{ action.due_at|date:"d M Y, h:i A" }}
</span>
{% if action.assigned_to %}
<span class="flex items-center gap-1">
<i data-lucide="user-check" class="w-4 h-4"></i>
{{ action.assigned_to.get_full_name }}
</span>
{% endif %}
</div>
<!-- SLA Progress -->
<div class="mt-4 flex items-center gap-3">
<div class="flex-1 max-w-xs h-2 bg-slate-200 rounded-full overflow-hidden">
<div class="h-full rounded-full {% if action.is_overdue %}bg-red-500{% elif sla_progress > 80 %}bg-orange-500{% else %}bg-green-500{% endif %}" style="width: {{ sla_progress }}%"></div>
</div>
<span class="text-xs font-bold {% if action.is_overdue %}text-red-600{% else %}text-slate{% endif %}">{{ sla_progress }}%</span>
</div>
</header>
<div class="flex flex-col lg:flex-row gap-6">
<!-- Main Content -->
<div class="flex-1">
<!-- Tabs -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden">
<div class="border-b border-gray-100">
<nav class="flex gap-1 p-2" role="tablist">
<button type="button" class="tab-btn active px-4 py-2 rounded-xl text-sm font-semibold transition" data-tab="details">
<i data-lucide="info" class="w-4 h-4 inline me-1"></i> {% trans "Details" %}
</button>
<button type="button" class="tab-btn px-4 py-2 rounded-xl text-sm font-semibold transition" data-tab="logs">
<i data-lucide="history" class="w-4 h-4 inline me-1"></i> {% trans "Activity" %} ({{ logs.count }})
</button>
<button type="button" class="tab-btn px-4 py-2 rounded-xl text-sm font-semibold transition" data-tab="evidence">
<i data-lucide="file-check" class="w-4 h-4 inline me-1"></i> {% trans "Evidence" %} ({{ evidence_attachments.count }})
</button>
<button type="button" class="tab-btn px-4 py-2 rounded-xl text-sm font-semibold transition" data-tab="attachments">
<i data-lucide="paperclip" class="w-4 h-4 inline me-1"></i> {% trans "Attachments" %} ({{ attachments.count }})
</button>
</nav>
</div>
<!-- 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">
{% trans "Details" %}
</button>
<button class="py-4 text-sm tab-inactive" onclick="switchTab('activity')" id="tab-activity">
{% trans "Activity" %} ({{ logs.count }})
</button>
<button class="py-4 text-sm tab-inactive" onclick="switchTab('evidence')" id="tab-evidence">
{% trans "Evidence" %} ({{ evidence_attachments.count }})
</button>
<button class="py-4 text-sm tab-inactive" onclick="switchTab('attachments')" id="tab-attachments">
{% trans "Attachments" %} ({{ attachments.count }})
</button>
</nav>
<!-- Details Tab -->
<div id="tab-details" class="tab-content p-6">
<h3 class="text-lg font-bold text-navy mb-4">{% trans "Action Details" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<!-- Main Content Grid -->
<main class="grid grid-cols-12 gap-6">
<!-- Left Column -->
<div class="col-span-8 space-y-6">
<!-- Details Tab -->
<div id="panel-details" class="tab-panel space-y-6">
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4 py-2">
<div>
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Category" %}</div>
<span class="px-3 py-1 rounded-full text-sm font-medium bg-slate-100 text-slate-700">{{ action.get_category_display }}</span>
<p class="text-xs font-semibold text-slate-400 uppercase">{% trans "Category" %}</p>
<p class="text-sm font-bold text-navy mt-1">{{ action.get_category_display }}</p>
</div>
<div>
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Priority" %}</div>
<span class="px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-700">{{ action.get_priority_display }}</span>
<p class="text-xs font-semibold text-slate-400 uppercase">{% trans "Priority" %}</p>
<p class="text-sm font-bold mt-1
{% if action.priority == 'high' %}text-red-600
{% elif action.priority == 'medium' %}text-orange-600
{% else %}text-green-600{% endif %}">{{ action.get_priority_display }}</p>
</div>
<div>
<p class="text-xs font-semibold text-slate-400 uppercase">{% trans "Severity" %}</p>
<p class="text-sm font-bold text-navy mt-1">{{ action.get_severity_display }}</p>
</div>
<div>
<p class="text-xs font-semibold text-slate-400 uppercase">{% trans "Source" %}</p>
<p class="text-sm font-bold text-navy mt-1">{{ action.get_source_type_display }}</p>
</div>
<div>
<p class="text-xs font-semibold text-slate-400 uppercase">{% trans "Created" %}</p>
<p class="text-sm font-bold text-navy mt-1">{{ action.created_at|date:"d M Y, h:i A" }}</p>
</div>
<div>
<p class="text-xs font-semibold text-slate-400 uppercase">{% trans "Requires Approval" %}</p>
<p class="text-sm font-bold mt-1 {% if action.requires_approval %}text-orange-600{% else %}text-slate{% endif %}">
{% if action.requires_approval %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}
</p>
</div>
</div>
<hr class="border-gray-100 mb-6">
<div class="mb-6">
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-2">{% trans "Description" %}</div>
<p class="text-gray-700 leading-relaxed">{{ action.description|linebreaks }}</p>
{% if action.description %}
<div class="py-3 border-t border-slate-100">
<p class="text-xs font-semibold text-slate-400 uppercase mb-2">{% trans "Description" %}</p>
<div class="bg-slate-50 p-4 rounded-xl border-l-4 border-navy">
<p class="text-sm leading-relaxed text-slate-700">{{ action.description|linebreaksbr }}</p>
</div>
</div>
{% endif %}
{% if action.action_plan %}
<hr class="border-gray-100 mb-6">
<div class="mb-6">
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-2">{% trans "Action Plan" %}</div>
<div class="bg-blue-50 border border-blue-100 rounded-xl p-4 text-gray-700">{{ action.action_plan|linebreaks }}</div>
</div>
{% endif %}
{% if action.outcome %}
<hr class="border-gray-100 mb-6">
<div class="mb-6">
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-2">{% trans "Outcome" %}</div>
<div class="bg-green-50 border border-green-100 rounded-xl p-4 text-gray-700">{{ action.outcome|linebreaks }}</div>
</div>
{% endif %}
<hr class="border-gray-100 mb-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Created" %}</div>
<div class="text-gray-700">{{ action.created_at|date:"M d, Y H:i" }}</div>
</div>
<div>
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Last Updated" %}</div>
<div class="text-gray-700">{{ action.updated_at|date:"M d, Y H:i" }}</div>
<div class="py-3 border-t border-slate-100">
<p class="text-xs font-semibold text-slate-400 uppercase mb-2">{% trans "Action Plan" %}</p>
<div class="bg-blue-50 p-4 rounded-xl border border-blue-100">
<p class="text-sm leading-relaxed text-slate-700">{{ action.action_plan|linebreaksbr }}</p>
</div>
</div>
</div>
{% endif %}
<!-- Activity Log Tab -->
<div id="tab-logs" class="tab-content p-6 hidden">
<h3 class="text-lg font-bold text-navy mb-4">{% trans "Activity Log" %}</h3>
{% if action.outcome %}
<div class="py-3 border-t border-slate-100">
<p class="text-xs font-semibold text-slate-400 uppercase mb-2">{% trans "Outcome" %}</p>
<div class="bg-green-50 p-4 rounded-xl border border-green-100">
<p class="text-sm leading-relaxed text-slate-700">{{ action.outcome|linebreaksbr }}</p>
</div>
</div>
{% endif %}
{% if action.content_type.model == 'complaint' and action.source_object %}
<div class="py-3 border-t border-slate-100">
<p class="text-xs font-semibold text-slate-400 uppercase mb-2">{% trans "Related Complaint" %}</p>
<a href="{% url 'complaints:complaint_detail' pk=action.source_object.pk %}" class="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 border border-blue-200 rounded-xl hover:bg-blue-100 transition">
<i data-lucide="link" class="w-4 h-4 text-blue-600"></i>
<div>
<p class="text-sm font-bold text-navy">{{ action.source_object.reference_number }}</p>
<p class="text-xs text-slate-500">{{ action.source_object.title|truncatechars:60 }}</p>
</div>
</a>
</div>
{% endif %}
</section>
</div>
<!-- Activity Tab -->
<div id="panel-activity" class="tab-panel hidden">
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<h3 class="font-bold text-navy mb-4">{% trans "Activity Log" %}</h3>
{% if logs %}
<div class="timeline">
{% for log in logs %}
<div class="timeline-item {{ log.log_type }}">
<div class="bg-slate-50 rounded-xl p-4 border border-slate-100">
<div class="flex justify-between items-start mb-2">
<div class="flex items-center gap-2">
<span class="px-2.5 py-1 rounded-lg text-xs font-semibold bg-navy text-white">{{ log.get_log_type_display }}</span>
{% if log.created_by %}
<span class="text-xs text-slate">by {{ log.created_by.get_full_name }}</span>
{% endif %}
</div>
<span class="text-xs text-slate">{{ log.created_at|date:"M d, Y H:i" }}</span>
<div class="bg-slate-50 rounded-xl p-4">
<div class="flex items-center justify-between mb-2">
<span class="font-bold text-navy text-sm">{{ log.message }}</span>
<span class="text-xs text-slate-400">{{ log.created_at|date:"M d, Y H:i" }}</span>
</div>
<p class="text-gray-700 text-sm">{{ log.message }}</p>
{% if log.old_status and log.new_status %}
<div class="mt-2 flex items-center gap-2">
<span class="px-2 py-0.5 rounded text-xs font-medium bg-slate-100 text-slate-600">{{ log.old_status }}</span>
<i data-lucide="arrow-right" class="w-3 h-3 text-slate"></i>
<span class="px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-600">{{ log.new_status }}</span>
{% if log.created_by %}
<div class="text-xs text-slate-500">
<i data-lucide="user" class="w-3 h-3 inline"></i> {{ log.created_by.get_full_name }}
</div>
{% endif %}
</div>
@ -227,32 +235,54 @@
{% endfor %}
</div>
{% else %}
<div class="text-center py-12">
<i data-lucide="history" class="w-16 h-16 mx-auto mb-4 text-gray-300"></i>
<p class="text-slate">{% trans "No activity log entries yet" %}</p>
<div class="text-center py-8">
<i data-lucide="history" class="w-12 h-12 mx-auto text-slate-300 mb-3"></i>
<p class="text-slate text-sm">{% trans "No activity yet" %}</p>
</div>
{% endif %}
</div>
</section>
</div>
<!-- Evidence Tab -->
<div id="panel-evidence" class="tab-panel hidden">
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<h3 class="font-bold text-navy mb-4">{% trans "Evidence Files" %}</h3>
{% if can_edit and action.status != 'closed' %}
<form method="post" action="{% url 'actions:action_upload_attachment' action.pk %}" enctype="multipart/form-data" class="mb-6 p-4 border-2 border-dashed border-slate-200 rounded-xl">
{% csrf_token %}
<input type="hidden" name="is_evidence" value="on">
<div class="flex flex-wrap items-end gap-3">
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1">{% trans "File" %}</label>
<input type="file" name="file" required class="block w-full text-sm text-slate-500 border border-slate-200 rounded-xl cursor-pointer">
</div>
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1">{% trans "Description (optional)" %}</label>
<input type="text" name="description" class="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm" placeholder="{% trans 'Brief description...' %}">
</div>
<button type="submit" class="px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition text-sm flex items-center gap-2">
<i data-lucide="upload" class="w-4 h-4"></i> {% trans "Upload Evidence" %}
</button>
</div>
</form>
{% endif %}
<!-- Evidence Tab -->
<div id="tab-evidence" class="tab-content p-6 hidden">
<h3 class="text-lg font-bold text-navy mb-4">{% trans "Evidence Files" %}</h3>
{% if evidence_attachments %}
<div class="space-y-3">
{% for evidence in evidence_attachments %}
<div class="flex items-center justify-between p-4 bg-orange-50 border-l-4 border-orange-400 rounded-r-xl hover:translate-x-1 transition">
<div class="flex items-center justify-between p-4 bg-orange-50 border-l-4 border-orange-400 rounded-r-xl">
<div class="flex items-center gap-3">
<i data-lucide="file-check" class="w-5 h-5 text-orange-500"></i>
<div>
<div class="font-semibold text-gray-800">{{ evidence.filename }}</div>
<div class="text-xs text-slate">
{% trans "Uploaded by" %} {{ evidence.uploaded_by.get_full_name }}
<div class="font-semibold text-slate-800">{{ evidence.filename }}</div>
<div class="text-xs text-slate-500">
{% trans "Uploaded by" %} {{ evidence.uploaded_by.get_full_name }}
{% trans "on" %} {{ evidence.created_at|date:"M d, Y H:i" }}
({{ evidence.file_size|filesizeformat }})
</div>
{% if evidence.description %}
<div class="text-sm text-gray-600 mt-1">{{ evidence.description }}</div>
<div class="text-sm text-slate-600 mt-1">{{ evidence.description }}</div>
{% endif %}
</div>
</div>
@ -263,38 +293,60 @@
{% endfor %}
</div>
{% else %}
<div class="text-center py-12">
<i data-lucide="file-check" class="w-16 h-16 mx-auto mb-4 text-gray-300"></i>
<p class="text-slate mb-2">{% trans "No evidence files uploaded yet" %}</p>
<div class="text-center py-8">
<i data-lucide="file-check" class="w-12 h-12 mx-auto text-slate-300 mb-3"></i>
<p class="text-slate text-sm">{% trans "No evidence files uploaded yet" %}</p>
{% if action.requires_approval and action.status != 'closed' %}
<p class="text-orange-500 text-sm">
<i data-lucide="alert-triangle" class="w-4 h-4 inline me-1"></i>
{% trans "Evidence is required before requesting approval" %}
</p>
<p class="text-orange-500 text-sm mt-1">{% trans "Evidence is required before requesting approval" %}</p>
{% endif %}
</div>
{% endif %}
</div>
</section>
</div>
<!-- Attachments Tab -->
<div id="panel-attachments" class="tab-panel hidden">
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<h3 class="font-bold text-navy mb-4">{% trans "All Attachments" %}</h3>
{% if can_edit and action.status != 'closed' %}
<form method="post" action="{% url 'actions:action_upload_attachment' action.pk %}" enctype="multipart/form-data" class="mb-6 p-4 border-2 border-dashed border-slate-200 rounded-xl">
{% csrf_token %}
<div class="flex flex-wrap items-end gap-3">
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1">{% trans "File" %}</label>
<input type="file" name="file" required class="block w-full text-sm text-slate-500 border border-slate-200 rounded-xl cursor-pointer">
</div>
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1">{% trans "Description (optional)" %}</label>
<input type="text" name="description" class="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm" placeholder="{% trans 'Brief description...' %}">
</div>
<label class="flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" name="is_evidence" class="w-4 h-4 accent-navy">
{% trans "Mark as evidence" %}
</label>
<button type="submit" class="px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition text-sm flex items-center gap-2">
<i data-lucide="upload" class="w-4 h-4"></i> {% trans "Upload" %}
</button>
</div>
</form>
{% endif %}
<!-- Attachments Tab -->
<div id="tab-attachments" class="tab-content p-6 hidden">
<h3 class="text-lg font-bold text-navy mb-4">{% trans "All Attachments" %}</h3>
{% if attachments %}
<div class="space-y-3">
{% for attachment in attachments %}
<div class="flex items-center justify-between p-4 bg-slate-50 border border-slate-100 rounded-xl hover:bg-slate-100 transition">
<div class="flex items-center justify-between p-4 bg-slate-50 border border-slate-100 rounded-xl">
<div class="flex items-center gap-3">
<i data-lucide="file" class="w-5 h-5 text-slate"></i>
<div>
<div class="font-semibold text-gray-800">
<div class="font-semibold text-slate-800">
{{ attachment.filename }}
{% if attachment.is_evidence %}
<span class="ms-2 px-2 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-600">{% trans "Evidence" %}</span>
<span class="ml-2 px-2 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-600">{% trans "Evidence" %}</span>
{% endif %}
</div>
<div class="text-xs text-slate">
{% trans "Uploaded by" %} {{ attachment.uploaded_by.get_full_name }}
<div class="text-xs text-slate-500">
{% trans "Uploaded by" %} {{ attachment.uploaded_by.get_full_name }}
{% trans "on" %} {{ attachment.created_at|date:"M d, Y H:i" }}
({{ attachment.file_size|filesizeformat }})
</div>
@ -307,272 +359,197 @@
{% endfor %}
</div>
{% else %}
<div class="text-center py-12">
<i data-lucide="paperclip" class="w-16 h-16 mx-auto mb-4 text-gray-300"></i>
<p class="text-slate">{% trans "No attachments" %}</p>
<div class="text-center py-8">
<i data-lucide="paperclip" class="w-12 h-12 mx-auto text-slate-300 mb-3"></i>
<p class="text-slate text-sm">{% trans "No attachments" %}</p>
</div>
{% endif %}
</div>
</section>
</div>
</div>
<!-- Sidebar -->
<div class="lg:w-80 space-y-4">
<!-- Right Column (Sidebar) -->
<div class="col-span-4 space-y-6">
<!-- Approval Required -->
{% if action.status == 'pending_approval' and can_approve %}
<div class="bg-green-50 border border-green-200 rounded-2xl overflow-hidden">
<div class="px-4 py-3 bg-green-500 text-white">
<h3 class="font-bold flex items-center gap-2">
<i data-lucide="check-circle" class="w-4 h-4"></i> {% trans "Approval Required" %}
</h3>
</div>
<div class="p-4">
<p class="text-sm text-slate mb-3">{% trans "This action is awaiting your approval." %}</p>
<form method="post" action="{% url 'actions:action_approve' action.id %}">
{% csrf_token %}
<button type="submit" class="w-full bg-green-500 text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-green-600 transition flex items-center justify-center gap-2">
<i data-lucide="check-circle" class="w-4 h-4"></i> {% trans "Approve Action" %}
</button>
</form>
</div>
</div>
<section class="bg-green-50 border border-green-200 rounded-2xl p-6">
<h3 class="font-bold text-navy mb-3 flex items-center gap-2">
<i data-lucide="check-circle" class="w-5 h-5 text-green-600"></i> {% trans "Approval Required" %}
</h3>
<p class="text-sm text-slate mb-3">{% trans "This action is awaiting your approval." %}</p>
<form method="post" action="{% url 'actions:action_approve' action.id %}">
{% csrf_token %}
<button type="submit" class="w-full bg-green-500 text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-green-600 transition flex items-center justify-center gap-2">
<i data-lucide="check-circle" class="w-4 h-4"></i> {% trans "Approve Action" %}
</button>
</form>
</section>
{% endif %}
<!-- Quick Actions -->
{% if can_edit %}
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden">
<div class="px-4 py-3 bg-navy text-white">
<h3 class="font-bold flex items-center gap-2">
<i data-lucide="zap" class="w-4 h-4"></i> {% trans "Quick Actions" %}
</h3>
</div>
<div class="p-4 space-y-4">
<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="zap" class="w-4 h-4"></i> {% trans "Quick Actions" %}
</h3>
<div class="space-y-4">
<!-- Assign -->
<form method="post" action="{% url 'actions:action_assign' action.id %}">
{% csrf_token %}
<label class="block text-xs font-semibold text-slate uppercase tracking-wide mb-2">{% trans "Assign To" %}</label>
<label class="block text-xs font-semibold text-slate-400 uppercase mb-2">{% trans "Assign To" %}</label>
<div class="flex gap-2">
<select name="user_id" class="flex-1 px-3 py-2 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent" required>
<select name="user_id" class="flex-1 px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy/20 outline-none" required>
<option value="">{% trans "Select user..." %}</option>
{% for user_obj in assignable_users %}
<option value="{{ user_obj.id }}" {% if action.assigned_to and action.assigned_to.id == user_obj.id %}selected{% endif %}>
{{ user_obj.get_full_name }}
</option>
<option value="{{ user_obj.id }}" {% if action.assigned_to and action.assigned_to.id == user_obj.id %}selected{% endif %}>{{ user_obj.get_full_name }}</option>
{% endfor %}
</select>
<button type="submit" class="px-3 py-2 bg-navy text-white rounded-xl hover:bg-blue transition">
<i data-lucide="user-check" class="w-4 h-4"></i>
</button>
<button type="submit" class="px-3 py-2 bg-navy text-white rounded-xl hover:bg-blue transition"><i data-lucide="user-check" class="w-4 h-4"></i></button>
</div>
</form>
<!-- Change Status -->
<form method="post" action="{% url 'actions:action_change_status' action.id %}">
{% csrf_token %}
<label class="block text-xs font-semibold text-slate uppercase tracking-wide mb-2">{% trans "Change Status" %}</label>
<select name="status" class="w-full px-3 py-2 border border-gray-200 rounded-xl text-sm mb-2 focus:ring-2 focus:ring-navy focus:border-transparent" required>
<label class="block text-xs font-semibold text-slate-400 uppercase mb-2">{% trans "Change Status" %}</label>
<select name="status" class="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm mb-2 focus:ring-2 focus:ring-navy/20 outline-none" required>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if action.status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<textarea name="note" class="w-full px-3 py-2 border border-gray-200 rounded-xl text-sm mb-2 focus:ring-2 focus:ring-navy focus:border-transparent" rows="2" placeholder="{% trans 'Optional note...' %}"></textarea>
<button type="submit" class="w-full bg-navy text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-blue transition flex items-center justify-center gap-2">
<textarea name="note" class="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm mb-2 focus:ring-2 focus:ring-navy/20 outline-none" rows="2" placeholder="{% trans 'Optional note...' %}"></textarea>
<button type="submit" class="w-full bg-navy text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-blue transition flex items-center justify-center gap-2 text-sm">
<i data-lucide="refresh-cw" class="w-4 h-4"></i> {% trans "Update Status" %}
</button>
</form>
<!-- Escalate -->
<button type="button" onclick="document.getElementById('escalateModal').classList.remove('hidden')" class="w-full bg-red-500 text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-red-600 transition flex items-center justify-center gap-2">
<button type="button" onclick="document.getElementById('escalateModal').style.display='flex'" class="w-full bg-red-50 border border-red-200 text-red-600 px-4 py-2.5 rounded-xl font-semibold hover:bg-red-100 transition flex items-center justify-center gap-2 text-sm">
<i data-lucide="arrow-up-circle" class="w-4 h-4"></i> {% trans "Escalate" %}
</button>
</div>
</div>
</section>
{% endif %}
<!-- Convert to QI Project -->
{% if user.is_px_admin or user.is_hospital_admin or user.is_department_manager %}
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden">
<div class="px-4 py-3 bg-blue-500 text-white">
<h3 class="font-bold flex items-center gap-2">
<i data-lucide="kanban" class="w-4 h-4"></i> {% trans "QI Project" %}
</h3>
</div>
<div class="p-4">
{% if action.qi_projects.exists %}
<p class="text-sm text-slate mb-2">{% trans "Linked to:" %}</p>
<ul class="space-y-2 mb-3">
{% for project in action.qi_projects.all %}
<li>
<a href="{% url 'projects:project_detail' pk=project.pk %}" class="flex items-center gap-2 text-navy hover:underline">
<i data-lucide="link" class="w-4 h-4"></i>{{ project.name }}
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-sm text-slate mb-3">
{% trans "Convert this action to a QI project for long-term tracking." %}
</p>
{% endif %}
<a href="{% url 'projects:convert_action' action_pk=action.pk %}"
class="w-full {% if action.qi_projects.exists %}bg-gray-300 text-gray-500 cursor-not-allowed{% else %}bg-blue-500 text-white hover:bg-blue-600{% endif %} px-4 py-2.5 rounded-xl font-semibold transition flex items-center justify-center gap-2">
<i data-lucide="arrow-right-circle" class="w-4 h-4"></i>
{% if action.qi_projects.exists %}{% trans "Already Converted" %}{% else %}{% trans "Convert to Project" %}{% endif %}
</a>
</div>
</div>
{% endif %}
<!-- Add Note -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden">
<div class="px-4 py-3 bg-green-500 text-white">
<h3 class="font-bold flex items-center gap-2">
<i data-lucide="message-square" class="w-4 h-4"></i> {% trans "Add Note" %}
</h3>
</div>
<div class="p-4">
<form method="post" action="{% url 'actions:action_add_note' action.id %}">
{% csrf_token %}
<textarea name="note" class="w-full px-3 py-2 border border-gray-200 rounded-xl text-sm mb-2 focus:ring-2 focus:ring-navy focus:border-transparent" rows="3" placeholder="{% trans 'Enter your note...' %}" required></textarea>
<button type="submit" class="w-full bg-green-500 text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-green-600 transition flex items-center justify-center gap-2">
<i data-lucide="plus-circle" class="w-4 h-4"></i> {% trans "Add Note" %}
</button>
</form>
</div>
</div>
<!-- Assignment Info -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden">
<div class="px-4 py-3 border-b border-gray-100">
<h3 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="info" class="w-4 h-4"></i> {% trans "Assignment Info" %}
</h3>
</div>
<div class="p-4 space-y-4">
<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="info" class="w-4 h-4"></i> {% trans "Assignment Info" %}
</h3>
<div class="space-y-3 text-sm">
<div>
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Assigned To" %}</div>
<p class="text-xs font-semibold text-slate-400 uppercase mb-1">{% trans "Assigned To" %}</p>
{% if action.assigned_to %}
<div class="font-medium text-gray-800">{{ action.assigned_to.get_full_name }}</div>
<div class="text-xs text-slate">{% trans "Assigned" %} {{ action.assigned_at|date:"M d, Y H:i" }}</div>
<div class="flex items-center gap-2">
<span class="w-7 h-7 rounded-full bg-navy text-white text-[10px] font-bold flex items-center justify-center">{{ action.assigned_to.first_name|first }}{{ action.assigned_to.last_name|first }}</span>
<div>
<p class="font-bold text-navy">{{ action.assigned_to.get_full_name }}</p>
<p class="text-xs text-slate-500">{% trans "Assigned" %} {{ action.assigned_at|date:"M d, Y" }}</p>
</div>
</div>
{% else %}
<div class="text-slate">{% trans "Unassigned" %}</div>
<p class="text-slate italic">{% trans "Unassigned" %}</p>
{% endif %}
</div>
{% if action.approved_by %}
<div>
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Approved By" %}</div>
<div class="font-medium text-gray-800">{{ action.approved_by.get_full_name }}</div>
<div class="text-xs text-slate">{{ action.approved_at|date:"M d, Y H:i" }}</div>
<div class="pt-3 border-t border-slate-100">
<p class="text-xs font-semibold text-slate-400 uppercase mb-1">{% trans "Approved By" %}</p>
<p class="font-bold text-navy">{{ action.approved_by.get_full_name }}</p>
<p class="text-xs text-slate-500">{{ action.approved_at|date:"M d, Y H:i" }}</p>
</div>
{% endif %}
{% if action.closed_by %}
<div>
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Closed By" %}</div>
<div class="font-medium text-gray-800">{{ action.closed_by.get_full_name }}</div>
<div class="text-xs text-slate">{{ action.closed_at|date:"M d, Y H:i" }}</div>
<div class="pt-3 border-t border-slate-100">
<p class="text-xs font-semibold text-slate-400 uppercase mb-1">{% trans "Closed By" %}</p>
<p class="font-bold text-navy">{{ action.closed_by.get_full_name }}</p>
<p class="text-xs text-slate-500">{{ action.closed_at|date:"M d, Y H:i" }}</p>
</div>
{% endif %}
</div>
</div>
<!-- SLA Info -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden">
<div class="px-4 py-3 bg-orange-500 text-white">
<h3 class="font-bold flex items-center gap-2">
<i data-lucide="clock" class="w-4 h-4"></i> {% trans "SLA Information" %}
</h3>
</div>
<div class="p-4 space-y-3">
<div>
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Due Date" %}</div>
<div class="{% if action.is_overdue %}text-red-600 font-bold{% else %}text-gray-800{% endif %}">
{{ action.due_at|date:"M d, Y H:i" }}
</div>
</div>
{% if action.escalated_at %}
<div>
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Escalated" %}</div>
<div class="flex items-center gap-2">
{{ action.escalated_at|date:"M d, Y H:i" }}
<span class="px-2 py-0.5 rounded text-xs font-bold bg-red-100 text-red-600">Level {{ action.escalation_level }}</span>
</div>
</div>
{% endif %}
{% if action.reminder_sent_at %}
<div>
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Reminder Sent" %}</div>
<div class="text-gray-800">{{ action.reminder_sent_at|date:"M d, Y H:i" }}</div>
</div>
{% endif %}
</div>
</div>
</section>
<!-- QI Project -->
{% if user.is_px_admin or user.is_hospital_admin or user.is_department_manager %}
<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="kanban" class="w-4 h-4"></i> {% trans "QI Project" %}
</h3>
{% if action.qi_projects.exists %}
<ul class="space-y-2 mb-3">
{% for project in action.qi_projects.all %}
<li><a href="{% url 'projects:project_detail' pk=project.pk %}" class="flex items-center gap-2 text-navy hover:underline text-sm"><i data-lucide="link" class="w-4 h-4"></i>{{ project.name }}</a></li>
{% endfor %}
</ul>
{% else %}
<p class="text-sm text-slate-500 mb-3">{% trans "Convert this action to a QI project for long-term tracking." %}</p>
{% endif %}
<a href="{% url 'projects:convert_action' action_pk=action.pk %}" class="w-full {% if action.qi_projects.exists %}bg-slate-200 text-slate-400 cursor-not-allowed{% else %}bg-blue-50 border border-blue-200 text-blue-700 hover:bg-blue-100{% endif %} px-4 py-2.5 rounded-xl font-semibold transition flex items-center justify-center gap-2 text-sm">
<i data-lucide="arrow-right-circle" class="w-4 h-4"></i>
{% if action.qi_projects.exists %}{% trans "Already Converted" %}{% else %}{% trans "Convert to Project" %}{% endif %}
</a>
</section>
{% endif %}
<!-- Add Note -->
<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="message-square" class="w-4 h-4"></i> {% trans "Add Note" %}
</h3>
<form method="post" action="{% url 'actions:action_add_note' action.id %}">
{% csrf_token %}
<textarea name="note" class="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm mb-2 focus:ring-2 focus:ring-navy/20 outline-none" rows="3" placeholder="{% trans 'Enter your note...' %}" required></textarea>
<button type="submit" class="w-full bg-navy text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-blue transition flex items-center justify-center gap-2 text-sm">
<i data-lucide="plus-circle" class="w-4 h-4"></i> {% trans "Add Note" %}
</button>
</form>
</section>
</div>
</div>
</main>
<!-- Escalate Modal -->
<div id="escalateModal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-xl max-w-md w-full">
<div id="escalateModal" 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 max-w-md w-full">
<form method="post" action="{% url 'actions:action_escalate' action.id %}">
{% csrf_token %}
<div class="p-4 border-b border-gray-100">
<h3 class="text-lg font-bold text-navy">{% trans "Escalate Action" %}</h3>
<div class="p-5 border-b border-slate-100">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center"><i data-lucide="arrow-up-circle" class="w-5 h-5 text-red-500"></i></div>
<h3 class="text-xl font-bold text-navy">{% trans "Escalate Action" %}</h3>
</div>
</div>
<div class="p-4">
<div class="p-5">
<div class="bg-orange-50 border border-orange-200 rounded-xl p-3 mb-4 text-sm text-orange-700">
<i data-lucide="alert-triangle" class="w-4 h-4 inline me-2"></i>
<i data-lucide="alert-triangle" class="w-4 h-4 inline mr-2"></i>
{% trans "This will escalate the action to the next level." %}
{% if action.escalation_level > 0 %}
{% trans "Current level:" %} {{ action.escalation_level }}
{% endif %}
</div>
<div>
<label class="block text-xs font-semibold text-slate uppercase tracking-wide mb-2">{% trans "Reason for Escalation" %}</label>
<textarea name="reason" class="w-full px-3 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-navy focus:border-transparent" rows="3" placeholder="{% trans 'Explain why this action needs escalation...' %}" required></textarea>
{% if action.escalation_level > 0 %}({% trans "Current level:" %} {{ action.escalation_level }}){% endif %}
</div>
<label class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Reason for Escalation" %} <span class="text-red-500">*</span></label>
<textarea name="reason" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" rows="3" placeholder="{% trans 'Explain why this action needs escalation...' %}" required></textarea>
</div>
<div class="p-4 border-t border-gray-100 flex justify-end gap-3">
<button type="button" onclick="document.getElementById('escalateModal').classList.add('hidden')" class="px-4 py-2 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition">
{% trans "Cancel" %}
</button>
<button type="submit" class="px-4 py-2 bg-red-500 text-white rounded-xl font-semibold hover:bg-red-600 transition flex items-center gap-2">
<i data-lucide="arrow-up-circle" class="w-4 h-4"></i> {% trans "Escalate" %}
</button>
<div class="p-5 border-t border-slate-100 flex gap-3">
<button type="button" onclick="document.getElementById('escalateModal').style.display='none'" 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" class="flex-1 px-4 py-2.5 bg-red-500 text-white rounded-xl font-semibold hover:bg-red-600 transition flex items-center justify-center gap-2"><i data-lucide="arrow-up-circle" class="w-4 h-4"></i> {% trans "Escalate" %}</button>
</div>
</form>
</div>
</div>
<!-- Tab Switching -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Tab switching
const tabBtns = document.querySelectorAll('.tab-btn');
tabBtns.forEach(btn => {
btn.addEventListener('click', function() {
// Update button styles
tabBtns.forEach(b => {
b.classList.remove('bg-navy', 'text-white');
b.classList.add('text-slate', 'hover:bg-gray-100');
});
this.classList.add('bg-navy', 'text-white');
this.classList.remove('text-slate', 'hover:bg-gray-100');
// Show/hide content
const tabId = this.dataset.tab;
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden');
});
document.getElementById('tab-' + tabId).classList.remove('hidden');
});
function switchTab(tabName) {
document.querySelectorAll('.tab-panel').forEach(panel => { panel.classList.add('hidden'); });
document.getElementById('panel-' + tabName).classList.remove('hidden');
document.querySelectorAll('main nav button').forEach(tab => {
tab.classList.remove('tab-active');
tab.classList.add('tab-inactive');
});
// Initialize first tab as active
const firstTab = document.querySelector('.tab-btn');
if (firstTab) {
firstTab.classList.add('bg-navy', 'text-white');
firstTab.classList.remove('text-slate', 'hover:bg-gray-100');
}
});
var activeBtn = document.getElementById('tab-' + tabName);
if (activeBtn) { activeBtn.classList.remove('tab-inactive'); activeBtn.classList.add('tab-active'); }
if (window.lucide) lucide.createIcons();
}
</script>
{% endblock %}
{% endblock %}

View File

@ -165,22 +165,35 @@
{% 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.taxonomy_reviewed %}bg-green-500{% else %}bg-slate-200{% endif %}"></div>
<!-- Step 3: Review Taxonomy -->
<div class="flex flex-col items-center flex-shrink-0">
{% if workflow_steps.taxonomy_reviewed %}
<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="clipboard-check" 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="clipboard-check" class="w-5 h-5"></i></div>
{% endif %}
<span class="text-xs font-bold mt-2 {% if workflow_steps.taxonomy_reviewed %}text-slate{% elif workflow_steps.activated %}text-navy{% else %}text-slate-300{% endif %}">{% trans "Taxonomy" %}</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 -->
<!-- Step 4: 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 %}
{% elif workflow_steps.taxonomy_reviewed %}
<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>
<span class="text-xs font-bold mt-2 {% if workflow_steps.sent_to_department %}text-slate{% elif workflow_steps.taxonomy_reviewed %}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 -->
<!-- Step 5: 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>
@ -193,7 +206,7 @@
</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 -->
<!-- Step 6: 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>
@ -218,6 +231,34 @@
<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.taxonomy_reviewed %}
<!-- Review Taxonomy callout (gate before Send to Department) -->
<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 min-w-0">
<div class="w-9 h-9 rounded-full bg-amber-100 flex items-center justify-center shrink-0"><i data-lucide="clipboard-check" class="w-4 h-4 text-amber-600"></i></div>
<div class="min-w-0">
<p class="text-sm font-bold text-amber-800">{% trans "Review taxonomy" %}</p>
<p class="text-xs text-amber-700">{% trans "Verify the AI classification before sending to the department." %}</p>
<div class="flex items-center gap-1 mt-1.5 flex-wrap text-xs font-medium text-amber-900">
<span>{{ complaint.domain.get_localized_name|default:"—" }}</span>
{% if complaint.category %}<i data-lucide="chevron-right" class="w-3 h-3"></i><span>{{ complaint.category.get_localized_name }}</span>{% endif %}
{% if complaint.subcategory_obj %}<i data-lucide="chevron-right" class="w-3 h-3"></i><span>{{ complaint.subcategory_obj.get_localized_name }}</span>{% endif %}
{% if complaint.classification_obj %}<i data-lucide="chevron-right" class="w-3 h-3"></i><span>{{ complaint.classification_obj.get_localized_name }}</span>{% endif %}
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<button type="button" onclick="openTaxonomyModal()" class="px-3 py-2 text-sm border-2 border-amber-300 text-amber-800 rounded-lg font-bold hover:bg-amber-100 transition">
<i data-lucide="pencil" class="w-3.5 h-3.5 inline"></i> {% trans "Edit" %}
</button>
<form method="post" action="{% url 'complaints:confirm_taxonomy' pk=complaint.pk %}" class="inline">
{% csrf_token %}
<button type="submit" class="px-4 py-2 bg-amber-600 text-white rounded-lg font-bold text-sm hover:bg-amber-700 transition whitespace-nowrap">
{% trans "Confirm" %}
</button>
</form>
</div>
</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">
@ -227,15 +268,50 @@
<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>
<!-- No department has responded yet -->
<div class="mt-5 p-4 bg-amber-50 rounded-xl border border-amber-200">
<div class="flex items-center gap-3 mb-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>
{% if dept_response_progress.total > 0 %}
<p class="text-xs text-amber-700">{% blocktrans with responded=dept_response_progress.responded total=dept_response_progress.total %}{{ responded }} of {{ total }} departments responded{% endblocktrans %}</p>
{% else %}
<p class="text-xs text-amber-700">{% trans "The department champion and manager have been notified." %}</p>
{% endif %}
</div>
</div>
{% if dept_response_progress.pending %}
<div class="space-y-1 ml-12">
{% for dept_name in dept_response_progress.pending %}
<p class="text-xs text-amber-600 flex items-center gap-1.5"><i data-lucide="clock" class="w-3 h-3"></i> {{ dept_name }}</p>
{% endfor %}
</div>
{% endif %}
<div class="mt-3 flex items-center justify-end gap-2 {% if not dept_response_progress.pending %}ml-12{% endif %}">
<span class="text-xs text-amber-600">{% trans "Don't want to wait?" %}</span>
<button type="button" onclick="switchTab('resolution')"
class="px-4 py-2 bg-amber-600 text-white rounded-lg font-bold text-sm hover:bg-amber-700 transition">
{% trans "Proceed to Resolve" %}
</button>
</div>
</div>
{% elif not workflow_steps.resolved %}
<!-- At least one dept responded — show resolve + any pending -->
<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>
<p class="text-sm font-bold text-green-800">{% trans "Resolve this complaint" %}</p>
<p class="text-xs text-green-700">
{% if dept_response_progress.total > 0 and dept_response_progress.responded < dept_response_progress.total %}
{% blocktrans with responded=dept_response_progress.responded total=dept_response_progress.total %}{{ responded }} of {{ total }} departments responded.{% endblocktrans %}
{% if dept_response_progress.pending %} {% trans "Still waiting for:" %} {{ dept_response_progress.pending|join:", " }}{% endif %}
{% else %}
{% trans "The department has responded. Review and close the complaint." %}
{% endif %}
</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>
@ -282,6 +358,16 @@
</div>
</div>
{% else %}
{% if complaint.satisfaction_window_expired %}
<!-- Satisfaction window expired (5+ days) -->
<div class="mt-5 p-4 bg-slate-50 rounded-xl border border-slate-200 flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center shrink-0"><i data-lucide="clock-alert" class="w-4 h-4 text-slate-400"></i></div>
<div>
<p class="text-sm font-bold text-slate-600">{% trans "Satisfaction window expired" %}</p>
<p class="text-xs text-slate-400">{% trans "Satisfaction can only be set within 5 days of resolution." %}</p>
</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">
@ -294,6 +380,7 @@
<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 %}
{% endif %}
</div>
{% endif %}
@ -474,6 +561,16 @@
{% 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 %}
{% if complaint.classification_obj %}<i data-lucide="chevron-right" class="w-3 h-3 text-slate-300"></i><span class="text-sm text-navy font-medium">{{ complaint.classification_obj.get_localized_name }}</span>{% endif %}
{% if can_edit %}
<button type="button" onclick="openTaxonomyModal()" class="ml-1 p-1 text-slate/60 hover:text-navy transition" title="{% trans 'Edit Taxonomy' %}">
<i data-lucide="pencil" class="w-3.5 h-3.5"></i>
</button>
{% endif %}
{% if complaint.taxonomy_reviewed_at %}
<span class="ml-1 px-1.5 py-0.5 rounded text-[10px] font-bold bg-green-100 text-green-700 flex items-center gap-1">
<i data-lucide="check-circle" class="w-3 h-3"></i> {% trans "Reviewed" %}
</span>
{% endif %}
</div>
{% endif %}
@ -595,7 +692,9 @@
{% 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 %}
{% if dept.routing_status == 'rejected' %}
<p class="text-[10px] text-red-500 mt-0.5 italic">{% trans "Rejected:" %} {{ dept.rejection_reason|truncatechars:80 }}{% if dept.suggested_department %} &middot; {% trans "Suggested:" %} {{ dept.suggested_department.get_localized_name }}{% endif %}</p>
{% elif 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>
@ -605,11 +704,13 @@
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] font-bold
{% if not dept.response_submitted %}text-slate-400
{% if dept.routing_status == 'rejected' %}text-red-600
{% elif 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" %}
{% if dept.routing_status == 'rejected' %}{% trans "Wrong Department" %}
{% elif 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 %}
@ -669,6 +770,11 @@
<i data-lucide="file-text" class="w-4 h-4"></i>
{% trans "PDF" %}
</a>
<a href="{% url 'complaints:complaint_pdf_v2' pk=complaint.id %}" target="_blank"
class="px-3 py-2.5 border border-blue-200 bg-blue-50 rounded-xl text-blue-700 hover:border-navy hover:text-navy transition flex items-center gap-2 text-sm font-semibold">
<i data-lucide="file-image" class="w-4 h-4"></i>
{% trans "PDF (Letterhead)" %}
</a>
</div>
{% endif %}
@ -750,18 +856,25 @@
<i data-lucide="clock" class="w-5 h-5 text-slate group-hover:text-orange-500"></i>
<span class="text-[10px] font-bold uppercase">{% trans "Follow Up" %}</span>
</button>
{% if complaint.escalated_at %}
<button disabled class="p-3 border-red-200 bg-red-100 rounded-xl flex flex-col items-center gap-2 transition cursor-not-allowed opacity-70">
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-500"></i>
<span class="text-[10px] font-bold text-red-600 uppercase">{% trans "Escalated" %}</span>
</button>
{% else %}
<button onclick="showEscalateModal()" class="p-3 border-red-100 bg-red-50 rounded-xl hover:bg-red-100 flex flex-col items-center gap-2 group transition">
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-500"></i>
<span class="text-[10px] font-bold text-red-600 uppercase">{% trans "Escalate" %}</span>
</button>
{% endif %}
<form method="post" action="{% url 'complaints:toggle_escalated_ovr' pk=complaint.pk %}" class="contents">
{% csrf_token %}
<button type="submit" name="is_escalated_ovr" value="{% if not complaint.is_escalated_ovr %}on{% endif %}"
class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition
{% if complaint.is_escalated_ovr %}border-orange-200 bg-orange-50{% endif %}">
<i data-lucide="shield-alert" class="w-5 h-5 {% if complaint.is_escalated_ovr %}text-orange-600{% else %}text-slate group-hover:text-orange-500{% endif %}"></i>
{% if complaint.is_escalated_ovr %}border-orange-300 bg-orange-100 ring-2 ring-orange-200{% endif %}">
<i data-lucide="{% if complaint.is_escalated_ovr %}shield-check{% else %}shield-alert{% endif %}" class="w-5 h-5 {% if complaint.is_escalated_ovr %}text-orange-600{% else %}text-slate group-hover:text-orange-500{% endif %}"></i>
<span class="text-[10px] font-bold uppercase {% if complaint.is_escalated_ovr %}text-orange-700{% endif %}">
{% if complaint.is_escalated_ovr %}{% trans "Remove OVR" %}{% else %}{% trans "OVR Escalate" %}{% endif %}
{% if complaint.is_escalated_ovr %}{% trans "OVR Active" %}{% else %}{% trans "OVR Escalate" %}{% endif %}
</span>
</button>
</form>
@ -939,14 +1052,10 @@
<option value="">{% trans "Select Person" %}</option>
{% regroup escalation_targets by group as target_groups %}
{% for group in target_groups %}
{% if group.grouper == 'department_roles' %}
<optgroup label="{% trans 'Department Roles' %}">
{% else %}
<optgroup label="{% trans 'Managers & Admins' %}">
{% endif %}
<optgroup label="{{ group.grouper }}">
{% for target in group.list %}
<option value="{{ target.staff.id }}" {% if target.staff.id|stringformat:"s" == default_escalation_target %}selected{% endif %}>
{{ target.staff.get_full_name }}{% if target.role_label %} ({{ target.role_label }}){% elif target.is_line_manager %} ({% trans "Line Manager" %}){% elif target.is_manager %} ({% trans "Manager" %}){% endif %}
{{ target.staff.get_full_name }} ({{ target.role_label }})
</option>
{% endfor %}
</optgroup>
@ -1153,6 +1262,54 @@
</div>
</div>
<!-- Taxonomy Edit Modal -->
<div id="taxonomyModal" 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 p-6 w-full max-w-lg shadow-2xl">
<h3 class="text-lg font-bold text-navy mb-4 flex items-center gap-2">
<i data-lucide="clipboard-check" class="w-5 h-5"></i>
{% trans "Edit Taxonomy" %}
</h3>
<div class="space-y-3">
<div>
<label class="block text-xs font-bold text-slate/60 uppercase mb-1">{% trans "Domain (Level 1)" %}</label>
<select id="taxDomain" class="w-full border-2 border-slate-200 rounded-xl p-2.5 text-sm focus:outline-none focus:border-navy">
<option value="">{% trans "Loading..." %}</option>
</select>
</div>
<div>
<label class="block text-xs font-bold text-slate/60 uppercase mb-1">{% trans "Category (Level 2)" %}</label>
<select id="taxCategory" class="w-full border-2 border-slate-200 rounded-xl p-2.5 text-sm focus:outline-none focus:border-navy" disabled>
<option value="">{% trans "Select domain first" %}</option>
</select>
</div>
<div>
<label class="block text-xs font-bold text-slate/60 uppercase mb-1">{% trans "Subcategory (Level 3)" %}</label>
<select id="taxSubcategory" class="w-full border-2 border-slate-200 rounded-xl p-2.5 text-sm focus:outline-none focus:border-navy" disabled>
<option value="">{% trans "Select category first" %}</option>
</select>
</div>
<div>
<label class="block text-xs font-bold text-slate/60 uppercase mb-1">{% trans "Classification (Level 4)" %}</label>
<select id="taxClassification" class="w-full border-2 border-slate-200 rounded-xl p-2.5 text-sm focus:outline-none focus:border-navy" disabled>
<option value="">{% trans "Select subcategory first" %}</option>
</select>
</div>
</div>
<div id="taxonomyError" class="hidden mt-3 text-sm text-red-600 font-semibold"></div>
<div class="flex gap-2 mt-5">
<button type="button" onclick="closeTaxonomyModal()" class="flex-1 px-4 py-2 border-2 border-slate-200 text-slate-700 rounded-xl font-bold text-sm hover:bg-slate-50 transition">
{% trans "Cancel" %}
</button>
<button type="button" onclick="saveTaxonomy()" class="flex-1 px-4 py-2 bg-navy text-white rounded-xl font-bold text-sm hover:bg-blue transition">
{% trans "Save Changes" %}
</button>
</div>
</div>
</div>
<!-- Tab Switching JavaScript -->
<script>
function showActivationRequired() {
@ -1232,6 +1389,171 @@ window.onclick = function(event) {
}
};
// ─── Taxonomy Edit Modal (cascading dropdowns) ───
var taxonomyData = null;
var TAX_LANG = document.documentElement.lang || 'en';
function openTaxonomyModal() {
var m = document.getElementById('taxonomyModal');
if (!m) return;
m.style.display = 'flex';
if (!taxonomyData) loadTaxonomyOptions();
if (window.lucide) lucide.createIcons();
}
function closeTaxonomyModal() {
var m = document.getElementById('taxonomyModal');
if (m) m.style.display = 'none';
var err = document.getElementById('taxonomyError');
if (err) { err.classList.add('hidden'); err.textContent = ''; }
}
function loadTaxonomyOptions() {
fetch('{% url "complaints:complaint-api-taxonomy-options" pk=complaint.pk %}', {credentials: 'same-origin'})
.then(function(r) { return r.json(); })
.then(function(data) {
taxonomyData = data.hierarchy || data.domains || data;
populateTaxDomain();
})
.catch(function(err) {
var errEl = document.getElementById('taxonomyError');
if (errEl) { errEl.textContent = 'Failed to load taxonomy: ' + err.message; errEl.classList.remove('hidden'); }
});
}
function populateTaxDomain() {
var sel = document.getElementById('taxDomain');
sel.innerHTML = '<option value="">{% trans "Select domain" %}</option>';
if (!Array.isArray(taxonomyData)) return;
taxonomyData.forEach(function(d) {
var opt = document.createElement('option');
opt.value = d.id;
opt.textContent = (TAX_LANG === 'ar' && d.name_ar) ? d.name_ar : (d.name_en || d.name || d.code);
if (d.is_selected) opt.selected = true;
sel.appendChild(opt);
});
cascadeTaxCategory();
}
function cascadeTaxCategory() {
var domainId = document.getElementById('taxDomain').value;
var sel = document.getElementById('taxCategory');
sel.innerHTML = '<option value="">{% trans "Select category" %}</option>';
sel.disabled = !domainId;
if (!domainId) return;
var domain = (taxonomyData || []).find(function(d) { return d.id === domainId; });
var categories = (domain && domain.categories) || [];
categories.forEach(function(c) {
var opt = document.createElement('option');
opt.value = c.id;
opt.textContent = (TAX_LANG === 'ar' && c.name_ar) ? c.name_ar : (c.name_en || c.name || c.code);
if (c.is_selected) opt.selected = true;
sel.appendChild(opt);
});
cascadeTaxSubcategory();
}
function cascadeTaxSubcategory() {
var domainId = document.getElementById('taxDomain').value;
var categoryId = document.getElementById('taxCategory').value;
var sel = document.getElementById('taxSubcategory');
sel.innerHTML = '<option value="">{% trans "Select subcategory" %}</option>';
sel.disabled = !categoryId;
if (!categoryId) return;
var domain = (taxonomyData || []).find(function(d) { return d.id === domainId; });
var category = ((domain && domain.categories) || []).find(function(c) { return c.id === categoryId; });
var subcategories = (category && category.subcategories) || [];
subcategories.forEach(function(s) {
var opt = document.createElement('option');
opt.value = s.id;
opt.textContent = (TAX_LANG === 'ar' && s.name_ar) ? s.name_ar : (s.name_en || s.name || s.code);
if (s.is_selected) opt.selected = true;
sel.appendChild(opt);
});
cascadeTaxClassification();
}
function cascadeTaxClassification() {
var domainId = document.getElementById('taxDomain').value;
var categoryId = document.getElementById('taxCategory').value;
var subcategoryId = document.getElementById('taxSubcategory').value;
var sel = document.getElementById('taxClassification');
sel.innerHTML = '<option value="">{% trans "Select classification" %}</option>';
sel.disabled = !subcategoryId;
if (!subcategoryId) return;
var domain = (taxonomyData || []).find(function(d) { return d.id === domainId; });
var category = ((domain && domain.categories) || []).find(function(c) { return c.id === categoryId; });
var subcategory = ((category && category.subcategories) || []).find(function(s) { return s.id === subcategoryId; });
var classifications = (subcategory && subcategory.classifications) || [];
classifications.forEach(function(c) {
var opt = document.createElement('option');
opt.value = c.id;
opt.textContent = (TAX_LANG === 'ar' && c.name_ar) ? c.name_ar : (c.name_en || c.name || c.code);
if (c.is_selected) opt.selected = true;
sel.appendChild(opt);
});
}
// Wire up cascading change handlers
// Wire up cascading change handlers (directly — script is at bottom of <body>)
var taxD = document.getElementById('taxDomain');
if (taxD) taxD.addEventListener('change', cascadeTaxCategory);
var taxC = document.getElementById('taxCategory');
if (taxC) taxC.addEventListener('change', cascadeTaxSubcategory);
var taxS = document.getElementById('taxSubcategory');
if (taxS) taxS.addEventListener('change', cascadeTaxClassification);
function saveTaxonomy() {
var errEl = document.getElementById('taxonomyError');
if (errEl) { errEl.classList.add('hidden'); errEl.textContent = ''; }
var domainId = document.getElementById('taxDomain').value;
var categoryId = document.getElementById('taxCategory').value;
var subcategoryId = document.getElementById('taxSubcategory').value;
var classificationId = document.getElementById('taxClassification').value;
if (!domainId) {
if (errEl) { errEl.textContent = '{% trans "Please select at least a domain." %}'; errEl.classList.remove('hidden'); }
return;
}
var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
if (!csrfToken) {
var csrfInput = document.querySelector('#taxonomyModal input[name=csrfmiddlewaretoken]');
if (csrfInput) csrfToken = csrfInput.value;
else csrfToken = '';
} else {
csrfToken = csrfToken.value;
}
var body = new URLSearchParams();
body.append('domain_id', domainId);
if (categoryId) body.append('category_id', categoryId);
if (subcategoryId) body.append('subcategory_id', subcategoryId);
if (classificationId) body.append('classification_id', classificationId);
fetch('{% url "complaints:complaint-api-update-taxonomy" pk=complaint.pk %}', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken,
},
body: body.toString(),
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) throw new Error(data.error);
location.reload();
})
.catch(function(err) {
if (errEl) { errEl.textContent = err.message; errEl.classList.remove('hidden'); }
});
}
// ─── Send to Department modal (department + staff selects) ───
function openSendToDeptModal() {
var m = document.getElementById('sendToDeptModal');

View File

@ -595,7 +595,9 @@
{% 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 %}
{% if dept.routing_status == 'rejected' %}
<p class="text-[10px] text-red-500 mt-0.5 italic">{% trans "Rejected:" %} {{ dept.rejection_reason|truncatechars:80 }}{% if dept.suggested_department %} &middot; {% trans "Suggested:" %} {{ dept.suggested_department.get_localized_name }}{% endif %}</p>
{% elif 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>
@ -605,11 +607,13 @@
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] font-bold
{% if not dept.response_submitted %}text-slate-400
{% if dept.routing_status == 'rejected' %}text-red-600
{% elif 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" %}
{% if dept.routing_status == 'rejected' %}{% trans "Wrong Department" %}
{% elif 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 %}

View File

@ -0,0 +1,243 @@
{% load i18n %}
<!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;
min-height: 297mm;
position: relative;
overflow: hidden;
page-break-after: always;
background-image: url('{{ letterhead_path }}');
background-size: 210mm 297mm;
background-repeat: no-repeat;
background-position: top left;
}
.page:last-child { page-break-after: auto; }
.stamp {
position: fixed;
bottom: 18mm;
right: 20mm;
width: 40mm;
height: auto;
z-index: 100;
}
.form-body {
padding: 38mm 20mm 35mm 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-top: 12px;
}
.data-table th {
background: var(--surface);
color: var(--blue);
font-size: 12px;
padding: 6px 12px;
border-right: 3px solid var(--blue);
border: 1px solid var(--border);
text-align: right;
}
.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;
}
.section-header {
background: var(--surface);
color: var(--blue);
font-size: 12px;
font-weight: 700;
padding: 6px 12px;
border-right: 3px solid var(--blue);
border: 1px solid var(--border);
border-bottom: none;
margin-top: 8mm;
}
</style>
</head>
<body>
<div class="page">
<div class="form-body">
<div class="form-title">تقرير الشكوى</div>
<!-- Complaint Info -->
<table class="data-table">
<tr>
<td class="lbl">رقم الشكوى</td>
<td class="val">{{ complaint.reference_number }}</td>
<td class="lbl">الحالة</td>
<td class="val">{{ complaint.get_status_display }}</td>
</tr>
<tr>
<td class="lbl">العنوان</td>
<td class="val">{{ complaint.title }}</td>
<td class="lbl">نوع الشكوى</td>
<td class="val">{{ complaint.get_complaint_type_display }}</td>
</tr>
<tr>
<td class="lbl">القسم</td>
<td class="val">{% if complaint.department %}{{ complaint.department.name_ar|default:complaint.department.name_en }}{% else %}—{% endif %}</td>
<td class="lbl">الخطورة</td>
<td class="val">{{ complaint.get_severity_display }}</td>
</tr>
<tr>
<td class="lbl">الموظف المشكو عليه</td>
<td class="val">{% if complaint.staff %}{{ complaint.staff.get_full_name }}{% else %}—{% endif %}</td>
<td class="lbl">جهة الادعاء</td>
<td class="val">{% if complaint.source %}{{ complaint.source.name_ar|default:complaint.source.name_en }}{% else %}—{% endif %}</td>
</tr>
<tr>
<td class="lbl">تاريخ التقديم</td>
<td class="val">{{ complaint.created_at|date:"Y/m/d H:i" }}</td>
<td class="lbl">تاريخ الحادثة</td>
<td class="val">{% if complaint.incident_date %}{{ complaint.incident_date|date:"Y/m/d" }}{% else %}—{% endif %}</td>
</tr>
</table>
<!-- Description -->
<div class="section-header">مختصر الشكوى</div>
<table class="data-table">
<tr>
<td colspan="4" class="text-cell">{{ complaint.description }}</td>
</tr>
</table>
<!-- Patient Info (if available) -->
{% if complaint.patient %}
<div class="section-header">بيانات المريض</div>
<table class="data-table">
<tr>
<td class="lbl">اسم المريض</td>
<td class="val">{{ complaint.patient.get_full_name }}</td>
<td class="lbl">رقم الملف</td>
<td class="val">{{ complaint.patient.mrn|default:"—" }}</td>
</tr>
</table>
{% endif %}
<!-- Contact Info -->
{% if complaint.contact_name or complaint.contact_phone %}
<div class="section-header">بيانات مقدم الشكوى</div>
<table class="data-table">
<tr>
<td class="lbl">الاسم</td>
<td class="val">{{ complaint.contact_name|default:"—" }}</td>
<td class="lbl">الهاتف</td>
<td class="val">{{ complaint.contact_phone|default:"—" }}</td>
</tr>
</table>
{% endif %}
<!-- Department Responses -->
{% if complaint.involved_departments.exists %}
<div class="section-header">ردود الأقسام</div>
{% for dept in complaint.involved_departments.all %}
<table class="data-table">
<tr>
<td class="lbl">القسم</td>
<td class="val">{{ dept.department.name_ar|default:dept.department.name_en }}</td>
<td class="lbl">تاريخ الرد</td>
<td class="val">{% if dept.response_submitted_at %}{{ dept.response_submitted_at|date:"Y/m/d H:i" }}{% else %}—{% endif %}</td>
</tr>
{% if dept.response_notes %}
<tr>
<td colspan="4" class="text-cell">{{ dept.response_notes }}</td>
</tr>
{% endif %}
</table>
{% endfor %}
{% endif %}
<!-- Explanations -->
{% if explanations %}
<div class="section-header">الردود المباشرة</div>
{% for exp in explanations %}
{% if exp.explanation %}
<table class="data-table">
<tr>
<td class="lbl">الموظف</td>
<td class="val">{{ exp.staff.get_full_name|default:"—" }}</td>
<td class="lbl">تاريخ الرد</td>
<td class="val">{% if exp.responded_at %}{{ exp.responded_at|date:"Y/m/d H:i" }}{% else %}—{% endif %}</td>
</tr>
<tr>
<td colspan="4" class="text-cell">{{ exp.explanation }}</td>
</tr>
</table>
{% endif %}
{% endfor %}
{% endif %}
<!-- Resolution -->
{% if complaint.resolution %}
<div class="section-header">الحل</div>
<table class="data-table">
<tr>
<td colspan="4" class="text-cell">{{ complaint.resolution }}</td>
</tr>
</table>
{% endif %}
</div>
</div>
{% if stamp_path %}
<img class="stamp" src="{{ stamp_path }}" alt="">
{% endif %}
</body>
</html>

View File

@ -12,7 +12,6 @@
:root {
--blue: #005696;
--blue-light: #007bbd;
--ink: #1e293b;
--body: #334155;
--muted: #64748b;
@ -30,268 +29,208 @@
height: 297mm;
position: relative;
overflow: hidden;
page-break-after: always;
background-image: url('{{ letterhead_path }}');
background-size: 210mm 297mm;
background-repeat: no-repeat;
background-position: top left;
}
.page:last-child { page-break-after: auto; }
/* === 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;
.stamp {
position: fixed;
bottom: 22mm;
right: 20mm;
width: 40mm;
height: auto;
object-fit: contain;
}
.header-hospital-name {
font-size: 14px;
font-weight: 700;
color: var(--blue);
}
.header-branch {
font-size: 10px;
color: var(--muted);
}
.header-cr {
font-size: 9px;
font-weight: 700;
color: var(--blue);
text-align: center;
}
.header-line {
height: 2px;
background: var(--blue);
margin: 8px 15mm 0 15mm;
}
/* === 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;
z-index: 100;
}
/* === Form Body === */
.form-body {
padding: 8mm 15mm;
padding: 38mm 18mm 30mm 18mm;
position: relative;
z-index: 1;
}
/* === Title === */
.form-title {
text-align: center;
font-size: 16px;
font-size: 18px;
font-weight: 700;
color: var(--blue);
padding: 10px 0 16px 0;
padding: 6px 0 14px 0;
}
/* === Section Header === */
.section-title {
background: var(--surface);
color: var(--blue);
font-size: 11px;
font-weight: 700;
padding: 6px 12px;
border: 1px solid var(--border);
border-bottom: none;
margin-top: 6px;
}
/* === Data Tables === */
.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);
border-left: 1px solid var(--border);
border-top: 1px solid var(--border);
text-align: right;
}
.data-table td {
border: 1px solid var(--border);
font-size: 11px;
padding: 8px 12px;
font-size: 10px;
padding: 6px 10px;
}
.data-table .lbl {
background: var(--surface);
color: var(--body);
font-weight: 700;
width: 15%;
width: 18%;
}
.data-table .val {
color: var(--ink);
width: 35%;
}
.data-table .text-cell {
padding: 12px;
line-height: 2;
text-align: justify;
width: 32%;
}
.signature-block {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--border);
/* === Summary Block === */
.summary-box {
border: 1px solid var(--border);
padding: 12px;
font-size: 10px;
line-height: 2;
text-align: justify;
min-height: 40mm;
}
/* === Responsible Department === */
.responsible-dept {
margin-top: 8mm;
background: var(--blue);
color: var(--white);
text-align: center;
font-size: 12px;
font-weight: 700;
padding: 8px 12px;
border-radius: 4px;
}
/* === Bottom: QR + Notice (fixed position, like stamp) === */
.qr-block {
position: fixed;
bottom: 22mm;
left: 18mm;
z-index: 100;
}
.qr-block table {
border-collapse: collapse;
}
.qr-block td {
vertical-align: top;
padding: 0;
}
.qr-block img {
width: 25mm;
height: 25mm;
}
.qr-label {
font-size: 7px;
color: var(--muted);
margin-top: 2px;
text-align: center;
}
.signature-label {
font-size: 11px;
color: var(--blue);
}
.signature-line {
width: 200px;
border-bottom: 1px solid var(--border);
margin: 30px auto 0 auto;
height: 1px;
}
.signature-stamp-text {
font-size: 9px;
.qr-notice {
font-size: 8px;
color: var(--muted);
margin-top: 8px;
line-height: 1.6;
text-align: right;
padding-right: 5mm;
max-width: 45mm;
}
</style>
</head>
<body>
<!-- Page 1: Complaint Forwarding Form -->
<div class="page">
{% include "complaints/partials/pdf_letterhead_header.html" %}
<div class="watermark"><img src="{{ logo_path }}" alt=""></div>
<div class="form-body">
<!-- Title -->
<div class="form-title">نموذج الشكوى</div>
<!-- Section 1: Complainant Information -->
<div class="section-title">بيانات مقدم الشكوى</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>
<td class="lbl">رقم الملف</td>
<td class="val">{{ complaint.reference_number|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>
<td class="lbl">الموقع</td>
<td class="val">{{ department_name|default:"—" }}</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>
<td class="val">{{ incident_date|default:"—" }}</td>
<td class="lbl">تاريخ التقديم</td>
<td class="val">{{ submission_date|default:"—" }}</td>
</tr>
<tr>
<td class="lbl">رقم الاتصال</td>
<td class="val" colspan="3">{{ complaint.contact_phone|default:"—" }}</td>
</tr>
</table>
<!-- Section 2: Information About Person Complained Of -->
<div class="section-title">بيانات الموظف المشكو عليه</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">{{ 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">{% if complaint.department and complaint.department.parent %}{{ complaint.department.parent.name_ar|default:complaint.department.parent.name_en }}{% elif complaint.department %}{{ complaint.department.name_ar|default:complaint.department.name_en }}{% else %}—{% endif %}</td>
<td class="lbl">تاريخ الإرسال</td>
<td class="val">{{ sent_to_dept_date }}</td>
<td class="val">{{ sent_to_dept_date|default:"—" }}</td>
</tr>
</table>
<table class="data-table">
<tr><th colspan="4">مختصر الشكوى</th></tr>
<tr>
<td colspan="4" class="text-cell">{{ content_summary }}</td>
</tr>
</table>
<!-- Section 3: Complaint Summary -->
<div class="section-title">مختصر الشكوى</div>
<div class="summary-box">{{ content_summary|default:complaint.description }}</div>
<!-- Section 4: Responsible Department -->
<div class="responsible-dept">قسم علاقات المرضى</div>
</div>
{% include "complaints/partials/pdf_letterhead_footer.html" %}
</div>
<!-- Page 2: Complaint Response Form -->
<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">{{ 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>
<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" %}
<!-- QR Code + Notice (fixed bottom-left, table-aligned tops) -->
{% if qr_code_path %}
<div class="qr-block">
<table dir="ltr">
<tr>
<td>
<img src="{{ qr_code_path }}" alt="QR">
<div class="qr-label">للتحقق من صحة المستند</div>
</td>
<td class="qr-notice">
هذه الوثيقة صادرة إلكترونياً ولا تتطلب توقيعاً يدوياً.<br>
This document is electronically generated<br>
and does not require a signature.
</td>
</tr>
</table>
</div>
{% endif %}
{% if stamp_path %}
<img class="stamp" src="{{ stamp_path }}" alt="">
{% endif %}
</body>
</html>

View File

@ -110,6 +110,64 @@
</button>
{% endif %}
</div>
<button type="button" onclick="showRejectForm()" class="w-full mt-3 flex items-center justify-center gap-2 px-4 py-2.5 border-2 border-red-200 text-red-600 rounded-xl font-bold text-sm hover:border-red-400 hover:bg-red-50 transition">
<i data-lucide="log-out" class="w-4 h-4"></i>
{% trans "Wrong Department" %}
</button>
</div>
<!-- Reject Routing Form -->
<div id="reject-section" style="display:none">
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 mt-4">
{% if error %}
<div class="bg-red-50 border border-red-200 rounded-xl p-3 mb-4 text-sm text-red-700 font-semibold">
{% trans error %}
</div>
{% endif %}
<form method="post" class="space-y-4">
{% csrf_token %}
<div class="flex items-center gap-2 mb-1">
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-500"></i>
<h2 class="text-base font-bold text-navy">{% trans "Reject Routing" %}</h2>
</div>
<p class="text-sm text-slate">{% trans "Use this if the complaint was sent to the wrong department. The PX team will be notified to re-route it." %}</p>
<div>
<label for="rejection_reason" class="block text-sm font-semibold text-navy mb-2">
{% trans "Reason" %} <span class="text-red-500">*</span>
</label>
<textarea name="rejection_reason" id="rejection_reason" rows="4" required
class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl text-sm text-slate focus:outline-none focus:border-navy focus:ring-2 focus:ring-navy/20 resize-none"
placeholder="{% trans 'Explain why this complaint does not belong to your department...' %}">{{ rejection_reason|default:'' }}</textarea>
</div>
{% if hospital_departments %}
<div>
<label for="suggested_department_id" class="block text-sm font-semibold text-navy mb-2">
{% trans "Suggest Correct Department (optional)" %}
</label>
<select name="suggested_department_id" id="suggested_department_id"
class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl text-sm text-slate focus:outline-none focus:border-navy bg-white">
<option value="">{% trans "Select Department" %}</option>
{% for dept in hospital_departments %}
<option value="{{ dept.id }}">{{ dept.get_localized_name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="flex gap-3">
<button type="button" onclick="hideRejectForm()" class="flex-1 px-4 py-3 border-2 border-slate-200 text-slate-700 rounded-xl font-bold text-sm hover:border-slate-400 transition">
{% trans "Cancel" %}
</button>
<button type="submit" name="action" value="reject_routing" class="flex-1 px-4 py-3 bg-red-500 text-white rounded-xl font-bold text-sm hover:bg-red-600 transition flex items-center justify-center gap-2"
onclick="return confirm('{% trans "Reject this routing as wrong department?" %}')">
<i data-lucide="log-out" class="w-4 h-4"></i> {% trans "Reject Routing" %}
</button>
</div>
<p class="text-xs text-slate/60 text-center">{% trans "This action cannot be undone." %}</p>
</form>
</div>
</div>
<!-- Reply Form -->
@ -124,6 +182,57 @@
<form method="post" enctype="multipart/form-data" class="space-y-4">
{% csrf_token %}
{% if otp_sent %}
<!-- ═══ OTP VERIFICATION BANNER (top, prominent) ═══ -->
<div class="bg-green-50 border border-green-200 rounded-xl p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<i data-lucide="shield-check" class="w-5 h-5 text-green-600"></i>
<p class="text-sm font-bold text-green-800">{% trans "Verify your submission" %}</p>
</div>
<button type="submit" name="action" value="cancel_otp"
class="text-xs text-slate/60 hover:text-navy underline flex items-center gap-1">
<i data-lucide="pencil" class="w-3 h-3"></i>
{% trans "Edit response" %}
</button>
</div>
<p class="text-sm text-green-700 mb-3">
{% if otp_phone and otp_email %}{% trans "Verification code sent to your phone and email." %}
{% elif otp_phone %}{% trans "Verification code sent to your phone." %}
{% elif otp_email %}{% trans "Verification code sent to your email." %}
{% else %}{% trans "Verification code sent." %}{% endif %}
</p>
<div class="flex items-center gap-3">
<input type="text" name="otp_code" id="otpInput" maxlength="6" pattern="[0-9]{6}" autofocus
class="w-32 px-3 py-2 border-2 border-green-300 rounded-lg text-center text-lg font-bold tracking-widest focus:outline-none focus:border-green-600"
placeholder="000000" required
oninput="toggleVerifyBtn()">
<button type="submit" name="action" value="verify_submit" id="verifyBtn"
class="flex-1 px-4 py-2.5 bg-green-600 text-white rounded-xl font-bold text-sm hover:bg-green-700 transition disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
disabled>
<i data-lucide="check-circle" class="w-4 h-4"></i>
{% trans "Verify & Submit" %}
</button>
</div>
<div class="flex items-center justify-between mt-3">
<p class="text-xs text-slate/60">{% trans "Enter the 6-digit code to complete your submission." %}</p>
<button type="submit" name="action" value="send_code"
class="text-xs text-green-700 hover:text-green-800 underline">
{% trans "Resend code" %}
</button>
</div>
</div>
<!-- Locked form fields wrapper -->
<div class="relative rounded-xl" style="opacity: 0.5; pointer-events: none;">
<div class="absolute -top-2.5 left-4 px-2 py-0.5 bg-slate-200 text-slate-600 rounded text-[10px] font-bold uppercase tracking-wider flex items-center gap-1 z-10">
<i data-lucide="lock" class="w-3 h-3"></i>
{% trans "Locked" %}
</div>
{% endif %}
<div>
<label for="explanation" class="block text-sm font-semibold text-navy mb-2">
{% trans "Your Response" %} <span class="text-red-500">*</span>
@ -218,33 +327,10 @@
</div>
{% if otp_sent %}
<!-- Step 2: Code entry -->
<div class="bg-green-50 border border-green-200 rounded-xl p-4">
<p class="text-sm font-semibold text-green-800 mb-3">
{% if otp_phone and otp_email %}{% trans "Verification code sent to your phone and email." %}
{% elif otp_phone %}{% trans "Verification code sent to your phone." %}
{% elif otp_email %}{% trans "Verification code sent to your email." %}
{% else %}{% trans "Verification code sent." %}{% endif %}
</p>
<div class="flex items-center gap-3">
<input type="text" name="otp_code" id="otpInput" maxlength="6" pattern="[0-9]{6}"
class="w-32 px-3 py-2 border-2 border-slate-200 rounded-lg text-center text-lg font-bold tracking-widest focus:outline-none focus:border-navy"
placeholder="000000" required
oninput="toggleVerifyBtn()">
<button type="submit" name="action" value="verify_submit" id="verifyBtn"
class="flex-1 px-4 py-2.5 bg-green-600 text-white rounded-xl font-bold text-sm hover:bg-green-700 transition disabled:opacity-40 disabled:cursor-not-allowed"
disabled>
{% trans "Verify & Submit" %}
</button>
</div>
<p class="text-xs text-slate/60 mt-2">{% trans "Enter the 6-digit code to complete your submission." %}</p>
<!-- Close the locked-fields wrapper -->
</div>
<button type="submit" name="action" value="send_code"
class="text-xs text-slate/60 hover:text-navy underline">
{% trans "Resend code" %}
</button>
{% else %}
<!-- Step 1: Send code -->
<!-- Step 1: Send code (only shown when OTP not yet sent) -->
<button type="submit" name="action" value="send_code" id="sendCodeBtn"
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-navy text-white rounded-xl font-bold text-sm hover:bg-blue transition disabled:opacity-40 disabled:cursor-not-allowed"
disabled>
@ -267,6 +353,16 @@
document.getElementById('reply-section').style.display = 'block';
}
function showRejectForm() {
document.getElementById('action-choice').style.display = 'none';
document.getElementById('reject-section').style.display = 'block';
}
function hideRejectForm() {
document.getElementById('reject-section').style.display = 'none';
document.getElementById('action-choice').style.display = 'block';
}
function goToInvestigation() {
window.location.href = "{{ investigate_url }}";
}
@ -286,15 +382,18 @@
var field = document.getElementById('projectField');
if (field && yesRadio) field.classList.toggle('hidden', !yesRadio.checked);
}
document.addEventListener('DOMContentLoaded', function() {
toggleSubmitBtn();
toggleVerifyBtn();
toggleProjectField();
// Auto-show reply form if OTP was already sent (e.g. page reload)
{% if otp_sent %}
showReplyForm();
{% endif %}
});
// Init directly — script is at the bottom of <body>, all elements exist.
// (DOMContentLoaded has already fired by the time this runs, so a listener
// would be dead code.)
toggleSubmitBtn();
toggleVerifyBtn();
toggleProjectField();
// Auto-show reply form if OTP was already sent (e.g. page reload after send_code)
{% if otp_sent %}
showReplyForm();
{% elif show_reject_form %}
showRejectForm();
{% endif %}
</script>
</body>
</html>

View File

@ -0,0 +1,66 @@
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE|default:'en' }}" dir="{% if LANGUAGE_CODE == 'ar' %}rtl{% else %}ltr{% endif %}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ complaint.reference_number }} - {% trans "Routing Rejected" %}</title>
<link rel="stylesheet" href="{% static 'dist/css/tailwind.css' %}">
<link rel="stylesheet" href="{% static 'vendor/fonts/noto-kufi-arabic/arabic.css' %}">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body { font-family: 'Inter', 'Noto Kufi Arabic', sans-serif; }
[dir="rtl"] body { font-family: 'Noto Kufi Arabic', sans-serif; }
</style>
</head>
<body class="bg-slate-50 min-h-screen py-8 px-4">
<div class="w-full max-w-2xl mx-auto">
<!-- Logo -->
<div class="text-center mb-6">
<img src="{% static 'img/hh-logo.png' %}" alt="Al Hammadi Hospital" class="h-16 w-auto mx-auto inline-block">
</div>
<!-- Success Card -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-8 text-center">
<div class="w-16 h-16 mx-auto bg-amber-100 rounded-full flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
</div>
<h1 class="text-lg font-bold text-navy mb-1">{% trans "Routing Rejected" %}</h1>
<p class="text-sm text-slate">{% trans "The PX team has been notified that this complaint was sent to the wrong department. They will re-route it accordingly." %}</p>
</div>
<!-- Summary -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 mt-4">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-bold text-slate/60 uppercase">{% trans "Reference" %}</span>
<span class="text-sm font-bold text-navy">{{ complaint.reference_number }}</span>
</div>
<div class="flex items-start gap-2 mb-3">
<span class="text-xs font-bold text-slate/60 uppercase pt-0.5 shrink-0">{% trans "Title" %}</span>
<span class="text-sm text-slate">{{ complaint.title }}</span>
</div>
{% if involved_dept and involved_dept.department %}
<div class="flex items-start gap-2 mb-3">
<span class="text-xs font-bold text-slate/60 uppercase pt-0.5 shrink-0">{% trans "Rejected Department" %}</span>
<span class="text-sm text-slate">{{ involved_dept.department.get_localized_name }}</span>
</div>
{% endif %}
{% if involved_dept and involved_dept.rejection_reason %}
<div class="flex items-start gap-2 mb-3">
<span class="text-xs font-bold text-slate/60 uppercase pt-0.5 shrink-0">{% trans "Reason" %}</span>
<span class="text-sm text-slate">{{ involved_dept.rejection_reason }}</span>
</div>
{% endif %}
{% if involved_dept and involved_dept.suggested_department %}
<div class="flex items-start gap-2">
<span class="text-xs font-bold text-slate/60 uppercase pt-0.5 shrink-0">{% trans "Suggested Department" %}</span>
<span class="text-sm text-slate">{{ involved_dept.suggested_department.get_localized_name }}</span>
</div>
{% endif %}
</div>
</div>
</body>
</html>

View File

@ -126,6 +126,9 @@
</div>
</header>
<!-- Workflow Stepper -->
{% include "core/workflow_stepper.html" %}
<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">{% trans "Details" %}</button>
<button class="py-4 text-sm tab-inactive" onclick="switchTab('timeline')" id="tab-timeline">{% trans "Timeline" %}</button>
@ -556,44 +559,6 @@
</section>
{% endif %}
{% if inquiry.status == 'resolved' or inquiry.status == 'closed' %}
<section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<h3 class="font-bold text-navy mb-3 text-sm flex items-center gap-2">
<i data-lucide="smile" class="w-4 h-4"></i> {% trans "Satisfaction" %}
</h3>
{% if inquiry.satisfaction %}
<div class="mb-3">
<span class="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold
{% if inquiry.satisfaction == 'satisfied' %}bg-green-100 text-green-800 border border-green-300
{% elif inquiry.satisfaction == 'neutral' %}bg-yellow-100 text-yellow-800 border border-yellow-300
{% elif inquiry.satisfaction == 'dissatisfied' %}bg-red-100 text-red-800 border border-red-300
{% else %}bg-slate-100 text-slate-600 border border-slate-300{% endif %}">
{{ inquiry.get_satisfaction_display }}
</span>
</div>
{% endif %}
<form method="post" action="{% url 'inquiries:inquiry_update_satisfaction' inquiry.pk %}">
{% csrf_token %}
<div class="flex flex-wrap gap-2">
<button type="submit" name="satisfaction" value="satisfied"
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
{% if inquiry.satisfaction == 'satisfied' %}border-green-500 bg-green-50 text-green-700{% else %}border-slate-200 text-slate-600 hover:border-green-400{% endif %}">
<i data-lucide="thumbs-up" class="w-3.5 h-3.5"></i> {% trans "Satisfied" %}
</button>
<button type="submit" name="satisfaction" value="neutral"
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
{% if inquiry.satisfaction == 'neutral' %}border-yellow-500 bg-yellow-50 text-yellow-700{% else %}border-slate-200 text-slate-600 hover:border-yellow-400{% endif %}">
<i data-lucide="minus-circle" class="w-3.5 h-3.5"></i> {% trans "Neutral" %}
</button>
<button type="submit" name="satisfaction" value="dissatisfied"
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
{% if inquiry.satisfaction == 'dissatisfied' %}border-red-500 bg-red-50 text-red-700{% else %}border-slate-200 text-slate-600 hover:border-red-400{% endif %}">
<i data-lucide="thumbs-down" class="w-3.5 h-3.5"></i> {% trans "Dissatisfied" %}
</button>
</div>
</form>
</section>
{% endif %}
</div>
</main>

View File

@ -267,7 +267,7 @@
var disabled = s.already_added || alreadyInList;
var onclickAttr = disabled ? '' : 'onclick="addStaffCard(\'' + s.id + '\', \'' + escapeHtml(s.name).replace(/'/g, "\\'") + '\', \'' + escapeHtml(s.department_name).replace(/'/g, "\\'") + '\')"';
return '<div class="px-3 py-2 flex items-center justify-between ' + (disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer hover:bg-slate-50') + '" ' + onclickAttr + '>' +
'<span class="text-sm">' + escapeHtml(s.name) + ' <span class="text-xs text-slate/60">' + escapeHtml(s.department_name) + '</span></span>' +
'<span class="text-sm">' + escapeHtml(s.name) + (s.employee_id ? ' <span class="text-xs text-slate/40">(' + escapeHtml(s.employee_id) + ')</span>' : '') + ' <span class="text-xs text-slate/60">' + escapeHtml(s.department_name) + '</span></span>' +
(disabled ? '<span class="text-[10px] font-bold text-slate/60">{% trans "ADDED" %}</span>' : '') +
'</div>';
}).join('');

View File

@ -127,6 +127,58 @@
<form method="post" enctype="multipart/form-data" class="space-y-4">
{% csrf_token %}
{% if otp_sent %}
<!-- ═══ OTP VERIFICATION BANNER (top, prominent) ═══ -->
<div class="bg-green-50 border border-green-200 rounded-xl p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<i data-lucide="shield-check" class="w-5 h-5 text-green-600"></i>
<p class="text-sm font-bold text-green-800">{% trans "Verify your submission" %}</p>
</div>
<button type="submit" name="action" value="cancel_otp"
class="text-xs text-slate/60 hover:text-navy underline flex items-center gap-1">
<i data-lucide="pencil" class="w-3 h-3"></i>
{% trans "Edit response" %}
</button>
</div>
<p class="text-sm text-green-700 mb-3">
{% if otp_phone and otp_email %}{% trans "Verification code sent to your phone and email." %}
{% elif otp_phone %}{% trans "Verification code sent to your phone." %}
{% elif otp_email %}{% trans "Verification code sent to your email." %}
{% else %}{% trans "Verification code sent." %}{% endif %}
</p>
<div class="flex items-center gap-3">
<input type="text" name="otp_code" id="otpInput" maxlength="6" pattern="[0-9]{6}" autofocus
class="w-32 px-3 py-2 border-2 border-green-300 rounded-lg text-center text-lg font-bold tracking-widest focus:outline-none focus:border-green-600"
placeholder="000000" required
oninput="toggleVerifyBtn()">
<button type="submit" name="action" value="verify_submit" id="verifyBtn"
class="flex-1 px-4 py-2.5 bg-green-600 text-white rounded-xl font-bold text-sm hover:bg-green-700 transition disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
disabled>
<i data-lucide="check-circle" class="w-4 h-4"></i>
{% trans "Verify & Submit" %}
</button>
</div>
<div class="flex items-center justify-between mt-3">
<p class="text-xs text-slate/60">{% trans "Enter the 6-digit code to complete your submission." %}</p>
<button type="submit" name="action" value="send_code"
class="text-xs text-green-700 hover:text-green-800 underline">
{% trans "Resend code" %}
</button>
</div>
</div>
<!-- Locked form fields wrapper -->
<div class="relative rounded-xl" style="opacity: 0.5; pointer-events: none;">
<div class="absolute -top-2.5 left-4 px-2 py-0.5 bg-slate-200 text-slate-600 rounded text-[10px] font-bold uppercase tracking-wider flex items-center gap-1 z-10">
<i data-lucide="lock" class="w-3 h-3"></i>
{% trans "Locked" %}
</div>
{% endif %}
<textarea name="final_reply" rows="6"
class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl text-sm text-slate focus:outline-none focus:border-navy resize-none"
placeholder="{% trans 'Write your final reply based on the feedback...' %}"
@ -197,7 +249,7 @@
<!-- Consent -->
<div class="bg-amber-50 border border-amber-200 rounded-xl p-4">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" name="consent" id="consentCheckbox" class="mt-0.5 w-5 h-5 accent-navy shrink-0" required
<input type="checkbox" name="consent" id="consentCheckbox" class="mt-0.5 w-5 h-5 accent-navy shrink-0"
{% if consent_checked %}checked{% endif %}
onchange="toggleSubmitBtn()">
<span class="text-sm text-slate-700 leading-relaxed">
@ -207,37 +259,14 @@
</div>
{% if otp_sent %}
<!-- Step 2: Code entry -->
<div class="bg-green-50 border border-green-200 rounded-xl p-4">
<p class="text-sm font-semibold text-green-800 mb-3">
{% if otp_phone and otp_email %}{% trans "Verification code sent to your phone and email." %}
{% elif otp_phone %}{% trans "Verification code sent to your phone." %}
{% elif otp_email %}{% trans "Verification code sent to your email." %}
{% else %}{% trans "Verification code sent." %}{% endif %}
</p>
<div class="flex items-center gap-3">
<input type="text" name="otp_code" id="otpInput" maxlength="6" pattern="[0-9]{6}"
class="w-32 px-3 py-2 border-2 border-slate-200 rounded-lg text-center text-lg font-bold tracking-widest focus:outline-none focus:border-navy"
placeholder="000000" required
oninput="toggleVerifyBtn()">
<button type="submit" name="action" value="verify_submit" id="verifyBtn"
class="flex-1 px-4 py-2.5 bg-green-600 text-white rounded-xl font-bold text-sm hover:bg-green-700 transition disabled:opacity-40 disabled:cursor-not-allowed"
disabled>
{% trans "Verify & Submit" %}
</button>
</div>
<p class="text-xs text-slate/60 mt-2">{% trans "Enter the 6-digit code to complete your submission." %}</p>
<!-- Close the locked-fields wrapper -->
</div>
<button type="submit" name="action" value="send_code"
class="text-xs text-slate/60 hover:text-navy underline">
{% trans "Resend code" %}
</button>
{% else %}
<!-- Step 1: Send code -->
<!-- Step 1: Send code (only when OTP not yet sent) -->
<button type="submit" name="action" value="send_code" id="sendCodeBtn"
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-navy text-white rounded-xl font-bold text-sm hover:bg-blue transition disabled:opacity-40 disabled:cursor-not-allowed"
disabled>
{% trans "Send Verification Code" %}
<i data-lucide="send" class="w-4 h-4"></i> {% trans "Send Verification Code" %}
</button>
{% endif %}
</form>
@ -267,11 +296,11 @@
var field = document.getElementById('projectField');
if (field && yesRadio) field.classList.toggle('hidden', !yesRadio.checked);
}
document.addEventListener('DOMContentLoaded', function() {
toggleSubmitBtn();
toggleVerifyBtn();
toggleProjectField();
});
// Init directly — script is at the bottom of <body>, all elements exist.
toggleSubmitBtn();
toggleVerifyBtn();
toggleProjectField();
lucide.createIcons();
</script>
</body>
</html>

View File

@ -4,9 +4,10 @@
<h3 class="text-lg font-bold text-navy">{% trans "Related PX Actions" %}</h3>
{% if can_admin and complaint.is_active_status %}
<div class="flex flex-wrap items-center gap-2">
<button onclick="createAction()" class="px-3 py-2 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-semibold hover:opacity-90 transition inline-flex items-center gap-2 text-sm">
<a href="{% url 'actions:action_create' %}?complaint_id={{ complaint.id }}"
class="px-3 py-2 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-semibold hover:opacity-90 transition inline-flex items-center gap-2 text-sm">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Create Action" %}
</button>
</a>
<a href="{% url 'projects:project_create' %}?related_model=complaint&related_id={{ complaint.pk }}"
class="px-3 py-2 bg-teal-600 text-white rounded-xl font-semibold hover:bg-teal-700 transition inline-flex items-center gap-2 text-sm">
<i data-lucide="folder-plus" class="w-4 h-4"></i> {% trans "QI Project" %}
@ -47,9 +48,3 @@
</div>
{% endif %}
</section>
<script>
function createAction() {
window.location.href = "{% url 'actions:action_create' %}?source_type=complaint&complaint_id={{ complaint.id }}";
}
</script>

View File

@ -130,29 +130,6 @@
{% endif %}
</div>
{% endif %}
<!-- AI Resolution Helper -->
{% if user.is_px_admin or user.is_hospital_admin %}
{% if complaint.is_active_status %}
<div class="mt-4 pt-3 border-t-2 border-slate-100">
<div class="flex items-center justify-between mb-2">
<h4 class="font-bold text-navy flex items-center gap-2 text-sm">
<i data-lucide="lightbulb" class="w-4 h-4 text-amber-500"></i>
{% trans "AI Resolution Helper" %}
</h4>
</div>
<p class="text-slate text-xs mb-3">{% trans "Get AI-powered suggestions on how to resolve this complaint based on its content and any explanations received." %}</p>
<div id="aiHelperContent">
<div class="text-center py-3">
<button onclick="loadAIHelperSuggestions()" class="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-lg font-bold hover:from-purple-600 hover:to-indigo-600 transition text-xs shadow">
<i data-lucide="sparkles" class="w-4 h-4"></i>
{% trans "Get Resolution Suggestions" %}
</button>
</div>
</div>
</div>
{% endif %}
{% endif %}
</section>
<script>
@ -321,7 +298,9 @@ function showAlert(type, message) {
function loadAIHelperSuggestions() {
const container = document.getElementById('aiHelperContent');
container.innerHTML = '<div class="text-center py-4"><div class="inline-block w-6 h-6 border-3 border-purple-200 border-t-purple-500 rounded-full animate-spin"></div><p class="text-slate text-xs mt-2">{% trans "Analyzing complaint for resolution suggestions..." %}</p></div>';
if (container) {
container.innerHTML = '<div class="text-center py-3"><div class="inline-block w-5 h-5 border-2 border-slate-200 border-t-navy rounded-full animate-spin"></div><p class="text-slate text-xs mt-2">{% trans "Generating suggestions..." %}</p></div>';
}
fetch('/complaints/api/complaints/{{ complaint.pk }}/ai_helper_suggestions/', {
headers: {'X-CSRFToken': getCSRFToken()},
@ -329,49 +308,18 @@ function loadAIHelperSuggestions() {
})
.then(r => r.json())
.then(data => {
if (!data.success) {
container.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">' + escapeHtml(data.error || 'Failed to generate suggestions') + '</div>';
return;
if (data.success) {
location.reload();
} else {
if (container) {
container.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">' + escapeHtml(data.error || '{% trans "Failed to generate suggestions" %}') + '</div>';
}
}
let html = '';
if (data.suggestions && data.suggestions.suggestions) {
html += '<div class="space-y-2 mb-4">';
data.suggestions.suggestions.forEach((s, i) => {
html += '<div class="bg-gradient-to-r from-purple-50 to-indigo-50 border border-purple-100 rounded-lg p-3">';
html += '<div class="flex items-start gap-2">';
html += '<span class="flex-shrink-0 w-5 h-5 bg-purple-500 text-white rounded-full flex items-center justify-center text-xs font-bold">' + (i+1) + '</span>';
html += '<div><p class="font-bold text-navy text-sm">' + escapeHtml(s.title) + '</p>';
html += '<p class="text-slate text-xs mt-1">' + escapeHtml(s.description) + '</p></div>';
html += '</div></div>';
});
html += '</div>';
}
if (data.suggestions && data.suggestions.recommended_actions) {
html += '<h5 class="font-bold text-navy mb-2 flex items-center gap-2 text-sm"><i data-lucide="list-checks" class="w-4 h-4 text-blue"></i> {% trans "Recommended Next Steps" %}</h5>';
html += '<ul class="space-y-1 mb-4">';
data.suggestions.recommended_actions.forEach(a => {
html += '<li class="flex items-start gap-2 text-xs text-slate-700"><i data-lucide="arrow-right" class="w-3.5 h-3.5 text-green-500 flex-shrink-0 mt-0.5"></i>' + escapeHtml(a) + '</li>';
});
html += '</ul>';
}
if (data.suggestions && data.suggestions.communication_tip) {
html += '<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-3">';
html += '<h5 class="font-bold text-blue mb-1 flex items-center gap-2 text-sm"><i data-lucide="message-circle" class="w-3.5 h-3.5"></i> {% trans "Communication Tip" %}</h5>';
html += '<p class="text-xs text-blue-800">' + escapeHtml(data.suggestions.communication_tip) + '</p>';
html += '</div>';
}
html += '<div class="text-center mt-2"><button onclick="loadAIHelperSuggestions()" class="text-xs text-purple-600 hover:text-purple-800 font-semibold">{% trans "Regenerate suggestions" %}</button></div>';
container.innerHTML = html;
lucide.createIcons();
})
.catch(err => {
container.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-3 text-xs text-red-700">{% trans "An error occurred. Please try again." %}</div>';
if (container) {
container.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-3 text-xs text-red-700">{% trans "An error occurred. Please try again." %}</div>';
}
});
}
</script>

View File

@ -57,12 +57,17 @@
{% if dept.response_notes %}
<p class="text-[10px] text-slate/70 mt-1 italic max-w-xs truncate">{{ dept.response_notes|truncatechars:60 }}</p>
{% endif %}
{% if dept.routing_status == 'rejected' and dept.rejection_reason %}
<p class="text-[10px] text-red-500 mt-1 italic max-w-xs truncate"><i data-lucide="log-out" class="w-3 h-3 inline"></i> {{ dept.rejection_reason|truncatechars:80 }}{% if dept.suggested_department %} &middot; {% trans "Suggested:" %} {{ dept.suggested_department.get_localized_name }}{% endif %}</p>
{% endif %}
</td>
<td class="py-2.5 px-2">
<span class="px-1.5 py-0.5 bg-light text-navy rounded text-[10px] font-semibold">{{ dept.get_role_display }}</span>
</td>
<td class="py-2.5 px-2">
{% if not dept.response_submitted %}
{% if dept.routing_status == 'rejected' %}
<span class="inline-flex items-center gap-1 text-[10px] font-bold text-red-600"><i data-lucide="log-out" class="w-3 h-3"></i> {% trans "Wrong Department" %}</span>
{% elif not dept.response_submitted %}
<span class="text-[10px] text-slate/50 italic">{% trans "No response" %}</span>
{% elif dept.manager_review_status == 'rejected' %}
<span class="inline-flex items-center gap-1 text-[10px] font-bold text-red-600"><i data-lucide="x-circle" class="w-3 h-3"></i> {% trans "Rejected" %}</span>

View File

@ -74,7 +74,7 @@
</span>
{% if complaint.satisfaction_set_at %}
<span class="text-[10px] text-slate-400 ml-2">
{{ complaint.satisfaction_set_at|date:"Y-m-d H:i" }}
{% if complaint.satisfaction_set_by %}{% trans "by" %} {{ complaint.satisfaction_set_by.get_full_name }} · {% endif %}{{ complaint.satisfaction_set_at|date:"Y-m-d H:i" }}
</span>
{% endif %}
</div>
@ -91,6 +91,34 @@
{% endif %}
</div>
</div>
{% elif complaint.satisfaction_window_expired %}
<!-- 5-day window expired: no editing allowed -->
<div class="flex items-center gap-3 mt-3 p-3 bg-amber-50 border border-amber-200 rounded-xl">
<i data-lucide="clock-alert" class="w-4 h-4 text-amber-500 shrink-0"></i>
<div class="text-xs text-amber-700">
<p class="font-bold">{% trans "Satisfaction window expired" %}</p>
<p class="text-[10px] text-amber-600">{% trans "Satisfaction can only be set within 5 days of resolution." %}</p>
</div>
</div>
{% elif complaint.satisfaction_change_count >= 3 and can_edit %}
<!-- Max changes reached -->
<div class="flex items-center gap-3 mt-3 p-3 bg-slate-50 border border-slate-200 rounded-xl">
<i data-lucide="lock" class="w-4 h-4 text-slate-500 shrink-0"></i>
<div class="text-xs text-slate-600">
<p class="font-bold">{% trans "No more PX-team changes allowed" %}</p>
<p class="text-[10px] text-slate-400">{% trans "Satisfaction has been changed 3 times. Only the patient can override via the tracking page." %}</p>
</div>
</div>
<!-- No Response is always allowed even after max changes -->
<form method="post" action="{% url 'complaints:update_satisfaction' pk=complaint.pk %}" class="mt-2">
{% csrf_token %}
<button type="submit" name="satisfaction" value="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.satisfaction == 'no_response' %}border-slate-400 bg-slate-50 text-slate-700
{% else %}border-slate-200 text-slate-600 hover:border-slate-400 hover:bg-slate-50{% endif %}">
<i data-lucide="phone-off" class="w-3.5 h-3.5"></i> {% trans "No Response" %}
</button>
</form>
{% elif can_edit %}
<form method="post" action="{% url 'complaints:update_satisfaction' pk=complaint.pk %}" class="mt-3">
{% csrf_token %}
@ -121,10 +149,32 @@
<i data-lucide="phone-off" class="w-3.5 h-3.5"></i> {% trans "No Response" %}
</button>
</div>
{% if complaint.satisfaction_change_count > 0 %}
<p class="text-[10px] text-slate-400 mt-2">
{{ complaint.satisfaction_change_count }}/3 {% trans "changes used" %} · {{ satisfaction_changes_remaining }} {% trans "remaining" %}
<span class="text-slate-300">({% trans "No Response doesn't count" %})</span>
</p>
{% endif %}
</form>
{% elif not complaint.satisfaction %}
<p class="text-sm text-slate italic">{% trans "No satisfaction feedback recorded yet." %}</p>
{% endif %}
<!-- Change history -->
{% if satisfaction_history and satisfaction_history|length > 0 %}
<div class="mt-3 pt-3 border-t border-slate-100">
<p class="text-[10px] font-bold text-slate/40 uppercase tracking-wider mb-2">{% trans "Change History" %}</p>
<div class="space-y-1">
{% for entry in satisfaction_history %}
<div class="text-[10px] text-slate/50 flex items-start gap-1.5">
<i data-lucide="history" class="w-3 h-3 mt-0.5 shrink-0 text-slate/30"></i>
<span>{{ entry.message }}</span>
<span class="text-slate/30 shrink-0">· {{ entry.created_at|date:"M d, H:i" }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% else %}
@ -160,7 +210,75 @@
<p class="text-xs text-slate mt-2">{% trans "Click on a card to select. You can edit the selected resolution in the text area below before submitting." %}</p>
</div>
{% endif %}
<!-- Response Suggestions -->
<div class="mb-4 bg-slate-50 border border-slate-200 rounded-xl p-4">
<div class="flex items-center justify-between mb-2">
<h4 class="font-bold text-navy flex items-center gap-2 text-sm">
<i data-lucide="lightbulb" class="w-4 h-4 text-navy"></i>
{% trans "Response Suggestions" %}
</h4>
{% if complaint.ai_response_suggestions_at %}
<span class="text-[10px] text-slate-400">{{ complaint.ai_response_suggestions_at|date:"Y-m-d H:i" }}</span>
{% endif %}
</div>
{% if complaint.ai_response_suggestions %}
<!-- Persisted suggestions rendered server-side -->
<div class="space-y-2 mb-3">
{% for s in complaint.ai_response_suggestions.suggestions %}
<div class="bg-white border border-slate-200 rounded-lg p-3">
<div class="flex items-start gap-2">
<span class="flex-shrink-0 w-5 h-5 bg-navy text-white rounded-full flex items-center justify-center text-xs font-bold">{{ forloop.counter }}</span>
<div>
<p class="font-bold text-navy text-sm">{% if LANG == 'ar' and s.title_ar %}{{ s.title_ar }}{% else %}{{ s.title_en }}{% endif %}</p>
<p class="text-slate text-xs mt-1">{% if LANG == 'ar' and s.description_ar %}{{ s.description_ar }}{% else %}{{ s.description_en }}{% endif %}</p>
</div>
</div>
</div>
{% endfor %}
</div>
{% if complaint.ai_response_suggestions.recommended_actions_en or complaint.ai_response_suggestions.recommended_actions_ar %}
<h5 class="font-bold text-navy mb-2 flex items-center gap-2 text-sm"><i data-lucide="list-checks" class="w-4 h-4 text-blue"></i> {% trans "Recommended Next Steps" %}</h5>
<ul class="space-y-1 mb-3">
{% if LANG == 'ar' and complaint.ai_response_suggestions.recommended_actions_ar %}
{% for a in complaint.ai_response_suggestions.recommended_actions_ar %}
<li class="flex items-start gap-2 text-xs text-slate-700"><i data-lucide="arrow-right" class="w-3.5 h-3.5 text-green-500 flex-shrink-0 mt-0.5"></i>{{ a }}</li>
{% endfor %}
{% else %}
{% for a in complaint.ai_response_suggestions.recommended_actions_en %}
<li class="flex items-start gap-2 text-xs text-slate-700"><i data-lucide="arrow-right" class="w-3.5 h-3.5 text-green-500 flex-shrink-0 mt-0.5"></i>{{ a }}</li>
{% endfor %}
{% endif %}
</ul>
{% endif %}
{% if complaint.ai_response_suggestions.communication_tip_en or complaint.ai_response_suggestions.communication_tip_ar %}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-3">
<h5 class="font-bold text-blue mb-1 flex items-center gap-2 text-sm"><i data-lucide="message-circle" class="w-3.5 h-3.5"></i> {% trans "Communication Tip" %}</h5>
<p class="text-xs text-blue-800">{% if LANG == 'ar' and complaint.ai_response_suggestions.communication_tip_ar %}{{ complaint.ai_response_suggestions.communication_tip_ar }}{% else %}{{ complaint.ai_response_suggestions.communication_tip_en }}{% endif %}</p>
</div>
{% endif %}
<div class="text-center mt-2">
<button type="button" onclick="loadAIHelperSuggestions()" class="text-xs text-navy hover:text-blue font-semibold underline">
{% trans "Regenerate" %}
</button>
</div>
{% else %}
<!-- No suggestions yet -->
<p class="text-xs text-slate mb-3">{% trans "Get suggestions on how to respond to the patient." %}</p>
<div id="aiHelperContent">
<button type="button" onclick="loadAIHelperSuggestions()" class="inline-flex items-center gap-2 px-3 py-2 border-2 border-navy text-navy rounded-lg font-semibold hover:bg-navy hover:text-white transition text-sm">
<i data-lucide="sparkles" class="w-4 h-4"></i>
{% trans "Get Suggestions" %}
</button>
</div>
{% endif %}
</div>
<div class="mb-4">
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Resolution Notes" %}</label>
<textarea name="resolution" id="resolutionTextarea" rows="6" class="w-full border border-slate-200 rounded-xl p-4 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="{% trans 'Enter resolution details...' %}" oninput="toggleResolveBtn()"></textarea>

View File

@ -76,10 +76,16 @@
<!-- Q&A pairs: one compact table -->
{% if event.type == 'staff_responded' and event.qa_pairs %}
<table class="w-full mt-2 text-[11px] border-collapse">
<thead>
<tr class="border-b-2 border-slate-200">
<th class="py-1 pr-3 text-left text-[9px] font-bold text-slate/50 uppercase tracking-wider" style="width:55%">{% trans "Question" %}</th>
<th class="py-1 text-left text-[9px] font-bold text-slate/50 uppercase tracking-wider">{% trans "Answer" %}</th>
</tr>
</thead>
<tbody>
{% for qa in event.qa_pairs %}
<tr class="border-b border-slate-100 last:border-0">
<td class="py-1.5 pr-3 font-semibold text-slate-600 align-top" style="width:55%">
<td class="py-1.5 pr-3 font-semibold text-slate-600 align-top">
{{ forloop.counter }}. {{ qa.question }}
</td>
<td class="py-1.5 text-slate-700 align-top">
@ -104,35 +110,73 @@
<!-- Final reply -->
{% if event.type == 'reply_submitted' %}
{% if event.final_reply %}
<div class="bg-green-50 rounded-lg p-2 mt-2">
<p class="text-[11px] text-slate-700">{{ event.final_reply|truncatewords:50 }}</p>
<div class="mt-2">
<p class="text-[10px] font-bold text-slate/40 uppercase tracking-wider mb-1">{% trans "Response" %}</p>
<div class="bg-green-50 rounded-lg p-2">
<p class="text-[11px] text-slate-700">{{ event.final_reply|truncatewords:50 }}</p>
</div>
</div>
{% endif %}
{% if event.negligence or event.policy_issue or event.improvement_project %}
<div class="flex flex-wrap gap-1 mt-2">
{% if event.negligence == 'yes' %}
<span class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-red-100 text-red-700">{% trans "Negligence" %}</span>
<div class="mt-2">
<p class="text-[10px] font-bold text-slate/40 uppercase tracking-wider mb-1">{% trans "Assessment" %}</p>
<table class="w-full text-[11px] border-collapse">
<thead>
<tr class="border-b-2 border-slate-200">
<th class="py-1 pr-3 text-left text-[9px] font-bold text-slate/50 uppercase tracking-wider" style="width:55%">{% trans "Question" %}</th>
<th class="py-1 text-left text-[9px] font-bold text-slate/50 uppercase tracking-wider">{% trans "Answer" %}</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-slate-100">
<td class="py-1.5 pr-3 font-semibold text-slate-600 align-top">{% trans "Is there any negligence resulting from carelessness?" %}</td>
<td class="py-1.5 align-top">
{% if event.negligence == 'yes' %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-red-100 text-red-700">{% trans "Yes" %}</span>
{% elif event.negligence == 'no' %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-green-100 text-green-700">{% trans "No" %}</span>
{% else %}<span class="text-slate-300"></span>{% endif %}
</td>
</tr>
<tr class="border-b border-slate-100">
<td class="py-1.5 pr-3 font-semibold text-slate-600 align-top">{% trans "Is it resulting from policies or regulations?" %}</td>
<td class="py-1.5 align-top">
{% if event.policy_issue == 'yes' %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-red-100 text-red-700">{% trans "Yes" %}</span>
{% elif event.policy_issue == 'no' %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-green-100 text-green-700">{% trans "No" %}</span>
{% else %}<span class="text-slate-300"></span>{% endif %}
</td>
</tr>
<tr>
<td class="py-1.5 pr-3 font-semibold text-slate-600 align-top">{% trans "Does this complaint require an improvement project?" %}</td>
<td class="py-1.5 align-top">
{% if event.improvement_project == 'yes' %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-amber-100 text-amber-700">{% trans "Yes" %}</span>
{% elif event.improvement_project == 'no' %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-slate-100 text-slate-600">{% trans "No" %}</span>
{% else %}<span class="text-slate-300"></span>{% endif %}
</td>
</tr>
</tbody>
</table>
{% if event.improvement_project == 'yes' and event.improvement_note %}
<div class="bg-amber-50 rounded-lg p-2 mt-1.5">
<span class="text-[10px] font-bold text-amber-700">{% trans "Improvement Project:" %}</span>
<span class="text-[11px] text-amber-800">{{ event.improvement_note }}</span>
</div>
{% endif %}
{% if event.policy_issue == 'yes' %}
<span class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-orange-100 text-orange-700">{% trans "Policy Issue" %}</span>
{% endif %}
{% if event.improvement_project == 'yes' %}
<span class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-amber-100 text-amber-700">{% trans "Improvement Project" %}</span>
{% endif %}
</div>
{% endif %}
{% if event.improvement_project == 'yes' and event.improvement_note %}
<div class="bg-amber-50 rounded-lg p-2 mt-1.5">
<span class="text-[10px] font-bold text-amber-700">{% trans "Improvement Project:" %}</span>
<span class="text-[11px] text-amber-800">{{ event.improvement_note }}</span>
</div>
{% endif %}
{% endif %}
<!-- Direct reply -->
{% if event.type == 'direct_reply' and event.reply %}
<div class="bg-green-50 rounded-lg p-2 mt-2">
<p class="text-[11px] text-slate-700">{{ event.reply|truncatewords:50 }}</p>
<div class="mt-2">
<p class="text-[10px] font-bold text-slate/40 uppercase tracking-wider mb-1">{% trans "Response" %}</p>
<div class="bg-green-50 rounded-lg p-2">
<p class="text-[11px] text-slate-700">{{ event.reply|truncatewords:50 }}</p>
</div>
</div>
{% endif %}
</div>

View File

@ -485,7 +485,7 @@ document.addEventListener('DOMContentLoaded', function() {
var satThankYou = document.getElementById('satisfactionThankYou');
var isResolved = resp.has_response && resp.en;
if (isResolved) {
if (isResolved && selectedType === 'complaint') {
satSection.classList.remove('hidden');
if (data.satisfaction_locked_by_patient) {
satButtons.classList.add('hidden');

View File

@ -0,0 +1,26 @@
{% load i18n %}
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 mb-6">
<div class="flex items-center justify-between">
{% for step in workflow_steps %}
<div class="flex flex-col items-center flex-shrink-0">
{% if step.done %}
<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 step.current %}
<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="{{ step.icon }}" 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="{{ step.icon }}" class="w-5 h-5"></i>
</div>
{% endif %}
<span class="text-xs font-bold mt-2 {% if step.done %}text-slate{% elif step.current %}text-navy{% else %}text-slate-300{% endif %}">{{ step.label }}</span>
</div>
{% if not forloop.last %}
<div class="flex-1 h-1 mx-2 rounded-full {% if step.done %}bg-green-500{% else %}bg-slate-200{% endif %}"></div>
{% endif %}
{% endfor %}
</div>
</div>

View File

@ -57,6 +57,9 @@
</div>
</header>
<!-- Workflow Stepper -->
{% include "core/workflow_stepper.html" %}
<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">{% trans "Details" %}</button>
{% if feedback.has_ai_analysis %}

View File

@ -72,6 +72,9 @@
</div>
</header>
<!-- Workflow Stepper -->
{% include "core/workflow_stepper.html" %}
<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">{% trans "Details" %}</button>
<button class="py-4 text-sm tab-inactive" onclick="switchTab('timeline')" id="tab-timeline">{% trans "Timeline" %}</button>
@ -706,44 +709,6 @@
</section>
{% endif %}
{% if observation.status == 'resolved' or observation.status == 'closed' %}
<section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<h3 class="font-bold text-navy mb-3 text-sm flex items-center gap-2">
<i data-lucide="smile" class="w-4 h-4"></i> {% trans "Satisfaction" %}
</h3>
{% if observation.satisfaction %}
<div class="mb-3">
<span class="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold
{% if observation.satisfaction == 'satisfied' %}bg-green-100 text-green-800 border border-green-300
{% elif observation.satisfaction == 'neutral' %}bg-yellow-100 text-yellow-800 border border-yellow-300
{% elif observation.satisfaction == 'dissatisfied' %}bg-red-100 text-red-800 border border-red-300
{% else %}bg-slate-100 text-slate-600 border border-slate-300{% endif %}">
{{ observation.get_satisfaction_display }}
</span>
</div>
{% endif %}
<form method="post" action="{% url 'observations:observation_update_satisfaction' observation.pk %}">
{% csrf_token %}
<div class="flex flex-wrap gap-2">
<button type="submit" name="satisfaction" value="satisfied"
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
{% if observation.satisfaction == 'satisfied' %}border-green-500 bg-green-50 text-green-700{% else %}border-slate-200 text-slate-600 hover:border-green-400{% endif %}">
<i data-lucide="thumbs-up" class="w-3.5 h-3.5"></i> {% trans "Satisfied" %}
</button>
<button type="submit" name="satisfaction" value="neutral"
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
{% if observation.satisfaction == 'neutral' %}border-yellow-500 bg-yellow-50 text-yellow-700{% else %}border-slate-200 text-slate-600 hover:border-yellow-400{% endif %}">
<i data-lucide="minus-circle" class="w-3.5 h-3.5"></i> {% trans "Neutral" %}
</button>
<button type="submit" name="satisfaction" value="dissatisfied"
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
{% if observation.satisfaction == 'dissatisfied' %}border-red-500 bg-red-50 text-red-700{% else %}border-slate-200 text-slate-600 hover:border-red-400{% endif %}">
<i data-lucide="thumbs-down" class="w-3.5 h-3.5"></i> {% trans "Dissatisfied" %}
</button>
</div>
</form>
</section>
{% endif %}
</div>
</main>

View File

@ -383,6 +383,86 @@
</div>
{% endif %}
{% if can_respond and pending_routing_involvements %}
<div class="bg-white rounded-xl shadow-sm border-2 border-blue-200 p-5 mb-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
<i data-lucide="inbox" class="w-5 h-5 text-blue-600"></i>
</div>
<div>
<h3 class="text-sm font-bold text-navy">{% trans "Incoming Complaints" %}</h3>
<p class="text-xs text-slate-400">{% trans "Complaints routed to your department awaiting your response" %}</p>
</div>
</div>
<div class="space-y-3">
{% for inv in pending_routing_involvements %}
<div class="border border-slate-200 rounded-xl p-4 bg-slate-50/30">
<div class="flex items-start justify-between gap-3 mb-2">
<div class="flex-1 min-w-0">
<a href="{% url 'complaints:complaint_detail' pk=inv.complaint.pk %}" class="font-mono text-xs font-bold text-navy hover:underline">
{{ inv.complaint.reference_number }}
</a>
<p class="text-sm text-slate mt-0.5">{{ inv.complaint.title|truncatechars:60 }}</p>
</div>
{% if inv.is_primary %}
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase whitespace-nowrap bg-navy/10 text-navy">{% trans "Primary" %}</span>
{% else %}
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase whitespace-nowrap bg-slate-100 text-slate-600">{{ inv.get_role_display }}</span>
{% endif %}
</div>
<div class="flex items-center gap-3 text-xs text-slate mb-3">
<span class="inline-flex items-center gap-1">
<i data-lucide="send" class="w-3.5 h-3.5"></i>
{% if inv.sent_at %}{% trans "Sent" %} {{ inv.sent_at|date:"d M Y" }}{% endif %}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<a href="{% url 'complaints:complaint_detail' pk=inv.complaint.pk %}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-navy text-white rounded-lg text-xs font-semibold hover:bg-blue transition">
<i data-lucide="external-link" class="w-3.5 h-3.5"></i> {% trans "View" %}
</a>
<button type="button" onclick="toggleRejectRouting('{{ inv.pk }}')"
class="inline-flex items-center gap-1.5 px-3 py-1.5 border-2 border-red-200 text-red-600 rounded-lg text-xs font-semibold hover:border-red-400 hover:bg-red-50 transition">
<i data-lucide="log-out" class="w-3.5 h-3.5"></i> {% trans "Wrong Department" %}
</button>
</div>
<form method="post" action="{% url 'complaints:involved_department_reject_routing' pk=inv.pk %}" id="reject-routing-{{ inv.pk }}" class="hidden mt-3 pt-3 border-t border-slate-100 space-y-3">
{% csrf_token %}
<div>
<label class="block text-xs font-semibold text-navy mb-1">{% trans "Reason" %} <span class="text-red-500">*</span></label>
<textarea name="rejection_reason" rows="2" required
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:outline-none focus:border-navy resize-none"
placeholder="{% trans 'Why does this not belong to your department?' %}"></textarea>
</div>
{% if hospital_departments %}
<div>
<label class="block text-xs font-semibold text-navy mb-1">{% trans "Suggest Correct Department (optional)" %}</label>
<select name="suggested_department_id" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:outline-none focus:border-navy bg-white">
<option value="">{% trans "Select Department" %}</option>
{% for dept in hospital_departments %}
<option value="{{ dept.id }}">{{ dept.get_localized_name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="flex gap-2">
<button type="button" onclick="toggleRejectRouting('{{ inv.pk }}')"
class="px-3 py-1.5 border-2 border-slate-200 text-slate-600 rounded-lg text-xs font-semibold hover:border-slate-400 transition">{% trans "Cancel" %}</button>
<button type="submit" class="px-3 py-1.5 bg-red-500 text-white rounded-lg text-xs font-semibold hover:bg-red-600 transition">{% trans "Reject Routing" %}</button>
</div>
</form>
</div>
{% endfor %}
</div>
</div>
<script>
function toggleRejectRouting(pk) {
const form = document.getElementById('reject-routing-' + pk);
if (form) form.classList.toggle('hidden');
}
</script>
{% endif %}
<!-- Department Head Info -->
{% if staff_head %}
<div class="bg-gradient-to-r from-navy/5 to-blue/5 border border-navy/10 rounded-2xl p-5 mb-6 flex items-center gap-4">

View File

@ -1,7 +1,7 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% trans "Departments" %} - PX360{% endblock %}
{% block title %}{% trans "Departments" %}{% endblock %}
{% block extra_css %}
<style>

View File

@ -216,15 +216,18 @@
{{ form.hospital }}
{% endif %}
<!-- Department -->
<div>
<label for="{{ form.department.id_for_label }}" class="form-label">
{% trans "Department" %}
<!-- Departments (multi-select) -->
<div class="md:col-span-2">
<label for="{{ form.departments.id_for_label }}" class="form-label">
{% trans "Departments" %}
</label>
{{ form.department }}
{% if form.department.errors %}
<p class="text-red-500 text-xs mt-1">{{ form.department.errors.0 }}</p>
{{ form.departments }}
{% if form.departments.errors %}
<p class="text-red-500 text-xs mt-1">{{ form.departments.errors.0 }}</p>
{% endif %}
<p class="text-xs text-slate mt-1">
{% trans "Select one or more departments. The team is auto-set from each department's champion and manager." %}
</p>
</div>
</div>
@ -239,7 +242,7 @@
<p class="text-red-500 text-xs mt-1">{{ form.project_lead.errors.0 }}</p>
{% endif %}
</div>
<!-- Status -->
<div>
<label for="{{ form.status.id_for_label }}" class="form-label">
@ -252,16 +255,15 @@
</div>
</div>
<!-- Team Members -->
<!-- Team (derived, read-only) -->
<div class="mt-6">
<label for="{{ form.team_members.id_for_label }}" class="form-label">
<label class="form-label">
{% trans "Team Members" %}
</label>
{{ form.team_members }}
{% if form.team_members.errors %}
<p class="text-red-500 text-xs mt-1">{{ form.team_members.errors.0 }}</p>
{% endif %}
<p class="text-xs text-slate mt-1">{% trans "Hold Ctrl/Cmd to select multiple team members" %}</p>
<p class="text-xs text-slate bg-blue-50 border border-blue-100 rounded-lg p-3">
<i data-lucide="users" class="w-3.5 h-3.5 inline -mt-0.5"></i>
{% blocktrans %}Team members are <strong>derived automatically</strong> from each selected department's champion and manager (plus the project lead). They cannot be edited directly.{% endblocktrans %}
</p>
</div>
<div class="grid grid-cols-2 gap-6 mt-6">
@ -319,54 +321,58 @@
{% block extra_js %}
<script>
// Dynamic department and staff filtering based on hospital selection
// Dynamic departments and project-lead filtering based on hospital selection.
// (team_members is derived server-side, so there is no widget to sync here.)
document.addEventListener('DOMContentLoaded', function() {
const hospitalSelect = document.getElementById('{{ form.hospital.id_for_label }}');
const departmentSelect = document.getElementById('{{ form.department.id_for_label }}');
const departmentsSelect = document.getElementById('{{ form.departments.id_for_label }}');
const projectLeadSelect = document.getElementById('{{ form.project_lead.id_for_label }}');
const teamMembersSelect = document.getElementById('{{ form.team_members.id_for_label }}');
function refreshDepartments(hospitalId) {
if (!departmentsSelect || !hospitalId) return;
fetch(`/api/organizations/hospitals/${hospitalId}/departments/`)
.then(r => r.json())
.then(data => {
// Preserve currently selected values (for edit form)
const previouslySelected = Array.from(departmentsSelect.selectedOptions || []).map(o => o.value);
departmentsSelect.innerHTML = '';
(data.departments || []).forEach(dept => {
const option = document.createElement('option');
option.value = dept.id;
option.textContent = {% if LANG == 'ar' %}dept.name_ar || dept.name{% else %}dept.name{% endif %};
if (previouslySelected.includes(String(dept.id))) {
option.selected = true;
}
departmentsSelect.appendChild(option);
});
});
}
function refreshProjectLead(hospitalId) {
if (!projectLeadSelect || !hospitalId) return;
fetch(`/api/organizations/hospitals/${hospitalId}/staff/`)
.then(r => r.json())
.then(data => {
const previouslySelected = projectLeadSelect.value;
projectLeadSelect.innerHTML = '<option value="">---------</option>';
(data.staff || []).forEach(staff => {
const option = document.createElement('option');
option.value = staff.id;
option.textContent = `${staff.first_name} ${staff.last_name}`;
if (String(staff.id) === String(previouslySelected)) {
option.selected = true;
}
projectLeadSelect.appendChild(option);
});
});
}
if (hospitalSelect) {
hospitalSelect.addEventListener('change', function() {
const hospitalId = this.value;
if (!hospitalId) return;
// Fetch departments and staff for selected hospital
fetch(`/api/organizations/hospitals/${hospitalId}/departments/`)
.then(response => response.json())
.then(data => {
// Update departments
departmentSelect.innerHTML = '<option value="">---------</option>';
data.departments.forEach(dept => {
const option = document.createElement('option');
option.value = dept.id;
option.textContent = {% if LANG == 'ar' %}dept.name_ar || dept.name{% else %}dept.name{% endif %};
departmentSelect.appendChild(option);
});
});
// Fetch staff
fetch(`/api/organizations/hospitals/${hospitalId}/staff/`)
.then(response => response.json())
.then(data => {
// Update project lead
projectLeadSelect.innerHTML = '<option value="">---------</option>';
data.staff.forEach(staff => {
const option = document.createElement('option');
option.value = staff.id;
option.textContent = `${staff.first_name} ${staff.last_name}`;
projectLeadSelect.appendChild(option);
});
// Update team members
teamMembersSelect.innerHTML = '';
data.staff.forEach(staff => {
const option = document.createElement('option');
option.value = staff.id;
option.textContent = `${staff.first_name} ${staff.last_name}`;
teamMembersSelect.appendChild(option);
});
});
refreshDepartments(hospitalId);
refreshProjectLead(hospitalId);
});
}
});

View File

@ -109,10 +109,12 @@
<p class="text-sm text-navy mt-1">{{ project.hospital.name }}</p>
</div>
{% if project.department %}
{% if project.departments.exists %}
<div class="mb-4">
<span class="text-xs font-bold text-slate uppercase">{% trans "Department" %}</span>
<p class="text-sm text-navy mt-1">{{ project.department.name }}</p>
<span class="text-xs font-bold text-slate uppercase">{% trans "Departments" %}</span>
<p class="text-sm text-navy mt-1">
{% for dept in project.departments.all %}{{ dept.name }}{% if not forloop.last %}, {% endif %}{% endfor %}
</p>
</div>
{% endif %}

View File

@ -105,10 +105,12 @@
</p>
</div>
{% if template.department %}
{% if template.departments.exists %}
<div class="mb-4">
<span class="text-xs font-bold text-slate uppercase">{% trans "Department" %}</span>
<p class="text-sm font-medium text-navy mt-1">{{ template.department.name }}</p>
<span class="text-xs font-bold text-slate uppercase">{% trans "Departments" %}</span>
<p class="text-sm font-medium text-navy mt-1">
{% for dept in template.departments.all %}{{ dept.name }}{% if not forloop.last %}, {% endif %}{% endfor %}
</p>
</div>
{% endif %}