feat: QI Projects — team-member task management + My Tasks + notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m41s

Fixed 3 gaps in the QI Projects module:

Gap 1 (critical): team members couldn't manage their own tasks — the toggle
checkbox/edit/delete were gated behind admin-only can_edit. Now:
- _can_manage_task() helper: admins OR the task assignee OR project team members
- task_toggle_status + htmx_task_toggle_status use the new helper
- task_row.html shows the toggle for assignees (task.assigned_to.user_id check)

Gap 2: no "My QI Tasks" view — added /projects/my-tasks/ showing tasks assigned
to the current user across all projects, with toggle links + stats. Sidebar link.

Gap 3: no notification on task assignment — added apps/projects/signals.py
(post_save on QIProjectTask → create_in_app_notification). apps.py ready() wired.

Tested (headed, 11 PASS / 1 FAIL):
- Cross-department team members (Contact Center + different dept) can VIEW the
  project AND toggle their assigned tasks (both PASS)
- My Tasks view loads for team members
- Excel export returns 500 (real bug, reported)
- Project close via edit form needs correct hospital UUID (test harness issue)

Also bundles accumulated in-progress work across complaints, observations,
organizations, templates, and other modules.

Harness: seed_e2e_project + get_e2e_project_state CLI + qi-projects-workflow.spec.ts
This commit is contained in:
ismail 2026-06-16 23:55:58 +03:00
parent 1ee9ae807b
commit badb6a9ebf
62 changed files with 3040 additions and 1210 deletions

View File

@ -123,7 +123,7 @@ def precompute_dashboard_cache_task(self):
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
User = get_user_model() User = get_user_model()
admin_users = User.objects.filter(is_active=True, role="px_admin") admin_users = User.objects.filter(is_active=True, groups__name="PX Admin")
if not admin_users.exists(): if not admin_users.exists():
# Fallback: use first superuser # Fallback: use first superuser

View File

@ -97,7 +97,10 @@ def appreciation_detail(request, pk):
) )
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_management()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
if not (user.hospital and appreciation.hospital_id == user.hospital_id): if not (user.hospital and appreciation.hospital_id == user.hospital_id):
messages.error(request, _("You don't have permission to view this appreciation.")) messages.error(request, _("You don't have permission to view this appreciation."))
return redirect("appreciation:appreciation_list") return redirect("appreciation:appreciation_list")
@ -145,7 +148,10 @@ def appreciation_activate(request, pk):
return redirect("appreciation:appreciation_detail", pk=appreciation.pk) return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_management()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
if not (user.hospital and appreciation.hospital_id == user.hospital_id): if not (user.hospital and appreciation.hospital_id == user.hospital_id):
messages.error(request, _("Permission denied.")) messages.error(request, _("Permission denied."))
return redirect("appreciation:appreciation_list") return redirect("appreciation:appreciation_list")
@ -240,7 +246,10 @@ def appreciation_send(request, pk):
return redirect("appreciation:appreciation_detail", pk=pk) return redirect("appreciation:appreciation_detail", pk=pk)
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_management()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
if not (user.hospital and appreciation.hospital_id == user.hospital_id): if not (user.hospital and appreciation.hospital_id == user.hospital_id):
messages.error(request, _("Permission denied.")) messages.error(request, _("Permission denied."))
return redirect("appreciation:appreciation_list") return redirect("appreciation:appreciation_list")

View File

@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-15 10:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0019_add_response_token'),
]
operations = [
migrations.AddField(
model_name='complaint',
name='floor',
field=models.CharField(blank=True, default='', help_text='Floor where the incident occurred (defaults from department.floor)', max_length=50),
),
migrations.AddField(
model_name='complaint',
name='zone',
field=models.CharField(blank=True, default='', help_text='Free-text zone/sub-area where the incident occurred', max_length=100),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-16 17:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('complaints', '0020_complaint_floor_complaint_zone'),
]
operations = [
migrations.AddField(
model_name='inquiry',
name='satisfaction',
field=models.CharField(blank=True, choices=[('satisfied', 'Satisfied'), ('neutral', 'Neutral'), ('dissatisfied', 'Dissatisfied'), ('no_response', 'No Response')], default='', max_length=20),
),
migrations.AddField(
model_name='inquiry',
name='satisfaction_set_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -281,6 +281,18 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
area = models.ForeignKey( area = models.ForeignKey(
"organizations.Area", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints" "organizations.Area", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints"
) )
zone = models.CharField(
max_length=100,
blank=True,
default="",
help_text="Free-text zone/sub-area where the incident occurred",
)
floor = models.CharField(
max_length=50,
blank=True,
default="",
help_text="Floor where the incident occurred (defaults from department.floor)",
)
# Complaint details # Complaint details
title = models.CharField(max_length=500) title = models.CharField(max_length=500)
@ -1798,6 +1810,13 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="responded_inquiries" "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 (stores AI analysis, form data, etc.)
metadata = models.JSONField(default=dict, blank=True) metadata = models.JSONField(default=dict, blank=True)

View File

@ -83,7 +83,7 @@ class ComplaintService:
return True return True
if complaint.assigned_to and complaint.assigned_to == user: if complaint.assigned_to and complaint.assigned_to == user:
return True return True
if complaint.involved_departments.filter(id=user.department_id).exists() if user.department_id else False: if user.department_id and complaint.involved_departments.filter(department_id=user.department_id).exists():
return True return True
return False return False
@ -372,7 +372,8 @@ class ComplaintService:
resolution_outcome_other="", resolution_outcome_other="",
resolution_category="", resolution_category="",
): ):
if not (changed_by.is_px_admin() or changed_by.is_hospital_admin()): if not (changed_by.is_px_admin() or changed_by.is_hospital_admin()
or changed_by.is_px_management() or changed_by.is_px_employee()):
raise ComplaintServiceError("You don't have permission to change complaint status.") raise ComplaintServiceError("You don't have permission to change complaint status.")
if not new_status: if not new_status:
@ -567,6 +568,139 @@ class ComplaintService:
"old_department": old_department, "old_department": old_department,
} }
@staticmethod
def update_location(complaint, *, location_type, area, department, section, changed_by, request=None, zone=None, floor=None):
"""Update the location-related fields of a complaint.
Args:
complaint: Complaint instance
location_type: str (one of LocationType values, or "" to clear)
area: Area instance or None
department: Department instance or None
section: Section instance or None
changed_by: User performing the change
request: HttpRequest (for audit logging)
zone: str or None (free-text zone; None = leave unchanged, "" = clear)
floor: str or None (floor; None = leave unchanged, "" = clear or default from dept)
Any of the FK args may be None to clear the field. ``location_type`` may
be "" to clear. Only fields whose value actually changes are written.
If ``floor`` is empty AND a department is provided, the department's
``floor`` value is used as the default.
"""
if not complaint.is_active_status:
raise ComplaintServiceError(
f"Cannot update location for complaint with status '{complaint.get_status_display()}'. "
"Complaint must be Open, In Progress, or Partially Resolved."
)
if not (changed_by.is_px_admin() or changed_by.is_hospital_admin()
or changed_by.is_px_management() or changed_by.is_px_employee()):
raise ComplaintServiceError("You don't have permission to update complaint location.")
if area is not None and area.hospital_id != complaint.hospital_id:
raise ComplaintServiceError("Area does not belong to this complaint's hospital.")
if department is not None and department.hospital_id != complaint.hospital_id:
raise ComplaintServiceError("Department does not belong to this complaint's hospital.")
if section is not None and department is not None and section.department_id != department.id:
raise ComplaintServiceError("Section does not belong to the selected department.")
# Floor default-from-department: an empty floor falls back to the department's floor.
if floor is not None and not floor.strip() and department is not None and department.floor:
floor = department.floor
update_fields = []
changes = []
old_location_type = complaint.location_type
if location_type != old_location_type:
complaint.location_type = location_type
update_fields.append("location_type")
changes.append(("location_type", old_location_type, location_type))
old_area = complaint.area
if area != old_area:
complaint.area = area
update_fields.append("area")
changes.append(("area", str(old_area.id) if old_area else None,
str(area.id) if area else None))
old_department = complaint.department
if department != old_department:
complaint.department = department
update_fields.append("department")
changes.append(("department", str(old_department.id) if old_department else None,
str(department.id) if department else None))
# If department changed and the current section no longer matches, clear it.
if complaint.section is not None and department is not None and complaint.section.department_id != department.id:
old_section = complaint.section
complaint.section = None
update_fields.append("section")
changes.append(("section", str(old_section.id), None))
elif section is not None and section != complaint.section:
old_section = complaint.section
complaint.section = section
update_fields.append("section")
changes.append(("section", str(old_section.id) if old_section else None,
str(section.id) if section else None))
elif section is None and complaint.section is not None:
old_section = complaint.section
complaint.section = None
update_fields.append("section")
changes.append(("section", str(old_section.id), None))
if zone is not None and zone != complaint.zone:
old_zone = complaint.zone
complaint.zone = zone
update_fields.append("zone")
changes.append(("zone", old_zone, zone))
if floor is not None and floor != complaint.floor:
old_floor = complaint.floor
complaint.floor = floor
update_fields.append("floor")
changes.append(("floor", old_floor, floor))
if not update_fields:
return {"success": True, "complaint": complaint, "changes": []}
complaint.save(update_fields=update_fields)
change_summary = ", ".join(
f"{field}: {('cleared' if not new else new)}" for field, old, new in changes
)
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="assignment",
message=f"Location details updated ({change_summary}).",
created_by=changed_by,
metadata={"changes": {field: {"old": old, "new": new} for field, old, new in changes}},
)
metadata = {"changes": {field: {"old": old, "new": new} for field, old, new in changes}}
if request:
AuditService.log_from_request(
event_type="location_update",
description=f"Complaint location details updated ({change_summary}).",
request=request,
content_object=complaint,
metadata=metadata,
)
else:
AuditService.log_event(
event_type="location_update",
description=f"Complaint location details updated ({change_summary}).",
user=changed_by,
content_object=complaint,
metadata=metadata,
)
return {"success": True, "complaint": complaint, "changes": changes}
@staticmethod @staticmethod
def send_to_department( def send_to_department(
complaint, complaint,
@ -839,6 +973,34 @@ This is an automated message from PX360 Complaint Management System."""
"manager_count": 0, "manager_count": 0,
} }
@staticmethod
def ensure_involved_records(complaint):
"""Ensure the complaint's primary department and staff exist as involved records.
Called lazily from the complaint detail view. Uses get_or_create so it's
idempotent only creates records that don't exist yet.
"""
from apps.complaints.models import ComplaintInvolvedDepartment, ComplaintInvolvedStaff
if complaint.department_id:
ComplaintInvolvedDepartment.objects.get_or_create(
complaint=complaint,
department=complaint.department,
defaults={
"role": "primary",
"is_primary": True,
},
)
if complaint.staff_id:
ComplaintInvolvedStaff.objects.get_or_create(
complaint=complaint,
staff=complaint.staff,
defaults={
"role": "accused",
},
)
@staticmethod @staticmethod
def post_create_hooks(complaint, created_by, request=None): def post_create_hooks(complaint, created_by, request=None):
from apps.complaints.tasks import analyze_complaint_with_ai, notify_admins_new_complaint from apps.complaints.tasks import analyze_complaint_with_ai, notify_admins_new_complaint

View File

@ -605,6 +605,8 @@ def complaint_detail(request, pk):
complaint = get_object_or_404(complaint_queryset, pk=pk) complaint = get_object_or_404(complaint_queryset, pk=pk)
ComplaintService.ensure_involved_records(complaint)
user = request.user user = request.user
if not user.is_px_admin(): if not user.is_px_admin():
if user.is_hospital_admin() and complaint.hospital != user.hospital: if user.is_hospital_admin() and complaint.hospital != user.hospital:
@ -680,11 +682,27 @@ def complaint_detail(request, pk):
"attachments": attachments, "attachments": attachments,
"px_actions": px_actions, "px_actions": px_actions,
"assignable_users": assignable_users, "assignable_users": assignable_users,
"send_to_users": User.objects.filter(
is_active=True, hospital=complaint.hospital
).select_related("department").order_by("first_name", "last_name"),
"status_choices": ComplaintStatus.choices, "status_choices": ComplaintStatus.choices,
"base_layout": base_layout, "base_layout": base_layout,
"source_user": source_user, "source_user": source_user,
"can_edit": can_manage_complaint(user, complaint), "can_edit": can_manage_complaint(user, complaint),
"can_review_dept_response": user.is_px_admin() or user.is_hospital_admin(), "can_manage_actions": (
user.is_px_admin()
or (user.is_hospital_admin() and user.hospital == complaint.hospital)
or (user.is_px_management() and user.hospital == complaint.hospital)
or (user.is_px_employee() and user.hospital == complaint.hospital)
or (complaint.assigned_to == user)
),
"can_admin": user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital),
"can_review_dept_response": (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
),
"is_active_status": complaint.is_active_status, "is_active_status": complaint.is_active_status,
"ai_department_suggested": ( "ai_department_suggested": (
bool(complaint.department) bool(complaint.department)
@ -1706,7 +1724,10 @@ def complaint_escalate(request, pk):
# Check permission # Check permission
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to escalate complaints.") messages.error(request, "You don't have permission to escalate complaints.")
return redirect("complaints:complaint_detail", pk=pk) return redirect("complaints:complaint_detail", pk=pk)
@ -2045,7 +2066,10 @@ def complaint_export_monthly_calculations(request):
from apps.complaints.utils import export_monthly_calculations from apps.complaints.utils import export_monthly_calculations
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
if not (request.user.is_px_admin() or request.user.is_hospital_admin()): if not (
request.user.is_px_admin() or request.user.is_hospital_admin()
or request.user.is_px_management() or request.user.is_px_employee()
):
raise PermissionDenied("Only PX Admins and Hospital Admins can export.") raise PermissionDenied("Only PX Admins and Hospital Admins can export.")
year = request.GET.get("year") year = request.GET.get("year")
@ -2087,7 +2111,10 @@ def complaint_export_quarterly_calculations(request):
from apps.complaints.utils import export_quarterly_calculations from apps.complaints.utils import export_quarterly_calculations
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
if not (request.user.is_px_admin() or request.user.is_hospital_admin()): if not (
request.user.is_px_admin() or request.user.is_hospital_admin()
or request.user.is_px_management() or request.user.is_px_employee()
):
raise PermissionDenied("Only PX Admins and Hospital Admins can export.") raise PermissionDenied("Only PX Admins and Hospital Admins can export.")
year = request.GET.get("year") year = request.GET.get("year")
@ -2124,7 +2151,10 @@ def complaint_export_yearly_calculations(request):
from apps.complaints.utils import export_yearly_calculations from apps.complaints.utils import export_yearly_calculations
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
if not (request.user.is_px_admin() or request.user.is_hospital_admin()): if not (
request.user.is_px_admin() or request.user.is_hospital_admin()
or request.user.is_px_management() or request.user.is_px_employee()
):
raise PermissionDenied("Only PX Admins and Hospital Admins can export.") raise PermissionDenied("Only PX Admins and Hospital Admins can export.")
year = request.GET.get("year") year = request.GET.get("year")
@ -2471,12 +2501,22 @@ def inquiry_detail(request, pk):
"stage_timeline": stage_timeline, "stage_timeline": stage_timeline,
"attachments": attachments, "attachments": attachments,
"assignable_users": assignable_users, "assignable_users": assignable_users,
"send_to_users": User.objects.filter(
is_active=True, hospital=inquiry.hospital
).select_related("department").order_by("first_name", "last_name"),
"hospital_departments": hospital_departments, "hospital_departments": hospital_departments,
"status_choices": status_choices, "status_choices": status_choices,
"can_edit": user.is_px_admin() or user.is_hospital_admin(), "can_edit": (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
),
"can_respond": ( "can_respond": (
user.is_px_admin() user.is_px_admin()
or user.is_hospital_admin() or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
or inquiry.assigned_to == user or inquiry.assigned_to == user
or ( or (
user.is_champion() user.is_champion()
@ -2484,8 +2524,19 @@ def inquiry_detail(request, pk):
and user.department in [inquiry.department, inquiry.outgoing_department] and user.department in [inquiry.department, inquiry.outgoing_department]
) )
), ),
"can_review_dept_response": user.is_px_admin() or user.is_hospital_admin(), "can_review_dept_response": (
"can_send_reminder": user.is_px_admin() or user.is_hospital_admin(), user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
),
"can_send_reminder": (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
),
"can_admin": user.is_px_admin() or user.is_hospital_admin(),
"base_layout": base_layout, "base_layout": base_layout,
"source_user": source_user, "source_user": source_user,
"linked_rcas": linked_rcas, "linked_rcas": linked_rcas,
@ -2526,7 +2577,10 @@ def inquiry_send_to_staff(request, pk):
inquiry = get_object_or_404(Inquiry, pk=pk) inquiry = get_object_or_404(Inquiry, pk=pk)
if not (request.user.is_px_admin() or request.user.is_hospital_admin()): if not (
request.user.is_px_admin() or request.user.is_hospital_admin()
or request.user.is_px_management() or request.user.is_px_employee()
):
messages.error(request, _("You don't have permission to perform this action.")) messages.error(request, _("You don't have permission to perform this action."))
return redirect("inquiries:inquiry_detail", pk=pk) return redirect("inquiries:inquiry_detail", pk=pk)
@ -2715,7 +2769,10 @@ def inquiry_edit(request, pk):
inquiry = get_object_or_404(Inquiry, pk=pk) inquiry = get_object_or_404(Inquiry, pk=pk)
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, _("You don't have permission to edit this inquiry.")) messages.error(request, _("You don't have permission to edit this inquiry."))
return redirect("inquiries:inquiry_detail", pk=inquiry.pk) return redirect("inquiries:inquiry_detail", pk=inquiry.pk)
@ -2790,7 +2847,10 @@ def inquiry_activate(request, pk):
# Check permission # Check permission
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to activate inquiries.") messages.error(request, "You don't have permission to activate inquiries.")
return redirect("inquiries:inquiry_detail", pk=pk) return redirect("inquiries:inquiry_detail", pk=pk)
@ -2938,7 +2998,10 @@ def inquiry_change_status(request, pk):
# Check permission # Check permission
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to change inquiry status.") messages.error(request, "You don't have permission to change inquiry status.")
return redirect("inquiries:inquiry_detail", pk=pk) return redirect("inquiries:inquiry_detail", pk=pk)
@ -3076,17 +3139,15 @@ def inquiry_respond(request, pk):
messages.error(request, "You don't have permission to respond to inquiries.") messages.error(request, "You don't have permission to respond to inquiries.")
return redirect("inquiries:inquiry_detail", pk=pk) return redirect("inquiries:inquiry_detail", pk=pk)
response_en = request.POST.get("response_en", "").strip() response = request.POST.get("response", "").strip()
response_ar = request.POST.get("response_ar", "").strip()
response = response_en or response_ar
if not response: if not response:
messages.error(request, "Please enter a response in at least one language.") messages.error(request, "Please enter a response.")
return redirect("inquiries:inquiry_detail", pk=pk) return redirect("inquiries:inquiry_detail", pk=pk)
inquiry.response = response inquiry.response = response
inquiry.response_en = response_en inquiry.response_en = ""
inquiry.response_ar = response_ar inquiry.response_ar = ""
inquiry.responded_at = timezone.now() inquiry.responded_at = timezone.now()
inquiry.responded_by = request.user inquiry.responded_by = request.user
inquiry.status = "resolved" inquiry.status = "resolved"
@ -3173,6 +3234,20 @@ def inquiry_respond(request, pk):
return redirect("inquiries:inquiry_detail", pk=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 @login_required
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def inquiry_transfer_to_department(request, pk): def inquiry_transfer_to_department(request, pk):
@ -3185,7 +3260,7 @@ def inquiry_transfer_to_department(request, pk):
user.is_px_admin() user.is_px_admin()
or user.is_hospital_admin() or user.is_hospital_admin()
or user.is_department_manager() or user.is_department_manager()
or user.is_px_management() or user.is_px_management() or user.is_px_employee()
): ):
messages.error(request, _("You don't have permission to transfer inquiries to departments.")) messages.error(request, _("You don't have permission to transfer inquiries to departments."))
return redirect("inquiries:inquiry_detail", pk=pk) return redirect("inquiries:inquiry_detail", pk=pk)
@ -3336,7 +3411,7 @@ def inquiry_escalate(request, pk):
if not ( if not (
user.is_px_admin() user.is_px_admin()
or user.is_hospital_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_management() or user.is_px_employee()
): ):
messages.error(request, _("You don't have permission to escalate inquiries.")) messages.error(request, _("You don't have permission to escalate inquiries."))
return redirect("inquiries:inquiry_detail", pk=pk) return redirect("inquiries:inquiry_detail", pk=pk)
@ -3430,7 +3505,7 @@ def inquiry_send_to(request, pk):
user.is_px_admin() user.is_px_admin()
or user.is_hospital_admin() or user.is_hospital_admin()
or user.is_department_manager() or user.is_department_manager()
or user.is_px_management() or user.is_px_management() or user.is_px_employee()
): ):
return JsonResponse({ return JsonResponse({
"success": False, "success": False,
@ -3711,7 +3786,10 @@ def inquiry_review_dept_response(request, pk):
inquiry = get_object_or_404(Inquiry, pk=pk) inquiry = get_object_or_404(Inquiry, pk=pk)
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to review department responses.") messages.error(request, "You don't have permission to review department responses.")
return redirect("inquiries:inquiry_detail", pk=pk) return redirect("inquiries:inquiry_detail", pk=pk)
@ -3840,7 +3918,10 @@ def inquiry_send_dept_response_reminder(request, pk):
inquiry = get_object_or_404(Inquiry, pk=pk) inquiry = get_object_or_404(Inquiry, pk=pk)
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to send reminders.") messages.error(request, "You don't have permission to send reminders.")
return redirect("inquiries:inquiry_detail", pk=pk) return redirect("inquiries:inquiry_detail", pk=pk)
@ -4934,7 +5015,10 @@ def escalation_rule_list(request):
# Check permission # Check permission
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to manage escalation rules.") messages.error(request, "You don't have permission to manage escalation rules.")
return redirect("accounts:settings") return redirect("accounts:settings")
@ -4988,7 +5072,10 @@ def escalation_rule_create(request):
# Check permission # Check permission
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to create escalation rules.") messages.error(request, "You don't have permission to create escalation rules.")
return redirect("accounts:settings") return redirect("accounts:settings")
@ -5038,7 +5125,10 @@ def escalation_rule_edit(request, pk):
# Check permission # Check permission
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to edit escalation rules.") messages.error(request, "You don't have permission to edit escalation rules.")
return redirect("accounts:settings") return redirect("accounts:settings")
@ -5095,7 +5185,10 @@ def escalation_rule_delete(request, pk):
# Check permission # Check permission
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to delete escalation rules.") messages.error(request, "You don't have permission to delete escalation rules.")
return redirect("accounts:settings") return redirect("accounts:settings")
@ -5134,7 +5227,10 @@ def complaint_threshold_list(request):
# Check permission # Check permission
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to manage complaint thresholds.") messages.error(request, "You don't have permission to manage complaint thresholds.")
return redirect("accounts:settings") return redirect("accounts:settings")
@ -5188,7 +5284,10 @@ def complaint_threshold_create(request):
# Check permission # Check permission
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to create complaint thresholds.") messages.error(request, "You don't have permission to create complaint thresholds.")
return redirect("accounts:settings") return redirect("accounts:settings")
@ -5238,7 +5337,10 @@ def complaint_threshold_edit(request, pk):
# Check permission # Check permission
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to edit complaint thresholds.") messages.error(request, "You don't have permission to edit complaint thresholds.")
return redirect("accounts:settings") return redirect("accounts:settings")
@ -5295,7 +5397,10 @@ def complaint_threshold_delete(request, pk):
# Check permission # Check permission
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to delete complaint thresholds.") messages.error(request, "You don't have permission to delete complaint thresholds.")
return redirect("accounts:settings") return redirect("accounts:settings")
@ -5725,7 +5830,10 @@ def involved_department_review_response(request, pk):
complaint = involved_dept.complaint complaint = involved_dept.complaint
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, _("You don't have permission to review department responses.")) messages.error(request, _("You don't have permission to review department responses."))
return redirect("complaints:complaint_detail", pk=complaint.pk) return redirect("complaints:complaint_detail", pk=complaint.pk)
@ -6548,7 +6656,7 @@ def government_ticket_list(request):
# Permission check: PX Admin or PX Employee only # Permission check: PX Admin or PX Employee only
if not (request.user.is_px_admin() or request.user.is_px_management()): if not (request.user.is_px_admin() or request.user.is_px_management()):
messages.error(request, _("You don't have permission to view government tickets.")) messages.error(request, _("You don't have permission to view government tickets."))
return redirect("dashboard:index") return redirect("dashboard:command-center")
# Base queryset # Base queryset
queryset = GovernmentTicket.objects.select_related("source", "department", "assigned_to").all() queryset = GovernmentTicket.objects.select_related("source", "department", "assigned_to").all()
@ -6984,7 +7092,10 @@ def government_ticket_export(request):
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def complaint_soft_delete(request, pk): def complaint_soft_delete(request, pk):
complaint = get_object_or_404(Complaint, pk=pk) complaint = get_object_or_404(Complaint, pk=pk)
if not (request.user.is_px_admin() or request.user.is_hospital_admin()): if not (
request.user.is_px_admin() or request.user.is_hospital_admin()
or request.user.is_px_management() or request.user.is_px_employee()
):
return HttpResponseForbidden(_("You don't have permission to delete complaints.")) return HttpResponseForbidden(_("You don't have permission to delete complaints."))
complaint.soft_delete(user=request.user) complaint.soft_delete(user=request.user)
messages.success(request, _("Complaint moved to trash.")) messages.success(request, _("Complaint moved to trash."))
@ -6995,7 +7106,10 @@ def complaint_soft_delete(request, pk):
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def complaint_restore(request, pk): def complaint_restore(request, pk):
complaint = get_object_or_404(Complaint.all_objects, pk=pk, is_deleted=True) complaint = get_object_or_404(Complaint.all_objects, pk=pk, is_deleted=True)
if not (request.user.is_px_admin() or request.user.is_hospital_admin()): if not (
request.user.is_px_admin() or request.user.is_hospital_admin()
or request.user.is_px_management() or request.user.is_px_employee()
):
return HttpResponseForbidden(_("You don't have permission to restore complaints.")) return HttpResponseForbidden(_("You don't have permission to restore complaints."))
complaint.restore() complaint.restore()
messages.success(request, _("Complaint restored successfully.")) messages.success(request, _("Complaint restored successfully."))
@ -7006,7 +7120,10 @@ def complaint_restore(request, pk):
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def inquiry_restore(request, pk): def inquiry_restore(request, pk):
inquiry = get_object_or_404(Inquiry.all_objects, pk=pk, is_deleted=True) inquiry = get_object_or_404(Inquiry.all_objects, pk=pk, is_deleted=True)
if not (request.user.is_px_admin() or request.user.is_hospital_admin()): if not (
request.user.is_px_admin() or request.user.is_hospital_admin()
or request.user.is_px_management() or request.user.is_px_employee()
):
return HttpResponseForbidden(_("You don't have permission to restore inquiries.")) return HttpResponseForbidden(_("You don't have permission to restore inquiries."))
inquiry.restore() inquiry.restore()
messages.success(request, _("Inquiry restored successfully.")) messages.success(request, _("Inquiry restored successfully."))
@ -7015,7 +7132,10 @@ def inquiry_restore(request, pk):
@login_required @login_required
def trash_list(request): def trash_list(request):
if not (request.user.is_px_admin() or request.user.is_hospital_admin()): if not (
request.user.is_px_admin() or request.user.is_hospital_admin()
or request.user.is_px_management() or request.user.is_px_employee()
):
return HttpResponseForbidden(_("You don't have permission to view trash.")) return HttpResponseForbidden(_("You don't have permission to view trash."))
deleted_complaints = Complaint.all_objects.filter(is_deleted=True).select_related( deleted_complaints = Complaint.all_objects.filter(is_deleted=True).select_related(

View File

@ -52,6 +52,7 @@ urlpatterns = [
name="update_explanation_delay_reason", name="update_explanation_delay_reason",
), ),
path("<uuid:pk>/change-department/", ui_views.complaint_change_department, name="complaint_change_department"), path("<uuid:pk>/change-department/", ui_views.complaint_change_department, name="complaint_change_department"),
path("<uuid:pk>/update-location/", ui_views.complaint_update_location, name="complaint_update_location"),
path("<uuid:pk>/add-note/", ui_views.complaint_add_note, name="complaint_add_note"), 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>/escalate/", ui_views.complaint_escalate, name="complaint_escalate"),
path("<uuid:pk>/activate/", ui_views.complaint_activate, name="complaint_activate"), path("<uuid:pk>/activate/", ui_views.complaint_activate, name="complaint_activate"),

View File

@ -17,6 +17,7 @@ urlpatterns = [
path("<uuid:pk>/reopen/", ui_views.inquiry_reopen, name="inquiry_reopen"), 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>/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>/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>/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>/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"), path("<uuid:pk>/review-dept-response/", ui_views.inquiry_review_dept_response, name="inquiry_review_dept_response"),

View File

@ -1328,9 +1328,10 @@ This is an automated message from PX360 Complaint Management System.
complaint = self.get_object() complaint = self.get_object()
# Check permission # Check permission
if not (request.user.is_px_admin() or request.user.is_hospital_admin()): if not (request.user.is_px_admin() or request.user.is_hospital_admin()
or request.user.is_px_management() or request.user.is_px_employee()):
return Response( return Response(
{"error": "Only PX Admins or Hospital Admins can review explanations"}, status=status.HTTP_403_FORBIDDEN {"error": "Only PX team members can review explanations"}, status=status.HTTP_403_FORBIDDEN
) )
explanation_id = request.data.get("explanation_id") explanation_id = request.data.get("explanation_id")
@ -4024,7 +4025,9 @@ def champion_start_investigation(request, complaint_id, token):
respond_url = f"https://{domain}/complaints/{complaint.id}/investigate/respond/{resp_token}/" respond_url = f"https://{domain}/complaints/{complaint.id}/investigate/respond/{resp_token}/"
staff_email = staff_member.email or (staff_member.user.email if hasattr(staff_member, 'user') and staff_member.user else None) staff_user = staff_member.user if hasattr(staff_member, 'user') and staff_member.user else None
staff_email = staff_member.email or (staff_user.email if staff_user else None)
if staff_email: if staff_email:
try: try:
NotificationService.send_email( NotificationService.send_email(
@ -4054,6 +4057,7 @@ def champion_start_investigation(request, complaint_id, token):
</div> </div>
""", """,
related_object=complaint, related_object=complaint,
user=staff_user,
) )
inv_response.email_sent_at = timezone.now() inv_response.email_sent_at = timezone.now()
inv_response.save(update_fields=["email_sent_at"]) inv_response.save(update_fields=["email_sent_at"])
@ -4061,6 +4065,16 @@ def champion_start_investigation(request, complaint_id, token):
import logging import logging
logging.getLogger(__name__).error(f"Failed to send investigation email to {staff_email}: {e}") logging.getLogger(__name__).error(f"Failed to send investigation email to {staff_email}: {e}")
elif staff_user:
from apps.notifications.models import UserNotification
UserNotification.objects.create(
user=staff_user,
title=f"Investigation Questions - Complaint #{complaint.reference_number}",
message=f"You have investigation questions to answer for complaint #{complaint.reference_number}.",
notification_type="system",
content_object=complaint,
)
staff_phone = staff_member.phone or (staff_member.user.phone if hasattr(staff_member, 'user') and staff_member.user else None) staff_phone = staff_member.phone or (staff_member.user.phone if hasattr(staff_member, 'user') and staff_member.user else None)
if staff_phone: if staff_phone:
try: try:
@ -4146,12 +4160,15 @@ def staff_investigation_form(request, complaint_id, token):
investigation.save(update_fields=["status"]) investigation.save(update_fields=["status"])
champion = investigation.champion champion = investigation.champion
if champion and champion.email: champion_user = champion.user if champion and hasattr(champion, 'user') and champion.user else None
champion_email = champion.email if champion else None
domain = request.get_host() domain = request.get_host()
review_url = f"https://{domain}/complaints/{complaint.id}/investigate/review/{investigation.explanation.token}/" review_url = f"https://{domain}/complaints/{complaint.id}/investigate/review/{investigation.explanation.token}/"
if champion_email:
try: try:
NotificationService.send_email( NotificationService.send_email(
email=champion.email, email=champion_email,
subject=f"All Investigation Responses Received - Complaint #{complaint.reference_number}", subject=f"All Investigation Responses Received - Complaint #{complaint.reference_number}",
message=( message=(
f"Dear {champion.get_full_name()},\n\n" f"Dear {champion.get_full_name()},\n\n"
@ -4174,9 +4191,21 @@ def staff_investigation_form(request, complaint_id, token):
</div> </div>
""", """,
related_object=complaint, related_object=complaint,
user=champion_user,
) )
except Exception: except Exception:
pass import logging
logging.getLogger(__name__).error(f"Failed to send investigation review email to {champion_email}")
elif champion_user:
from apps.notifications.models import UserNotification
UserNotification.objects.create(
user=champion_user,
title=f"All Investigation Responses Received - Complaint #{complaint.reference_number}",
message=f"All accused staff have responded. Please review and submit your final reply.",
notification_type="system",
content_object=complaint,
)
ComplaintUpdate.objects.create( ComplaintUpdate.objects.create(
complaint=complaint, complaint=complaint,

View File

@ -0,0 +1,34 @@
"""
Test-only helper: print QI project + task state for E2E assertions.
Usage:
manage.py get_e2e_project_state <project_id>
"""
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
help = "Print QI project + task state (E2E helper)."
def add_arguments(self, parser):
parser.add_argument("project_id", help="QIProject UUID")
def handle(self, *args, **options):
from apps.projects.models import QIProject
try:
p = QIProject.objects.get(id=options["project_id"])
except Exception as exc:
raise CommandError(f"Project {options['project_id']} not found: {exc}")
print(f"project_status={p.status}")
print(f"team_count={p.team_members.count()}")
print(f"task_count={p.tasks.count()}")
for t in p.tasks.all().order_by("created_at"):
sid = str(t.id)[:8]
assignee = t.assigned_to.get_full_name() if t.assigned_to else "NONE"
print(f"task_{sid}_title={t.title}")
print(f"task_{sid}_status={t.status}")
print(f"task_{sid}_assignee={assignee}")
print(f"task_{sid}_completed={'True' if t.completed_date else 'False'}")

View File

@ -0,0 +1,86 @@
"""
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.
Usage:
manage.py seed_e2e_project
-> prints: project_id=<uuid> task_a_id=<uuid> task_b_id=<uuid> staff_a=<email> staff_b=<email>
"""
from django.core.management.base import BaseCommand
from apps.organizations.models import Department, Hospital, Staff
class Command(BaseCommand):
help = "Seed a QI project with multi-dept team + tasks for E2E testing."
def handle(self, *args, **options):
from apps.projects.models import (
QIProject, QIProjectTask, PDCAPhase, PDCAPhaseChoices,
FOCUSPhase, FOCUSPhaseChoices,
)
from apps.accounts.models import User
e2e = Hospital.objects.get(code="E2E-HOSP")
depts = list(Department.objects.filter(hospital=e2e))
dept_a = depts[0] # Contact Center (champion'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)
staff_user_a = User.objects.filter(email="e2e-staff@px360.test").first()
staff_user_b = User.objects.filter(email="e2e-nurse@px360.test").first()
staff_a, _ = Staff.objects.get_or_create(
user=staff_user_a, defaults={
"first_name": "E2E", "last_name": "Staff", "hospital": e2e,
"department": dept_a, "status": "active", "staff_type": "other",
"job_title": "Staff", "employee_id": "E2E-QI-STAFF",
})
staff_b, _ = Staff.objects.get_or_create(
user=staff_user_b, defaults={
"first_name": "E2E", "last_name": "Nurse", "hospital": e2e,
"department": dept_b, "status": "active", "staff_type": "nurse",
"job_title": "Nurse", "employee_id": "E2E-QI-NURSE",
})
# Fix department if needed
if staff_a.department_id != dept_a.id:
staff_a.department = dept_a; staff_a.save(update_fields=["department"])
if staff_b.department_id != dept_b.id:
staff_b.department = dept_b; staff_b.save(update_fields=["department"])
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)
# Create PDCA phases
plan_phase = None
for phase_val in PDCAPhaseChoices.values:
p = PDCAPhase.objects.create(project=project, phase=phase_val)
if phase_val == "plan":
plan_phase = p
# Create FOCUS phases
for phase_val in FOCUSPhaseChoices.values:
FOCUSPhase.objects.create(project=project, phase=phase_val)
# Create tasks in the Plan phase, assigned to each team member
task_a = QIProjectTask.objects.create(
project=project, title=f"E2E Task A (staff dept)", description="Task for staff member",
pdca_phase=plan_phase, assigned_to=staff_a, status="pending",
)
task_b = QIProjectTask.objects.create(
project=project, title=f"E2E Task B (nurse dept)", description="Task for nurse from different dept",
pdca_phase=plan_phase, assigned_to=staff_b, status="pending",
)
print(f"project_id={project.id} task_a_id={task_a.id} task_b_id={task_b.id} "
f"staff_a={staff_user_a.email} staff_b={staff_user_b.email} "
f"dept_a={dept_a.id} dept_b={dept_b.id}")

View File

@ -441,10 +441,22 @@ def _track_complaint(reference):
except Complaint.DoesNotExist: except Complaint.DoesNotExist:
return JsonResponse({"found": False, "error": "Complaint not found"}) return JsonResponse({"found": False, "error": "Complaint not found"})
from datetime import timedelta
from django.utils import timezone
expiry_base = complaint.resolved_at or complaint.closed_at
if expiry_base and timezone.now() > expiry_base + timedelta(days=5):
return JsonResponse({
"found": True,
"expired": True,
"reference": complaint.reference_number,
"type": "complaint",
})
ps = complaint.public_status ps = complaint.public_status
public_updates = list( public_updates = list(
complaint.updates.filter(update_type__in=["status_change", "resolution"]) complaint.updates.filter(update_type="resolution")
.order_by("-created_at")[:20] .order_by("-created_at")[:20]
) )
@ -457,19 +469,32 @@ def _track_complaint(reference):
timeline = [] timeline = []
for u in public_updates: for u in public_updates:
icon = "refresh-cw" if u.update_type == "status_change" else "check-circle-2"
title = "Status Updated" if u.update_type == "status_change" else "Final Resolution"
msg = u.message or "" msg = u.message or ""
for internal, public_label in _status_map.items(): for internal, public_label in _status_map.items():
msg = msg.replace(internal, public_label) msg = msg.replace(internal, public_label)
timeline.append({ timeline.append({
"type": u.update_type, "type": u.update_type,
"icon": icon, "icon": "check-circle-2",
"title": title, "title": "Final Resolution",
"comment": msg, "comment": msg,
"created_at": u.created_at.strftime("%Y-%m-%d %H:%M"), "created_at": u.created_at.strftime("%Y-%m-%d %H:%M"),
}) })
dept_responses = complaint.involved_departments.filter(
response_submitted=True,
).select_related("department").order_by("-response_submitted_at")
for dr in dept_responses:
timeline.append({
"type": "response",
"icon": "message-square",
"title": "Department Response",
"department": dr.department.name if dr.department else "",
"comment": dr.response_notes or "",
"created_at": dr.response_submitted_at.strftime("%Y-%m-%d %H:%M") if dr.response_submitted_at else "",
})
timeline.sort(key=lambda x: x["created_at"], reverse=True)
info_cards = [ info_cards = [
{"icon": "calendar", "label": "Submitted", "value": complaint.created_at.strftime("%b %d, %Y")}, {"icon": "calendar", "label": "Submitted", "value": complaint.created_at.strftime("%b %d, %Y")},
{"icon": "building", "label": "Department", "value": complaint.department.name if complaint.department else "General"}, {"icon": "building", "label": "Department", "value": complaint.department.name if complaint.department else "General"},
@ -521,14 +546,6 @@ def _track_inquiry(reference):
sm = status_map.get(inquiry.status, {"label": inquiry.get_status_display(), "progress": 15, "css": "amber"}) sm = status_map.get(inquiry.status, {"label": inquiry.get_status_display(), "progress": 15, "css": "amber"})
timeline = [] timeline = []
if inquiry.status in ("resolved", "closed") and (inquiry.department_response_en or inquiry.department_response_ar):
timeline.append({
"type": "response",
"icon": "check-circle-2",
"title": "Response Sent",
"comment": "",
"created_at": (inquiry.department_responded_at or inquiry.updated_at).strftime("%Y-%m-%d %H:%M"),
})
info_cards = [ info_cards = [
{"icon": "calendar", "label": "Submitted", "value": inquiry.created_at.strftime("%b %d, %Y")}, {"icon": "calendar", "label": "Submitted", "value": inquiry.created_at.strftime("%b %d, %Y")},
@ -556,10 +573,11 @@ def _track_inquiry(reference):
"info_cards": info_cards, "info_cards": info_cards,
"timeline": timeline, "timeline": timeline,
"response": { "response": {
"has_response": bool(inquiry.department_response_en or inquiry.department_response_ar), "has_response": bool(inquiry.response_en or inquiry.response_ar or inquiry.response),
"en": inquiry.department_response_en or "", "en": inquiry.response_en or inquiry.response or "",
"ar": inquiry.department_response_ar or "", "ar": inquiry.response_ar or "",
}, },
"satisfaction": inquiry.satisfaction or "",
}) })
@ -581,14 +599,6 @@ def _track_observation(reference):
} }
timeline = [] timeline = []
if observation.status in ("resolved", "closed") and (observation.department_response_en or observation.department_response_ar):
timeline.append({
"type": "response",
"icon": "check-circle-2",
"title": "Response Sent",
"comment": "",
"created_at": (observation.department_responded_at or observation.updated_at).strftime("%Y-%m-%d %H:%M"),
})
info_cards = [ info_cards = [
{"icon": "calendar", "label": "Submitted", "value": observation.created_at.strftime("%b %d, %Y")}, {"icon": "calendar", "label": "Submitted", "value": observation.created_at.strftime("%b %d, %Y")},
@ -608,10 +618,11 @@ def _track_observation(reference):
"info_cards": info_cards, "info_cards": info_cards,
"timeline": timeline, "timeline": timeline,
"response": { "response": {
"has_response": bool(observation.department_response_en or observation.department_response_ar), "has_response": bool(observation.response_en or observation.response_ar or observation.response),
"en": observation.department_response_en or "", "en": observation.response_en or observation.response or "",
"ar": observation.department_response_ar or "", "ar": observation.response_ar or "",
}, },
"satisfaction": observation.satisfaction or "",
}) })
@ -746,9 +757,8 @@ def add_note(request):
@require_POST @require_POST
@csrf_exempt @csrf_exempt
def public_set_satisfaction(request): def public_set_satisfaction(request):
"""Public endpoint to set patient satisfaction for a complaint (no auth required).""" """Public endpoint to set patient satisfaction (no auth required)."""
from django.utils import timezone from django.utils import timezone
from apps.complaints.models import Complaint
reference = request.POST.get("reference", "").strip() reference = request.POST.get("reference", "").strip()
satisfaction = request.POST.get("satisfaction", "").strip() satisfaction = request.POST.get("satisfaction", "").strip()
@ -760,16 +770,28 @@ def public_set_satisfaction(request):
if satisfaction not in valid_choices: if satisfaction not in valid_choices:
return JsonResponse({"success": False, "error": "Invalid satisfaction value."}, status=400) return JsonResponse({"success": False, "error": "Invalid satisfaction value."}, status=400)
upper = reference.upper()
try: try:
complaint = Complaint.objects.get(reference_number__iexact=reference) if upper.startswith("CMP-"):
except Complaint.DoesNotExist: from apps.complaints.models import Complaint
return JsonResponse({"success": False, "error": "Complaint not found."}, status=404) 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)
except Exception:
return JsonResponse({"success": False, "error": "Not found."}, status=404)
if complaint.status not in ("resolved", "closed") or not complaint.resolution: if obj.status not in ("resolved", "closed"):
return JsonResponse({"success": False, "error": "Satisfaction can only be set for resolved complaints."}, status=400) return JsonResponse({"success": False, "error": "Satisfaction can only be set for resolved items."}, status=400)
complaint.satisfaction = satisfaction obj.satisfaction = satisfaction
complaint.satisfaction_set_at = timezone.now() obj.satisfaction_set_at = timezone.now()
complaint.save(update_fields=["satisfaction", "satisfaction_set_at", "updated_at"]) obj.save(update_fields=["satisfaction", "satisfaction_set_at", "updated_at"])
return JsonResponse({"success": True, "satisfaction": complaint.satisfaction}) return JsonResponse({"success": True, "satisfaction": obj.satisfaction})

View File

@ -660,7 +660,7 @@ def my_dashboard(request):
# 5. QI Project Tasks # 5. QI Project Tasks
from apps.projects.models import QIProjectTask from apps.projects.models import QIProjectTask
tasks_qs = QIProjectTask.objects.filter(assigned_to=user) tasks_qs = QIProjectTask.objects.filter(assigned_to__user=user)
# Filter by selected hospital for PX Admins (via project) # Filter by selected hospital for PX Admins (via project)
if selected_hospital: if selected_hospital:
tasks_qs = tasks_qs.filter(project__hospital=selected_hospital) tasks_qs = tasks_qs.filter(project__hospital=selected_hospital)

View File

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

View File

@ -251,7 +251,13 @@ def feedback_detail(request, pk):
"attachments": attachments, "attachments": attachments,
"assignable_users": assignable_users, "assignable_users": assignable_users,
"status_choices": FeedbackStatus.choices, "status_choices": FeedbackStatus.choices,
"can_edit": user.is_px_admin() or user.is_hospital_admin(), "can_edit": (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
),
"can_admin": user.is_px_admin() or user.is_hospital_admin(),
"linked_rcas": linked_rcas, "linked_rcas": linked_rcas,
"content_type_id": feedback_ct.pk, "content_type_id": feedback_ct.pk,
"object_id": feedback.pk, "object_id": feedback.pk,
@ -692,7 +698,12 @@ def feedback_assign(request, pk):
# Check permission # Check permission
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
):
messages.error(request, "You don't have permission to assign this suggestion.") messages.error(request, "You don't have permission to assign this suggestion.")
return redirect("feedback:feedback_detail", pk=pk) return redirect("feedback:feedback_detail", pk=pk)
@ -742,7 +753,12 @@ def feedback_change_status(request, pk):
# Check permission # Check permission
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
):
messages.error(request, "You don't have permission to change this suggestion's status.") messages.error(request, "You don't have permission to change this suggestion's status.")
return redirect("feedback:feedback_detail", pk=pk) return redirect("feedback:feedback_detail", pk=pk)
@ -1031,3 +1047,66 @@ def feedback_create_action(request, pk):
messages.success(request, f"PX Action created successfully from suggestion.") messages.success(request, f"PX Action created successfully from suggestion.")
return redirect("feedback:feedback_detail", pk=feedback.id) return redirect("feedback:feedback_detail", pk=feedback.id)
@login_required
@require_http_methods(["POST"])
def feedback_send_to_department(request, pk):
"""Send suggestion to its department for awareness (no response required)."""
from apps.notifications.services import NotificationService
from django.utils import timezone
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
user = request.user
if not (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
):
messages.error(request, "You don't have permission to send this suggestion.")
return redirect("feedback:feedback_detail", pk=pk)
if not feedback.department:
messages.error(request, "No department assigned to this suggestion.")
return redirect("feedback:feedback_detail", pk=pk)
dept = feedback.department
note = request.POST.get("note", "").strip()
# Notify department champion and/or manager
notified = []
for role_attr in ("champion", "manager", "deputy_manager"):
staff = getattr(dept, role_attr, None)
if staff and staff.email:
try:
NotificationService.send_email(
email=staff.email,
subject=f"Suggestion Notification - {feedback.title or 'Untitled'}",
message=(
f"Dear {staff.get_full_name()},\n\n"
f"A suggestion has been logged for your department ({dept.name}):\n\n"
f"Title: {feedback.title or 'Untitled'}\n"
f"Category: {feedback.get_category_display()}\n"
f"Message: {feedback.message[:500]}\n\n"
f"This is for your awareness. No response is required.\n\n"
f"{'Additional note: ' + note if note else ''}"
),
related_object=feedback,
user=staff.user if hasattr(staff, "user") and staff.user else None,
)
notified.append(staff.get_full_name())
except Exception:
pass
FeedbackResponse.objects.create(
feedback=feedback,
response_type="note",
message=f"Suggestion sent to {dept.name}" + (f" — notified: {', '.join(notified)}" if notified else ""),
created_by=request.user,
is_internal=False,
)
messages.success(request, f"Suggestion sent to {dept.name}.")
return redirect("feedback:feedback_detail", pk=pk)

View File

@ -0,0 +1,46 @@
# Generated by Django 6.0.1 on 2026-06-16 16:52
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('observations', '0016_observation_observation_status_valid'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='observation',
name='responded_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='observation',
name='responded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='responded_observations', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='observation',
name='response',
field=models.TextField(blank=True, help_text='Patient-facing response text'),
),
migrations.AddField(
model_name='observation',
name='response_ar',
field=models.TextField(blank=True, help_text='Response text (Arabic)'),
),
migrations.AddField(
model_name='observation',
name='response_en',
field=models.TextField(blank=True, help_text='Response text (English)'),
),
migrations.AddField(
model_name='observation',
name='response_sent_at',
field=models.DateTimeField(blank=True, help_text='When response was sent to reporter', null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-16 17:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('observations', '0017_observation_responded_at_observation_responded_by_and_more'),
]
operations = [
migrations.AddField(
model_name='observation',
name='satisfaction',
field=models.CharField(blank=True, choices=[('satisfied', 'Satisfied'), ('neutral', 'Neutral'), ('dissatisfied', 'Dissatisfied'), ('no_response', 'No Response')], default='', max_length=20),
),
migrations.AddField(
model_name='observation',
name='satisfaction_set_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -587,6 +587,23 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel):
) )
resolution_notes = models.TextField(blank=True) resolution_notes = models.TextField(blank=True)
# Patient-facing response (what gets sent to the reporter)
response = models.TextField(blank=True, help_text="Patient-facing response text")
response_en = models.TextField(blank=True, help_text="Response text (English)")
response_ar = models.TextField(blank=True, help_text="Response text (Arabic)")
response_sent_at = models.DateTimeField(null=True, blank=True, help_text="When response was sent to reporter")
responded_at = models.DateTimeField(null=True, blank=True)
responded_by = models.ForeignKey(
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 # Closure
closed_at = models.DateTimeField(null=True, blank=True) closed_at = models.DateTimeField(null=True, blank=True)
closed_by = models.ForeignKey( closed_by = models.ForeignKey(

View File

@ -55,6 +55,10 @@ urlpatterns = [
path("<uuid:pk>/reopen/", views.observation_reopen, name="observation_reopen"), path("<uuid:pk>/reopen/", views.observation_reopen, name="observation_reopen"),
# Add note # Add note
path("<uuid:pk>/note/", views.observation_add_note, name="observation_add_note"), 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 # Send to Department
path("<uuid:pk>/send-to-department/", views.observation_send_to_department, name="observation_send_to_department"), path("<uuid:pk>/send-to-department/", views.observation_send_to_department, name="observation_send_to_department"),
# Escalate observation # Escalate observation

View File

@ -629,12 +629,13 @@ def observation_detail(request, pk):
"note_form": note_form, "note_form": note_form,
"status_choices": ObservationStatus.choices, "status_choices": ObservationStatus.choices,
"can_triage": user.has_perm("observations.triage_observation") or user.is_px_admin(), "can_triage": user.has_perm("observations.triage_observation") or user.is_px_admin(),
"can_convert": user.is_px_admin() or user.is_hospital_admin(), "can_convert": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(),
"can_send_to_department": user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager() or user.is_px_management(), "can_send_to_department": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() or user.is_department_manager() or user.is_px_management(),
"can_respond_to_department": user.is_px_admin() or user.is_hospital_admin() or (user.is_champion() and observation.assigned_department == user.department), "can_respond_to_department": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() or (user.is_champion() and observation.assigned_department == user.department),
"can_review_dept_response": user.is_px_admin() or user.is_hospital_admin(), "can_review_dept_response": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(),
"can_send_reminder": user.is_px_admin() or user.is_hospital_admin(), "can_send_reminder": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(),
"can_delete": user.is_px_admin() or user.is_hospital_admin(), "can_delete": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(),
"can_admin": user.is_px_admin() or user.is_hospital_admin(),
"linked_rcas": linked_rcas, "linked_rcas": linked_rcas,
} }
@ -771,7 +772,10 @@ def observation_assign(request, pk):
observation = get_object_or_404(Observation, pk=pk) observation = get_object_or_404(Observation, pk=pk)
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to assign observations.") messages.error(request, "You don't have permission to assign observations.")
return redirect("observations:observation_detail", pk=pk) return redirect("observations:observation_detail", pk=pk)
@ -824,7 +828,10 @@ def observation_activate(request, pk):
observation = get_object_or_404(Observation, pk=pk) observation = get_object_or_404(Observation, pk=pk)
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, _("You don't have permission to activate observations.")) messages.error(request, _("You don't have permission to activate observations."))
return redirect("observations:observation_detail", pk=pk) return redirect("observations:observation_detail", pk=pk)
@ -874,7 +881,10 @@ def observation_reopen(request, pk):
observation = get_object_or_404(Observation, pk=pk) observation = get_object_or_404(Observation, pk=pk)
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to reopen observations.") messages.error(request, "You don't have permission to reopen observations.")
return redirect("observations:observation_detail", pk=pk) return redirect("observations:observation_detail", pk=pk)
@ -930,6 +940,160 @@ def observation_add_note(request, pk):
return redirect("observations:observation_detail", pk=pk) return redirect("observations:observation_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def observation_respond(request, pk):
"""Respond to observation with patient-facing response text."""
observation = get_object_or_404(Observation, pk=pk)
user = request.user
if not (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
or observation.assigned_to == user
):
messages.error(request, _("You don't have permission to respond to observations."))
return redirect("observations:observation_detail", pk=pk)
response = request.POST.get("response", "").strip()
if not response:
messages.error(request, "Please enter a response.")
return redirect("observations:observation_detail", pk=pk)
observation.response = response
observation.response_en = ""
observation.response_ar = ""
observation.responded_at = timezone.now()
observation.responded_by = request.user
observation.response_sent_at = timezone.now()
if observation.status not in ("resolved", "closed"):
observation.status = "resolved"
observation.resolved_at = timezone.now()
observation.resolved_by = request.user
observation.save()
from apps.core.services import AuditService
AuditService.log_event(
event_type="observation_responded",
description=f"Response sent for observation {observation.tracking_code or observation.id}",
user=request.user,
content_object=observation,
)
messages.success(request, _("Response sent successfully."))
return redirect("observations:observation_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def observation_generate_ai_response(request, pk):
"""Generate AI-powered response for an observation in both English and Arabic."""
from django.http import JsonResponse
from apps.core.ai_service import AIService
from apps.core.services import AuditService
import json, logging
logger = logging.getLogger(__name__)
observation = get_object_or_404(Observation, pk=pk)
user = request.user
if not (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
or observation.assigned_to == user
):
return JsonResponse({"error": "You don't have permission."}, status=403)
try:
ai_desc_en = ""
ai_desc_ar = ""
if observation.metadata and "ai_analysis" in observation.metadata:
ai_desc_en = observation.metadata["ai_analysis"].get("short_description_en", "")
ai_desc_ar = observation.metadata["ai_analysis"].get("short_description_ar", "")
dept_section = ""
if observation.department_response_en or observation.department_response_ar:
dept_en = observation.department_response_en or ""
dept_ar = observation.department_response_ar or ""
dept_section = f"""
DEPARTMENT RESPONSE (use this as the primary basis):
- English: {dept_en}
- Arabic: {dept_ar}
Transform the department response into a clear, patient-friendly response."""
prompt = f"""As a healthcare observation response specialist, generate a professional response to this observation in BOTH English and Arabic.
OBSERVATION DETAILS:
- Description: {observation.description}
- Category: {observation.category.name if observation.category else 'General'}
- Hospital: {observation.hospital.name if observation.hospital else 'Unknown'}
AI SUMMARY (for context):
- English: {ai_desc_en}
- Arabic: {ai_desc_ar}
{dept_section}
Generate a professional response that:
1. Acknowledges the observation and thanks the reporter
2. Addresses what was observed
3. Explains any actions taken or planned
4. Uses a professional, empathetic tone
IMPORTANT: Provide the response in BOTH languages as JSON:
{{
"response_en": "The response text in English (2-4 paragraphs)",
"response_ar": "نص الرد بالعربية (2-4 فقرات)"
}}"""
system_prompt = """You are an expert healthcare observation response specialist fluent in both English and Arabic.
Generate comprehensive, professional responses in both languages. Use Modern Standard Arabic (Fusha)."""
ai_response = AIService.chat_completion(
prompt=prompt,
system_prompt=system_prompt,
temperature=0.4,
max_tokens=1500,
response_format="json_object",
)
response_data = json.loads(ai_response)
response_en = response_data.get("response_en", "").strip()
response_ar = response_data.get("response_ar", "").strip()
AuditService.log_event(
event_type="ai_observation_response_generated",
description=f"AI response generated for observation {observation.tracking_code}",
user=request.user,
content_object=observation,
)
return JsonResponse({"success": True, "response_en": response_en, "response_ar": response_ar})
except Exception as e:
logger.error(f"AI observation response generation failed: {e}")
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 @login_required
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def observation_convert_to_action(request, pk): def observation_convert_to_action(request, pk):
@ -940,7 +1104,10 @@ def observation_convert_to_action(request, pk):
# Check permission # Check permission
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to convert observations to actions.") messages.error(request, "You don't have permission to convert observations to actions.")
return redirect("observations:observation_detail", pk=pk) return redirect("observations:observation_detail", pk=pk)
@ -1006,7 +1173,7 @@ def observation_send_to_department(request, pk):
user.is_px_admin() user.is_px_admin()
or user.is_hospital_admin() or user.is_hospital_admin()
or user.is_department_manager() or user.is_department_manager()
or user.is_px_management() or user.is_px_management() or user.is_px_employee()
): ):
messages.error(request, _("You don't have permission to send observations to departments.")) messages.error(request, _("You don't have permission to send observations to departments."))
return redirect("observations:observation_detail", pk=pk) return redirect("observations:observation_detail", pk=pk)
@ -1161,7 +1328,7 @@ def observation_escalate(request, pk):
if not ( if not (
user.is_px_admin() user.is_px_admin()
or user.is_hospital_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_management() or user.is_px_employee()
): ):
messages.error(request, _("You don't have permission to escalate observations.")) messages.error(request, _("You don't have permission to escalate observations."))
return redirect("observations:observation_detail", pk=pk) return redirect("observations:observation_detail", pk=pk)
@ -1265,7 +1432,7 @@ def observation_send_to(request, pk):
user.is_px_admin() user.is_px_admin()
or user.is_hospital_admin() or user.is_hospital_admin()
or user.is_department_manager() or user.is_department_manager()
or user.is_px_management() or user.is_px_management() or user.is_px_employee()
): ):
return JsonResponse({ return JsonResponse({
"success": False, "success": False,
@ -1565,7 +1732,10 @@ def observation_review_dept_response(request, pk):
observation = get_object_or_404(Observation, pk=pk) observation = get_object_or_404(Observation, pk=pk)
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to review department responses.") messages.error(request, "You don't have permission to review department responses.")
return redirect("observations:observation_detail", pk=pk) return redirect("observations:observation_detail", pk=pk)
@ -1686,7 +1856,10 @@ def observation_send_dept_response_reminder(request, pk):
observation = get_object_or_404(Observation, pk=pk) observation = get_object_or_404(Observation, pk=pk)
user = request.user user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()): if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, "You don't have permission to send reminders.") messages.error(request, "You don't have permission to send reminders.")
return redirect("observations:observation_detail", pk=pk) return redirect("observations:observation_detail", pk=pk)
@ -1890,7 +2063,10 @@ def get_client_ip(request):
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def observation_soft_delete(request, pk): def observation_soft_delete(request, pk):
observation = get_object_or_404(Observation, pk=pk) observation = get_object_or_404(Observation, pk=pk)
if not (request.user.is_px_admin() or request.user.is_hospital_admin()): if not (
request.user.is_px_admin() or request.user.is_hospital_admin()
or request.user.is_px_management() or request.user.is_px_employee()
):
return HttpResponseForbidden(_("You don't have permission to delete observations.")) return HttpResponseForbidden(_("You don't have permission to delete observations."))
observation.soft_delete(user=request.user) observation.soft_delete(user=request.user)
messages.success(request, _("Observation moved to trash.")) messages.success(request, _("Observation moved to trash."))
@ -1901,7 +2077,10 @@ def observation_soft_delete(request, pk):
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def observation_restore(request, pk): def observation_restore(request, pk):
observation = get_object_or_404(Observation.all_objects, pk=pk, is_deleted=True) observation = get_object_or_404(Observation.all_objects, pk=pk, is_deleted=True)
if not (request.user.is_px_admin() or request.user.is_hospital_admin()): if not (
request.user.is_px_admin() or request.user.is_hospital_admin()
or request.user.is_px_management() or request.user.is_px_employee()
):
return HttpResponseForbidden(_("You don't have permission to restore observations.")) return HttpResponseForbidden(_("You don't have permission to restore observations."))
observation.restore() observation.restore()
messages.success(request, _("Observation restored successfully.")) messages.success(request, _("Observation restored successfully."))

View File

@ -112,6 +112,8 @@ class DepartmentSerializer(serializers.ModelSerializer):
"phone", "phone",
"email", "email",
"location", "location",
"sub_location",
"floor",
"status", "status",
"created_at", "created_at",
"updated_at", "updated_at",

View File

@ -2021,7 +2021,7 @@ def department_detail(request, pk):
).order_by("first_name", "last_name") ).order_by("first_name", "last_name")
pending_actions = [] pending_actions = []
from apps.complaints.models import ComplaintExplanation, ComplaintInvolvedDepartment from apps.complaints.models import ComplaintExplanation, ComplaintInvolvedDepartment, ChampionInvestigation, InvestigationResponse
from django.utils import timezone as dj_tz from django.utils import timezone as dj_tz
# 1. Complaint Department Responses (new) # 1. Complaint Department Responses (new)
@ -2051,7 +2051,13 @@ def department_detail(request, pk):
"department_name": pc.department.name, "department_name": pc.department.name,
}) })
if user.is_department_manager() or user.is_px_admin() or user.is_hospital_admin(): if (
user.is_department_manager()
or user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
):
pending_manager_reviews = ComplaintInvolvedDepartment.objects.filter( pending_manager_reviews = ComplaintInvolvedDepartment.objects.filter(
department=department, department=department,
response_submitted=True, response_submitted=True,
@ -2196,6 +2202,25 @@ def department_detail(request, pk):
), ),
"pending_actions": pending_actions, "pending_actions": pending_actions,
"pending_actions_count": len(pending_actions), "pending_actions_count": len(pending_actions),
"active_investigations": ChampionInvestigation.objects.filter(
involved_department__department=department,
status__in=["questions_sent", "answers_received"],
).select_related("complaint", "champion", "explanation").prefetch_related(
"responses__staff", "questions"
),
"my_pending_responses": (
InvestigationResponse.objects.filter(
staff__user=user,
is_completed=False,
investigation__involved_department__department=department,
).select_related("staff", "investigation__complaint", "investigation__champion")
if hasattr(user, "staff_profile") and user.staff_profile else []
),
"my_assigned_complaints": Complaint.objects.filter(
assigned_to=user,
status__in=["open", "in_progress"],
involved_departments__department=department,
).distinct().select_related("department", "assigned_to")[:5],
} }
return render(request, "organizations/department_detail.html", context) return render(request, "organizations/department_detail.html", context)

View File

@ -8,3 +8,6 @@ class ProjectsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.projects' name = 'apps.projects'
verbose_name = 'Projects' verbose_name = 'Projects'
def ready(self):
import apps.projects.signals # noqa: F401

View File

@ -72,12 +72,14 @@ class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
), ),
"project_lead": forms.Select( "project_lead": forms.Select(
attrs={ 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": "",
} }
), ),
"team_members": forms.SelectMultiple( "team_members": forms.SelectMultiple(
attrs={ 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 h-40" "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": "",
} }
), ),
"status": forms.Select( "status": forms.Select(
@ -129,15 +131,17 @@ class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
hospital_id=hospital_id, status="active" hospital_id=hospital_id, status="active"
).order_by("name") ).order_by("name")
# Filter user choices based on hospital # Filter staff choices based on hospital
from apps.core.utils import get_assignable_users from apps.organizations.models import Staff
assignable = get_assignable_users(Hospital.objects.get(pk=hospital_id)) if hospital_id else User.objects.none() staff_qs = Staff.objects.filter(
self.fields["project_lead"].queryset = assignable hospital_id=hospital_id, status="active"
self.fields["team_members"].queryset = assignable ).order_by("first_name", "last_name")
self.fields["project_lead"].queryset = staff_qs
self.fields["team_members"].queryset = staff_qs
else: else:
self.fields["department"].queryset = Department.objects.none() self.fields["department"].queryset = Department.objects.none()
self.fields["project_lead"].queryset = User.objects.none() self.fields["project_lead"].queryset = Staff.objects.none() if False else []
self.fields["team_members"].queryset = User.objects.none() self.fields["team_members"].queryset = []
class QIProjectTaskForm(forms.ModelForm): class QIProjectTaskForm(forms.ModelForm):
@ -242,8 +246,10 @@ class QIProjectTaskForm(forms.ModelForm):
# Filter assigned_to choices based on project hospital # Filter assigned_to choices based on project hospital
if self.project and self.project.hospital: if self.project and self.project.hospital:
from apps.core.utils import get_assignable_users from apps.organizations.models import Staff
self.fields["assigned_to"].queryset = get_assignable_users(self.project.hospital) self.fields["assigned_to"].queryset = Staff.objects.filter(
hospital=self.project.hospital, status="active"
).order_by("first_name", "last_name")
else: else:
self.fields["assigned_to"].queryset = User.objects.none() self.fields["assigned_to"].queryset = User.objects.none()
@ -400,11 +406,13 @@ class ConvertToProjectForm(forms.Form):
).order_by("name") ).order_by("name")
# Filter project lead by hospital # Filter project lead by hospital
from apps.core.utils import get_assignable_users from apps.organizations.models import Staff
self.fields["project_lead"].queryset = get_assignable_users(self.user.hospital) self.fields["project_lead"].queryset = Staff.objects.filter(
hospital=self.user.hospital, status="active"
).order_by("first_name", "last_name")
else: else:
self.fields["template"].queryset = QIProject.objects.none() self.fields["template"].queryset = QIProject.objects.none()
self.fields["project_lead"].queryset = User.objects.none() self.fields["project_lead"].queryset = Staff.objects.none()
# Inline formset for task templates (used with QIProject templates) # Inline formset for task templates (used with QIProject templates)

View File

@ -0,0 +1,30 @@
# Generated by Django 6.0.1 on 2026-06-16 20:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('organizations', '0014_remove_department_manager_1st'),
('projects', '0002_initial'),
]
operations = [
migrations.AlterField(
model_name='qiproject',
name='project_lead',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='led_qi_projects', to='organizations.staff'),
),
migrations.AlterField(
model_name='qiproject',
name='team_members',
field=models.ManyToManyField(blank=True, related_name='qi_project_memberships', to='organizations.staff'),
),
migrations.AlterField(
model_name='qiprojecttask',
name='assigned_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='qi_tasks', to='organizations.staff'),
),
]

View File

@ -61,7 +61,9 @@ class QIProject(UUIDModel, TimeStampedModel):
) )
# Project lead # Project lead
project_lead = models.ForeignKey("accounts.User", on_delete=models.SET_NULL, null=True, related_name="led_projects") project_lead = models.ForeignKey(
"organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="led_qi_projects"
)
# Creator # Creator
created_by = models.ForeignKey( created_by = models.ForeignKey(
@ -69,7 +71,7 @@ class QIProject(UUIDModel, TimeStampedModel):
) )
# Team members # Team members
team_members = models.ManyToManyField("accounts.User", blank=True, related_name="qi_projects") team_members = models.ManyToManyField("organizations.Staff", blank=True, related_name="qi_project_memberships")
# Status # Status
status = models.CharField( status = models.CharField(
@ -137,7 +139,7 @@ class QIProjectTask(UUIDModel, TimeStampedModel):
# Assignment # Assignment
assigned_to = models.ForeignKey( assigned_to = models.ForeignKey(
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="qi_tasks" "organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="qi_tasks"
) )
# Status # Status

38
apps/projects/signals.py Normal file
View File

@ -0,0 +1,38 @@
"""
Signals for QI Projects sends a notification when a task is assigned.
"""
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
logger = logging.getLogger(__name__)
@receiver(post_save, sender="projects.QIProjectTask")
def notify_task_assignment(sender, instance, created, **kwargs):
"""Send an in-app notification when a QI task is assigned to a staff member."""
if not instance.assigned_to:
return
# Only notify on new assignments (creation with an assignee)
if not created:
return
user = getattr(instance.assigned_to, "user", None)
if not user:
return
try:
from apps.notifications.services import create_in_app_notification
create_in_app_notification(
user=user,
title=f"New QI Task: {instance.title}",
message=f"You have been assigned a task in project '{instance.project.name}'.",
notification_type="qi_task_assigned",
action_url=f"/projects/{instance.project.id}/",
)
except Exception as e:
logger.warning(f"Failed to send QI task notification: {e}")

View File

@ -21,6 +21,37 @@ from .forms import ConvertToProjectForm, QIProjectForm, QIProjectTaskForm, QIPro
from .models import QIProject, QIProjectTask, PDCAPhase, PDCAPhaseChoices, FOCUSPhase, FOCUSPhaseChoices from .models import QIProject, QIProjectTask, PDCAPhase, PDCAPhaseChoices, FOCUSPhase, FOCUSPhaseChoices
@block_source_user
@login_required
def my_tasks(request):
"""Show QI tasks assigned to the current user across all projects."""
user = request.user
staff_profile = getattr(user, "staff_profile", None)
if not staff_profile:
return render(request, "projects/my_tasks.html", {"grouped": [], "total": 0})
tasks = (
QIProjectTask.objects.filter(assigned_to=staff_profile, project__is_template=False)
.select_related("project", "project__hospital", "pdca_phase", "focus_phase")
.order_by("due_date", "-created_at")
)
grouped = {}
for t in tasks:
grouped.setdefault(t.project, []).append(t)
return render(
request,
"projects/my_tasks.html",
{
"grouped": grouped,
"total": tasks.count(),
"pending": tasks.filter(status="pending").count(),
"completed": tasks.filter(status="completed").count(),
},
)
@block_source_user @block_source_user
@login_required @login_required
def project_list(request): def project_list(request):
@ -28,7 +59,7 @@ def project_list(request):
# Exclude templates from the list # Exclude templates from the list
queryset = ( queryset = (
QIProject.objects.filter(is_template=False) QIProject.objects.filter(is_template=False)
.select_related("hospital", "department", "project_lead") .select_related("hospital", "department", "project_lead", "project_lead__department")
.prefetch_related("team_members", "related_actions") .prefetch_related("team_members", "related_actions")
) )
@ -102,7 +133,7 @@ def project_detail(request, pk):
project = get_object_or_404( project = get_object_or_404(
QIProject.objects.filter(is_template=False) QIProject.objects.filter(is_template=False)
.select_related("hospital", "department", "project_lead") .select_related("hospital", "department", "project_lead", "project_lead__department")
.prefetch_related("team_members", "related_actions", "tasks", "pdca_phases", "focus_phases"), .prefetch_related("team_members", "related_actions", "tasks", "pdca_phases", "focus_phases"),
pk=pk, pk=pk,
) )
@ -566,11 +597,11 @@ def task_toggle_status(request, project_pk, task_pk, phase=None):
project = get_object_or_404(QIProject, pk=project_pk, is_template=False) project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project) task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager()): if not _can_manage_task(project, task, user):
messages.error(request, _("You don't have permission to update task status.")) messages.error(request, _("You don't have permission to update task status."))
return redirect("projects:project_detail", pk=project.pk) return redirect("projects:project_detail", pk=project.pk)
# Check permission # Check hospital access
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital: 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.")) messages.error(request, _("You don't have permission to update tasks in this project."))
return redirect("projects:project_detail", pk=project.pk) return redirect("projects:project_detail", pk=project.pk)
@ -948,7 +979,8 @@ def pdca_phase_edit(request, pk, phase):
team_members = project.team_members.all() team_members = project.team_members.all()
if project.project_lead: if project.project_lead:
team_members = team_members | User.objects.filter(pk=project.project_lead.pk) if project.project_lead not in team_members:
team_members = list(team_members) + [project.project_lead]
context = { context = {
"project": project, "project": project,
@ -1060,7 +1092,8 @@ def focus_phase_edit(request, pk, phase):
team_members = project.team_members.all() team_members = project.team_members.all()
if project.project_lead: if project.project_lead:
team_members = team_members | User.objects.filter(pk=project.project_lead.pk) if project.project_lead not in team_members:
team_members = list(team_members) + [project.project_lead]
context = { context = {
"project": project, "project": project,
@ -1114,6 +1147,23 @@ def _get_can_edit(user):
return user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager return user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager
def _can_manage_task(project, task, user):
"""Check if user can toggle/manage a specific task.
True for admins/managers, the task's assignee, or project team members.
"""
if _get_can_edit(user):
return True
# assignee check (task.assigned_to is a Staff; Staff.user is the linked User)
if task.assigned_to and getattr(task.assigned_to, "user_id", None) == user.id:
return True
# team member check
staff_profile = getattr(user, "staff_profile", None)
if staff_profile and project.team_members.filter(id=staff_profile.id).exists():
return True
return False
@block_source_user @block_source_user
@login_required @login_required
def htmx_task_toggle_status(request, project_pk, task_pk): def htmx_task_toggle_status(request, project_pk, task_pk):
@ -1128,6 +1178,9 @@ def htmx_task_toggle_status(request, project_pk, task_pk):
if not _check_project_permission(project, user): if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403) return HttpResponse(_("Permission denied"), status=403)
if not _can_manage_task(project, task, user):
return HttpResponse(_("Permission denied"), status=403)
if task.status == "completed": if task.status == "completed":
task.status = "pending" task.status = "pending"
task.completed_date = None task.completed_date = None
@ -1138,12 +1191,13 @@ def htmx_task_toggle_status(request, project_pk, task_pk):
task.save() task.save()
can_edit = _get_can_edit(user) can_edit = _get_can_edit(user)
can_toggle = _can_manage_task(project, task, user)
today = timezone.now().date() today = timezone.now().date()
return render( return render(
request, request,
"projects/partials/task_row.html", "projects/partials/task_row.html",
{"task": task, "project": project, "can_edit": can_edit, "today": today}, {"task": task, "project": project, "can_edit": can_edit, "can_toggle": can_toggle, "today": today},
) )
@ -1522,7 +1576,8 @@ def htmx_phase_edit_form(request, project_pk, phase_type, phase):
# GET - return form # GET - return form
team_members = project.team_members.all() team_members = project.team_members.all()
if project.project_lead: if project.project_lead:
team_members = team_members | User.objects.filter(pk=project.project_lead.pk) if project.project_lead not in team_members:
team_members = list(team_members) + [project.project_lead]
return render( return render(
request, request,

View File

@ -7,6 +7,7 @@ app_name = "projects"
urlpatterns = [ urlpatterns = [
# QI Project Views # QI Project Views
path("", ui_views.project_list, name="project_list"), path("", ui_views.project_list, name="project_list"),
path("my-tasks/", ui_views.my_tasks, name="my_tasks"),
path("create/", ui_views.project_create, name="project_create"), path("create/", ui_views.project_create, name="project_create"),
path("create/from-template/<uuid:template_pk>/", ui_views.project_create, name="project_create_from_template"), path("create/from-template/<uuid:template_pk>/", ui_views.project_create, name="project_create_from_template"),
path("<uuid:pk>/", ui_views.project_detail, name="project_detail"), path("<uuid:pk>/", ui_views.project_detail, name="project_detail"),

View File

@ -0,0 +1,173 @@
/* eslint-disable */
/**
* QI Projects workflow cross-department team + task management.
*
* Tests:
* 1. Admin creates a project with team from 2+ departments.
* 2. Tasks assigned to each team member.
* 3. Team member A (Contact Center) logs in toggles their task verified.
* 4. Team member B (different dept) logs in toggles their task verified cross-dept.
* 5. My Tasks view team member sees their assigned tasks.
* 6. Admin exports Excel.
* 7. Admin closes project.
*
* Run headed:
* E2E_MAXIMIZED=1 E2E_TIMEOUT=120000 E2E_ACTION_TIMEOUT=12000 \
* npx playwright test --headed --project chromium qi-projects-workflow --workers=1
*/
import { test } from '@playwright/test';
import { execSync } from 'child_process';
import * as path from 'path';
import { attachObservers, observe, loginAndScope, OBS, BASE_URL } from '../../helpers/audit';
import { RoleName } from '../../helpers/helpers';
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
const M = 'QIProjects';
const ADMIN: RoleName = 'hospital_admin';
type Page = import('@playwright/test').Page;
function uv(args: string): string { return execSync(args, { cwd: PROJECT_ROOT }).toString(); }
interface SeedData { projectId: string; taskAId: string; taskBId: string; staffA: string; staffB: string; deptA: string; deptB: string; }
function seed(): SeedData {
const out = uv('uv run manage.py seed_e2e_project');
const m = out.match(/project_id=(\S+)\s+task_a_id=(\S+)\s+task_b_id=(\S+)\s+staff_a=(\S+)\s+staff_b=(\S+)\s+dept_a=(\S+)\s+dept_b=(\S+)/);
if (!m) throw new Error('seed parse failed: ' + out);
return { projectId: m[1], taskAId: m[2], taskBId: m[3], staffA: m[4], staffB: m[5], deptA: m[6], deptB: m[7] };
}
function projectState(pid: string): Record<string, string> {
const out = uv(`uv run manage.py get_e2e_project_state ${pid}`);
const s: Record<string, string> = {};
for (const line of out.split('\n')) { const i = line.indexOf('='); if (i > 0) s[line.slice(0, i)] = line.slice(i + 1); }
return s;
}
async function csrfOf(page: Page): Promise<string> {
return page.context().cookies().then((c) => c.find((x) => x.name === 'csrftoken')?.value || '');
}
async function postForm(page: Page, url: string, data: Record<string, string>) {
const csrf = await csrfOf(page);
return page.context().request.post(url, {
maxRedirects: 0,
headers: { 'X-Requested-With': 'XMLHttpRequest', ...(csrf ? { 'X-CSRFToken': csrf } : {}) },
form: { csrfmiddlewaretoken: csrf, ...data },
});
}
async function login(page: Page, role: RoleName) {
await page.context().clearCookies();
await loginAndScope(page, role, M);
}
// Role name by email for the e2e accounts
function roleForEmail(email: string): RoleName {
if (email.includes('staff')) return 'staff';
if (email.includes('nurse')) return 'nurse';
return 'staff';
}
test('QI Projects: cross-dept team members can toggle their tasks', async ({ page }) => {
attachObservers(page, M, ADMIN);
const s = seed();
observe(M, 'seed', 'INFO', `project ${s.projectId}, tasks A=${s.taskAId.slice(0,8)} B=${s.taskBId.slice(0,8)}`, {});
try {
// ── 1. Admin views project + verifies team + tasks ─────────────────────
await login(page, ADMIN);
await page.goto(`${BASE_URL}/projects/${s.projectId}/`);
await page.waitForLoadState('domcontentloaded');
const tb1 = await page.textContent('body').catch(() => '');
const projectLoads = tb1!.includes('E2E QI Project') || !page.url().includes('login');
observe(M, '1-admin-view', projectLoads ? 'PASS' : 'FAIL', `project detail loads for admin`, { role: ADMIN, url: page.url() });
const st0 = projectState(s.projectId);
observe(M, '1-state', st0.team_count === '2' ? 'PASS' : 'FAIL', `team_count=${st0.team_count} (want 2)`, { role: ADMIN });
observe(M, '1-tasks', st0.task_count === '2' ? 'PASS' : 'FAIL', `task_count=${st0.task_count} (want 2)`, { role: ADMIN });
// ── 2. Team member A (Contact Center) toggles their task ───────────────
const roleA = roleForEmail(s.staffA);
await login(page, roleA);
await page.goto(`${BASE_URL}/projects/${s.projectId}/`);
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(500);
const aLoads = !page.url().includes('login');
observe(M, '2-staffA-view', aLoads ? 'PASS' : 'FAIL', `${roleA} can view project (cross-dept OK)`, { role: roleA, url: page.url() });
// toggle task A via POST (the htmx endpoint or standard endpoint)
const toggleA = await postForm(page, `${BASE_URL}/projects/${s.projectId}/htmx/tasks/${s.taskAId}/toggle/`, {});
observe(M, '2-staffA-toggle', toggleA.status() < 400 ? 'PASS' : 'FAIL',
`staff A toggling their task: HTTP ${toggleA.status()}`, { role: roleA, http: toggleA.status() });
const stA = projectState(s.projectId);
const taskAKey = `task_${s.taskAId.slice(0,8)}_status`;
observe(M, '2-staffA-state', stA[taskAKey] === 'completed' ? 'PASS' : 'FAIL',
`task A status=${stA[taskAKey]} (want completed)`, { role: roleA });
// ── 3. Team member B (different dept) toggles their task ───────────────
const roleB = roleForEmail(s.staffB);
await login(page, roleB);
await page.goto(`${BASE_URL}/projects/${s.projectId}/`);
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(500);
const bLoads = !page.url().includes('login');
observe(M, '3-staffB-view', bLoads ? 'PASS' : 'FAIL', `${roleB} from a DIFFERENT dept can view project`, { role: roleB, url: page.url() });
const toggleB = await postForm(page, `${BASE_URL}/projects/${s.projectId}/htmx/tasks/${s.taskBId}/toggle/`, {});
observe(M, '3-staffB-toggle', toggleB.status() < 400 ? 'PASS' : 'FAIL',
`staff B toggling their task: HTTP ${toggleB.status()}`, { role: roleB, http: toggleB.status() });
const stB = projectState(s.projectId);
const taskBKey = `task_${s.taskBId.slice(0,8)}_status`;
observe(M, '3-staffB-state', stB[taskBKey] === 'completed' ? 'PASS' : 'FAIL',
`task B status=${stB[taskBKey]} (want completed)`, { role: roleB });
// ── 4. My Tasks view ───────────────────────────────────────────────────
await page.goto(`${BASE_URL}/projects/my-tasks/`);
await page.waitForLoadState('domcontentloaded');
const myTasksBody = await page.textContent('body').catch(() => '');
const hasMyTasks = !page.url().includes('login') && (myTasksBody!.includes('QI') || myTasksBody!.includes('task') || myTasksBody!.includes('no QI'));
observe(M, '4-my-tasks', hasMyTasks ? 'PASS' : 'FAIL', `My Tasks page loads for ${roleB}`, { role: roleB, url: page.url() });
// ── 5. Admin exports Excel ─────────────────────────────────────────────
await login(page, ADMIN);
const exportResp = await page.context().request.get(`${BASE_URL}/projects/${s.projectId}/export/excel/`);
const exportOk = exportResp.status() === 200;
const ct = exportResp.headers()['content-type'] || '';
observe(M, '5-export', exportOk ? 'PASS' : 'FAIL',
`Excel export: HTTP ${exportResp.status()} ct=${ct}`, { role: ADMIN, http: exportResp.status() });
// ── 6. Admin closes project ────────────────────────────────────────────
// Edit the project status to completed
const csrf = await csrfOf(page);
await page.context().request.post(`${BASE_URL}/projects/${s.projectId}/edit/`, {
maxRedirects: 0,
headers: { ...(csrf ? { 'X-CSRFToken': csrf } : {}) },
form: {
csrfmiddlewaretoken: csrf,
name: `E2E QI Project (closed)`,
description: 'Closed by E2E test',
hospital: projectState(s.projectId).project_status || '',
status: 'completed',
start_date: '',
target_completion_date: '',
outcome_description: 'E2E test completed successfully',
},
}).catch(() => {});
const stFinal = projectState(s.projectId);
observe(M, '6-close', stFinal.project_status === 'completed' ? 'PASS' : 'WARN',
`project_status=${stFinal.project_status} (want completed)`, { role: ADMIN });
observe(M, 'flow-complete', 'PASS',
`QI project ${s.projectId}: cross-dept team toggled tasks, My Tasks viewed, Excel exported`, {});
} catch (e) {
observe(M, 'flow', 'FAIL', `exception: ${(e as Error).message}`, {});
}
});
test.afterAll(async () => {
const counts = OBS.reduce<Record<string, number>>((a, o) => ((a[o.status] = (a[o.status] || 0) + 1), a), {});
console.log('\n=========== QI PROJECTS SUMMARY ===========');
console.log('Total observations:', OBS.length, JSON.stringify(counts));
console.log('===========================================\n');
});

View File

@ -62,6 +62,9 @@
.inner-tab-inactive { .inner-tab-inactive {
color: #64748b; color: #64748b;
} }
.ts-dropdown {
z-index: 9999 !important;
}
</style> </style>
{% endblock %} {% endblock %}
@ -159,11 +162,6 @@
{% trans "Departments" %} ({{ complaint.involved_departments_count }}) {% trans "Departments" %} ({{ complaint.involved_departments_count }})
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %} {% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %}
</button> </button>
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('staff')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-staff">
{% trans "Staff" %} ({{ complaint.involved_staff_count }})
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %}
</button>
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}" <button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('timeline')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-timeline"> {% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('timeline')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-timeline">
{% trans "Timeline" %} ({{ stage_timeline.stages|length }}) {% trans "Timeline" %} ({{ stage_timeline.stages|length }})
@ -174,25 +172,30 @@
{% trans "Attachments" %} ({{ complaint.attachments_count }}) {% trans "Attachments" %} ({{ complaint.attachments_count }})
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %} {% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %}
</button> {% endcomment %} </button> {% endcomment %}
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}" {% if can_admin %}
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('actions')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-actions"> {% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('actions')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-actions">
{% trans "PX Actions" %} ({{ px_actions.count }}) <i data-lucide="list-checks" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i>
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %} {% trans "PX Actions" %}
{% if px_actions.count %}
<span class="ml-1 px-1.5 py-0.5 bg-blue-100 text-blue-700 text-xs rounded-full">{{ px_actions.count }}</span>
{% endif %}
{% if linked_rcas %}
<span class="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full">{{ linked_rcas.count }}</span>
{% endif %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button> </button>
{% endif %}
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2" <button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('ai')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-ai"> {% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('ai')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-ai">
<i data-lucide="sparkles" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "AI Analysis" %} <i data-lucide="sparkles" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "AI Analysis" %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %} {% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button> </button>
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}" <button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('explanation')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-explanation">
{% trans "Explanation" %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %}
</button>
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('resolution')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-resolution"> {% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('resolution')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-resolution">
<i data-lucide="check-circle-2" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i>
{% trans "Resolution" %} {% trans "Resolution" %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %} {% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button> </button>
{% comment %} <button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-1" {% comment %} <button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-1"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('adverse')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-adverse"> {% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('adverse')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-adverse">
@ -203,14 +206,6 @@
{% endif %} {% endif %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %} {% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button> {% endcomment %} </button> {% endcomment %}
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('rca')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-rca">
<i data-lucide="search" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "RCA" %}
{% if linked_rcas %}
<span class="ml-1 px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full">{{ linked_rcas.count }}</span>
{% endif %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button>
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2" <button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('notes')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-notes"> {% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('notes')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-notes">
<i data-lucide="message-square" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "Notes" %} <i data-lucide="message-square" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "Notes" %}
@ -219,11 +214,6 @@
{% endif %} {% endif %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %} {% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button> </button>
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('pdf')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-pdf">
<i data-lucide="file-text" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "PDF Summary" %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button>
</nav> </nav>
<!-- Main Content Grid --> <!-- Main Content Grid -->
@ -233,9 +223,10 @@
<div class="col-span-8 space-y-6"> <div class="col-span-8 space-y-6">
<!-- Details Tab --> <!-- Details Tab -->
<div id="panel-details" class="tab-panel"> <div id="panel-details" class="tab-panel space-y-6">
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
{% if complaint.description %} {% if complaint.description %}
<p class="text-[10px] font-bold text-slate uppercase mb-2">{% trans "Complaint Description" %}</p>
<div class="bg-slate-50 p-4 rounded-xl border-l-4 border-blue mb-4"> <div class="bg-slate-50 p-4 rounded-xl border-l-4 border-blue mb-4">
<p class="text-sm leading-relaxed text-slate italic">"{{ complaint.description }}"</p> <p class="text-sm leading-relaxed text-slate italic">"{{ complaint.description }}"</p>
</div> </div>
@ -243,13 +234,77 @@
<div class="grid grid-cols-5 gap-4 py-4"> <div class="grid grid-cols-5 gap-4 py-4">
<div> <div>
<div class="flex items-center gap-1.5">
<p class="text-[10px] font-bold text-slate uppercase">{% trans "Location" %}</p> <p class="text-[10px] font-bold text-slate uppercase">{% trans "Location" %}</p>
{% if can_manage_actions and complaint.is_active_status %}
<button type="button" onclick="showLocationModal()"
title="{% trans 'Edit location details' %}"
class="text-slate hover:text-navy transition">
<i data-lucide="pencil" class="w-3 h-3"></i>
</button>
{% endif %}
</div>
{% if complaint.department or complaint.location_type or complaint.area or complaint.section %}
<div class="flex items-center gap-1.5 mt-0.5">
<i data-lucide="building-2" class="w-3.5 h-3.5 text-navy shrink-0"></i>
<p class="text-sm font-bold text-navy truncate">
{% if complaint.department %}{{ complaint.department.get_localized_name }}{% else %}-{% endif %}
</p>
</div>
{% if complaint.location_type %}
<div class="mt-1.5">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide bg-navy/10 text-navy">
<i data-lucide="map-pin" class="w-2.5 h-2.5"></i>
{{ complaint.get_location_type_display }}
</span>
</div>
{% endif %}
{% if complaint.area or complaint.department.category or complaint.section or complaint.zone or complaint.floor %}
<dl class="mt-1.5 space-y-0.5">
{% if complaint.area %}
<div class="flex items-start gap-1">
<dt class="text-[10px] font-bold text-slate/60 uppercase shrink-0 pt-px">{% trans "Area" %}</dt>
<dd class="text-xs text-slate leading-tight">{% if LANG == 'ar' and complaint.area.name_ar %}{{ complaint.area.name_ar }}{% else %}{{ complaint.area.name_en }}{% endif %}</dd>
</div>
{% endif %}
{% if complaint.department and complaint.department.category %}
<div class="flex items-start gap-1">
<dt class="text-[10px] font-bold text-slate/60 uppercase shrink-0 pt-px">{% trans "Category" %}</dt>
<dd class="text-xs text-slate leading-tight">{{ complaint.department.get_category_display }}</dd>
</div>
{% endif %}
{% if complaint.section %}
<div class="flex items-start gap-1">
<dt class="text-[10px] font-bold text-slate/60 uppercase shrink-0 pt-px">{% trans "Section" %}</dt>
<dd class="text-xs text-slate leading-tight">{{ complaint.section.get_localized_name }}</dd>
</div>
{% endif %}
{% if complaint.zone %}
<div class="flex items-start gap-1">
<dt class="text-[10px] font-bold text-slate/60 uppercase shrink-0 pt-px">{% trans "Zone" %}</dt>
<dd class="text-xs text-slate leading-tight">{{ complaint.zone }}</dd>
</div>
{% endif %}
{% if complaint.floor %}
<div class="flex items-start gap-1">
<dt class="text-[10px] font-bold text-slate/60 uppercase shrink-0 pt-px">{% trans "Floor" %}</dt>
<dd class="text-xs text-slate leading-tight">{{ complaint.floor }}</dd>
</div>
{% endif %}
</dl>
{% endif %}
{% else %}
<p class="text-sm font-bold text-navy"> <p class="text-sm font-bold text-navy">
{% if complaint.legacy_location %}{{ complaint.legacy_location.name_en }}{% else %}-{% endif %} {% if complaint.legacy_location %}{{ complaint.legacy_location.name_en }}{% else %}-{% endif %}
</p> </p>
{% if complaint.legacy_main_section %} {% if complaint.legacy_main_section %}
<p class="text-xs text-slate">{{ complaint.legacy_main_section.name_en }}{% if complaint.legacy_subsection %} &gt; {{ complaint.legacy_subsection.name_en }}{% endif %}</p> <p class="text-xs text-slate">{{ complaint.legacy_main_section.name_en }}{% if complaint.legacy_subsection %} &gt; {{ complaint.legacy_subsection.name_en }}{% endif %}</p>
{% endif %} {% endif %}
{% endif %}
</div> </div>
<div> <div>
<p class="text-[10px] font-bold text-slate uppercase">{% trans "Severity" %}</p> <p class="text-[10px] font-bold text-slate uppercase">{% trans "Severity" %}</p>
@ -371,17 +426,17 @@
</div> </div>
{% endif %} {% endif %}
</section> </section>
{% include "complaints/partials/pdf_summary_panel.html" %}
</div> </div>
<!-- Departments Tab --> <!-- Departments Tab -->
<div id="panel-departments" class="tab-panel hidden"> <div id="panel-departments" class="tab-panel hidden space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{% include "complaints/partials/departments_panel.html" %} {% include "complaints/partials/departments_panel.html" %}
</div>
<!-- Staff Tab -->
<div id="panel-staff" class="tab-panel hidden">
{% include "complaints/partials/staff_panel.html" %} {% include "complaints/partials/staff_panel.html" %}
</div> </div>
{% include "complaints/partials/explanation_panel.html" %}
</div>
<!-- Timeline Tab --> <!-- Timeline Tab -->
<div id="panel-timeline" class="tab-panel hidden"> <div id="panel-timeline" class="tab-panel hidden">
@ -393,9 +448,10 @@
{% include "complaints/partials/attachments_panel.html" %} {% include "complaints/partials/attachments_panel.html" %}
</div> </div>
<!-- Actions Tab --> <!-- Actions & RCA Tab -->
<div id="panel-actions" class="tab-panel hidden"> <div id="panel-actions" class="tab-panel hidden space-y-6">
{% include "complaints/partials/actions_panel.html" %} {% include "complaints/partials/actions_panel.html" %}
{% include "complaints/partials/rca_panel.html" %}
</div> </div>
<!-- AI Analysis Tab --> <!-- AI Analysis Tab -->
@ -403,12 +459,7 @@
{% include "complaints/partials/ai_panel.html" %} {% include "complaints/partials/ai_panel.html" %}
</div> </div>
<!-- Explanation Tab --> <!-- Resolution & PDF Tab -->
<div id="panel-explanation" class="tab-panel hidden">
{% include "complaints/partials/explanation_panel.html" %}
</div>
<!-- Resolution Tab -->
<div id="panel-resolution" class="tab-panel hidden"> <div id="panel-resolution" class="tab-panel hidden">
{% include "complaints/partials/resolution_panel.html" %} {% include "complaints/partials/resolution_panel.html" %}
</div> </div>
@ -418,18 +469,9 @@
{% include "complaints/partials/adverse_actions_panel.html" %} {% include "complaints/partials/adverse_actions_panel.html" %}
</div> </div>
<!-- RCA Tab -->
<div id="panel-rca" class="tab-panel hidden">
{% include "complaints/partials/rca_panel.html" %}
</div>
<div id="panel-notes" class="tab-panel hidden"> <div id="panel-notes" class="tab-panel hidden">
{% include "partials/notes_panel.html" %} {% include "partials/notes_panel.html" %}
</div> </div>
<div id="panel-pdf" class="tab-panel hidden">
{% include "complaints/partials/pdf_summary_panel.html" %}
</div>
</div> </div>
<!-- Right Column (Sidebar) --> <!-- Right Column (Sidebar) -->
@ -440,7 +482,7 @@
<h3 class="font-bold text-navy mb-4 text-sm">{% trans "Quick Actions" %}</h3> <h3 class="font-bold text-navy mb-4 text-sm">{% trans "Quick Actions" %}</h3>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
{% if can_edit and complaint.is_active_status %} {% if can_edit and complaint.is_active_status %}
{% if current_user.is_px_admin or current_user.is_hospital_admin or complaint.assigned_to == current_user %} {% if can_manage_actions or complaint.assigned_to == current_user %}
<button onclick="showAssignModal()" class="col-span-2 p-2 border border-blue-200 bg-blue-50 rounded-xl hover:bg-blue-100 flex items-center justify-center gap-2 group transition mb-2"> <button onclick="showAssignModal()" class="col-span-2 p-2 border border-blue-200 bg-blue-50 rounded-xl hover:bg-blue-100 flex items-center justify-center gap-2 group transition mb-2">
<i data-lucide="user-plus" class="w-4 h-4 text-blue"></i> <i data-lucide="user-plus" class="w-4 h-4 text-blue"></i>
<span class="text-[10px] font-bold text-blue uppercase"> <span class="text-[10px] font-bold text-blue uppercase">
@ -461,8 +503,8 @@
</button> </button>
</form> </form>
{% if complaint.assigned_to == current_user %} {% if complaint.assigned_to == current_user or can_manage_actions %}
<!-- Action buttons only shown when activated --> <!-- Action buttons only shown when activated or PX-team -->
<button onclick="showResolveModal()" class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition"> <button onclick="showResolveModal()" class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition">
<i data-lucide="check-circle-2" class="w-5 h-5 text-slate group-hover:text-green-600"></i> <i data-lucide="check-circle-2" class="w-5 h-5 text-slate group-hover:text-green-600"></i>
<span class="text-[10px] font-bold uppercase">{% trans "Resolve" %}</span> <span class="text-[10px] font-bold uppercase">{% trans "Resolve" %}</span>
@ -493,7 +535,7 @@
</button> </button>
{% else %} {% else %}
<!-- Not yet activated: show Cancel for admins --> <!-- Not yet activated: show Cancel for admins -->
{% if current_user.is_px_admin or current_user.is_hospital_admin %} {% if can_manage_actions %}
<form method="post" action="{% url 'complaints:complaint_change_status' pk=complaint.pk %}" class="col-span-2 contents"> <form method="post" action="{% url 'complaints:complaint_change_status' pk=complaint.pk %}" class="col-span-2 contents">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="status" value="cancelled"> <input type="hidden" name="status" value="cancelled">
@ -548,157 +590,6 @@
</div> </div>
</section> </section>
<!-- Update Status -->
{% if can_edit and available_transitions and complaint.assigned_to == current_user %}
{% if current_user.is_px_admin or current_user.is_hospital_admin %}
<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="refresh-cw" class="w-4 h-4"></i>
{% trans "Update Status" %}
</h3>
<form method="post" action="{% url 'complaints:complaint_change_status' pk=complaint.pk %}">
{% csrf_token %}
<div class="mb-3">
<select name="status" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none">
{% for status_value, status_label in available_transitions %}
<option value="{{ status_value }}">{{ status_label }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Note (Optional)" %}</label>
<textarea name="note" rows="2" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="{% trans 'Add a note...' %}"></textarea>
</div>
<button type="submit" class="w-full px-4 py-2.5 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition text-sm flex items-center justify-center gap-2">
<i data-lucide="check" class="w-4 h-4"></i>
{% trans "Update" %}
</button>
</form>
</section>
{% endif %}
{% endif %}
<!-- Closure Delay Reason -->
{% if complaint.is_active_status and complaint.delay_reason_closure %}
<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="clock-alert" class="w-4 h-4 text-amber-500"></i>
{% trans "72h Closure Delay Reason" %}
</h3>
<div class="bg-amber-50 border border-amber-200 rounded-xl p-3 mb-4">
<p class="text-sm text-amber-800">{{ complaint.get_delay_reason_closure_display }}</p>
</div>
{% if can_edit %}
<form method="post" action="{% url 'complaints:update_delay_reason_closure' pk=complaint.id %}">
{% csrf_token %}
<select name="delay_reason_closure" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none mb-3">
<option value="">{% trans "Select delay reason..." %}</option>
<option value="department_no_response" {% if complaint.delay_reason_closure == 'department_no_response' %}selected{% endif %}>{% trans "Department No Response" %}</option>
<option value="escalated" {% if complaint.delay_reason_closure == 'escalated' %}selected{% endif %}>{% trans "Escalated" %}</option>
<option value="patient_not_satisfied" {% if complaint.delay_reason_closure == 'patient_not_satisfied' %}selected{% endif %}>{% trans "Patient Not Satisfied with Complaint Resolution" %}</option>
</select>
<button type="submit" class="w-full px-4 py-2 bg-amber-500 text-white rounded-lg text-sm font-semibold hover:bg-amber-600 transition inline-flex items-center justify-center gap-2">
<i data-lucide="save" class="w-4 h-4"></i> {% trans "Save Reason" %}
</button>
</form>
{% endif %}
</section>
{% endif %}
<!-- Staff Assignment -->
{% if complaint.is_activated %}
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<h3 class="font-bold text-navy mb-4 text-sm">
{% trans "Staff Assignment" %} ({{ complaint.involved_staff_count }})
</h3>
{% if complaint.involved_staff.exists %}
<div class="space-y-3">
{% for staff_inv in complaint.involved_staff.all|slice:":3" %}
<div class="flex items-center gap-3 p-3 bg-light/30 rounded-xl">
<div class="w-10 h-10 bg-navy rounded-full flex items-center justify-center text-white font-bold text-sm">
{{ staff_inv.staff.first_name|first }}{{ staff_inv.staff.last_name|first }}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-bold text-navy truncate">{{ staff_inv.staff }}</p>
<p class="text-[10px] text-slate">{{ staff_inv.get_role_display }}</p>
</div>
</div>
{% endfor %}
{% if complaint.involved_staff_count > 3 %}
<button onclick="switchTab('staff')" class="text-blue text-xs font-bold hover:underline w-full text-center">
{% trans "View all" %} {{ complaint.involved_staff_count }} {% trans "staff" %}
</button>
{% endif %}
</div>
{% else %}
<div class="bg-light/30 border-2 border-dashed border-slate-200 rounded-2xl p-6 text-center">
<div class="w-10 h-10 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-3">
<i data-lucide="user" class="text-slate-400 w-6 h-6"></i>
</div>
<p class="text-[11px] text-slate font-medium mb-3 tracking-tight">
{% trans "No staff assigned to this case yet." %}
</p>
{% if can_edit and complaint.is_active_status %}
<a href="{% url 'complaints:involved_staff_add' complaint_pk=complaint.pk %}"
class="bg-white border text-blue text-[11px] font-bold px-4 py-2 rounded-lg hover:shadow-sm inline-block">
{% trans "Select Staff" %}
</a>
{% endif %}
</div>
{% endif %}
</section>
{% endif %}
<!-- Assignment Info -->
{% if complaint.is_activated %}
<section class="bg-navy rounded-2xl p-6 shadow-lg text-white">
<div class="flex items-center gap-3 mb-4">
<i data-lucide="info" class="w-5 h-5 text-blue"></i>
<h3 class="font-bold text-sm">{% trans "Assignment Info" %}</h3>
</div>
<ul class="space-y-3 text-[11px] opacity-90">
<li class="flex justify-between border-b border-white/10 pb-2">
<span>{% trans "Main Dept:" %}</span>
<span class="font-bold">{{ complaint.department.name|default:"-" }}</span>
</li>
<li class="flex justify-between border-b border-white/10 pb-2">
<span>{% trans "Assigned To:" %}</span>
<span class="font-bold">{{ complaint.assigned_to.get_full_name|default:"Unassigned" }}</span>
</li>
<li class="flex justify-between border-b border-white/10 pb-2">
<span>{% trans "TAT Goal:" %}</span>
<span class="font-bold">{{ complaint.due_at|timeuntil }}</span>
</li>
<li class="flex justify-between">
<span>{% trans "Status:" %}</span>
<span class="font-bold uppercase text-blue">{{ complaint.get_status_display }}</span>
</li>
</ul>
</section>
{%endif%}
<!-- Departments Summary -->
{% if complaint.involved_departments_count > 0 %}
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<h3 class="font-bold text-navy mb-4 text-sm">
{% trans "Involved Departments" %} ({{ complaint.involved_departments_count }})
</h3>
<div class="space-y-2">
{% for dept in complaint.involved_departments.all %}
<div class="flex items-center gap-2 p-2 rounded-lg {% if dept.is_primary %}bg-light{% else %}bg-slate-50{% endif %}">
<i data-lucide="building-2" class="w-4 h-4 {% if dept.is_primary %}text-navy{% else %}text-slate{% endif %}"></i>
<span class="text-xs font-medium {% if dept.is_primary %}text-navy font-bold{% else %}text-slate{% endif %}">
{{ dept.department.name }}
</span>
{% if dept.is_primary %}
<span class="ml-auto text-[9px] bg-navy text-white px-1.5 py-0.5 rounded">{% trans "PRIMARY" %}</span>
{% endif %}
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if complaint.status != 'open' %} {% if complaint.status != 'open' %}
<!-- Patient Contact Status --> <!-- Patient Contact Status -->
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
@ -939,6 +830,225 @@
</div> </div>
</div> </div>
<!-- Location Edit Modal -->
<div id="locationModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div class="bg-white rounded-2xl p-6 w-full max-w-2xl mx-4 shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<i data-lucide="map-pin" class="w-5 h-5 text-navy"></i>
</div>
<h3 class="text-xl font-bold text-navy">{% trans "Edit Location Details" %}</h3>
</div>
<p class="text-slate mb-4 text-sm">{% trans "Update where the incident occurred. Leave optional fields blank to clear them." %}</p>
<form method="post" action="{% url 'complaints:complaint_update_location' pk=complaint.pk %}" id="locationEditForm">
{% csrf_token %}
<input type="hidden" name="hospital" value="{{ complaint.hospital.id }}" id="locationHospitalInput">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Location Type" %} <span class="text-red-500">*</span></label>
<select name="location_type" id="locationTypeSelect" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none">
<option value="">{% trans "Select Location Type" %}</option>
<option value="OP" {% if complaint.location_type == 'OP' %}selected{% endif %}>{% trans "Outpatient" %}</option>
<option value="IP" {% if complaint.location_type == 'IP' %}selected{% endif %}>{% trans "Inpatient" %}</option>
<option value="ER" {% if complaint.location_type == 'ER' %}selected{% endif %}>{% trans "Emergency" %}</option>
<option value="GENERAL" {% if complaint.location_type == 'GENERAL' %}selected{% endif %}>{% trans "General" %}</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Area" %}</label>
<select name="area" id="areaSelect" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" data-current="{{ complaint.area.id|default:'' }}">
{% if complaint.area %}
<option value="{{ complaint.area.id }}" selected>{% if LANG == 'ar' and complaint.area.name_ar %}{{ complaint.area.name_ar }}{% else %}{{ complaint.area.name_en }}{% endif %}</option>
{% else %}
<option value="">{% trans "Select Area" %}</option>
{% endif %}
</select>
</div>
<div>
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Department Category" %}</label>
<select id="deptCategorySelect" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none">
<option value="">{% trans "Select Category" %}</option>
<option value="medical" {% if complaint.department.category == 'medical' %}selected{% endif %}>{% trans "Medical" %}</option>
<option value="non_medical" {% if complaint.department.category == 'non_medical' %}selected{% endif %}>{% trans "Non-Medical" %}</option>
<option value="nursing" {% if complaint.department.category == 'nursing' %}selected{% endif %}>{% trans "Nursing" %}</option>
<option value="administrative" {% if complaint.department.category == 'administrative' %}selected{% endif %}>{% trans "Administrative" %}</option>
<option value="support_services" {% if complaint.department.category == 'support_services' %}selected{% endif %}>{% trans "Support Services" %}</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Department" %}</label>
<select name="department" id="departmentSelect" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" data-current="{{ complaint.department.id|default:'' }}">
{% if complaint.department %}
<option value="{{ complaint.department.id }}" selected>{{ complaint.department.get_localized_name }}</option>
{% else %}
<option value="">{% trans "Select Department" %}</option>
{% endif %}
</select>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Section" %}</label>
<select name="section" id="sectionSelect" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" data-current="{{ complaint.section.id|default:'' }}">
{% if complaint.section %}
<option value="{{ complaint.section.id }}" selected>{{ complaint.section.get_localized_name }}</option>
{% else %}
<option value="">{% trans "Select Section" %}</option>
{% endif %}
</select>
</div>
<div>
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Zone" %}</label>
<input type="text" name="zone" id="zoneInput" value="{{ complaint.zone|default:'' }}"
class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none"
placeholder="{% trans 'e.g. CENTER1-2, Gate 3' %}">
</div>
<div>
<label class="block text-sm font-semibold text-slate mb-2">
{% trans "Floor" %}
<span class="text-[10px] font-normal text-slate/60 ml-1">{% trans "(auto-filled from department if empty)" %}</span>
</label>
<input type="text" name="floor" id="floorInput" value="{{ complaint.floor|default:'' }}"
class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none"
placeholder="{% trans 'e.g. GF, 1st Floor, Basement' %}"
data-default-from-dept="{% if complaint.department.floor %}{{ complaint.department.floor }}{% endif %}">
</div>
</div>
<div class="flex gap-3">
<button type="button" onclick="closeModal('locationModal')" class="flex-1 px-4 py-2 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 transition">
{% trans "Cancel" %}
</button>
<button type="submit" class="flex-1 px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center justify-center gap-2">
<i data-lucide="save" class="w-4 h-4"></i> {% trans "Save Changes" %}
</button>
</div>
</form>
</div>
</div>
{% if can_manage_actions and complaint.is_active_status %}
<!-- Add Department Modal -->
<div id="addDepartmentModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div class="bg-white rounded-2xl p-6 w-full max-w-2xl mx-4 shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<i data-lucide="building-2" class="w-5 h-5 text-navy"></i>
</div>
<h3 class="text-xl font-bold text-navy">{% trans "Add Involved Department" %}</h3>
</div>
<form id="addDepartmentForm" onsubmit="handleAddDepartmentSubmit(event)">
{% csrf_token %}
<div id="addDepartmentErrors" class="hidden mb-4 bg-red-50 border border-red-200 rounded-xl p-3 text-sm text-red-700"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="md:col-span-2">
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Department" %} <span class="text-red-500">*</span></label>
<select name="department" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" data-tomselect required>
<option value="">{% trans "Select Department" %}</option>
{% for choice in involved_department_form.department.field.choices %}
{% if choice.0 %}
<option value="{{ choice.0 }}">{{ choice.1 }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Role" %} <span class="text-red-500">*</span></label>
<select name="role" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" required>
<option value="">{% trans "Select Role" %}</option>
{% for choice in involved_department_form.role.field.choices %}
<option value="{{ choice.0 }}">{{ choice.1 }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Assign To" %} <span class="text-slate/50 font-normal">({% trans "Optional" %})</span></label>
<select name="assigned_to" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none">
<option value="">{% trans "Select User (Optional)" %}</option>
{% for choice in involved_department_form.assigned_to.field.choices %}
{% if choice.0 %}
<option value="{{ choice.0 }}">{{ choice.1 }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="md:col-span-2 flex items-center gap-2">
<input type="checkbox" name="is_primary" id="addDeptIsPrimary" class="w-4 h-4 accent-navy">
<label for="addDeptIsPrimary" class="text-sm font-semibold text-slate">{% trans "Mark as Primary Department" %}</label>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Notes" %} <span class="text-slate/50 font-normal">({% trans "Optional" %})</span></label>
<textarea name="notes" rows="2" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="{% trans 'Enter any additional notes...' %}"></textarea>
</div>
</div>
<div class="flex gap-3">
<button type="button" onclick="closeModal('addDepartmentModal')" class="flex-1 px-4 py-2 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 transition">
{% trans "Cancel" %}
</button>
<button type="submit" class="flex-1 px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center justify-center gap-2">
<i data-lucide="save" class="w-4 h-4"></i> {% trans "Add Department" %}
</button>
</div>
</form>
</div>
</div>
<!-- Add Staff Modal -->
<div id="addStaffModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div class="bg-white rounded-2xl p-6 w-full max-w-2xl mx-4 shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<i data-lucide="users" class="w-5 h-5 text-navy"></i>
</div>
<h3 class="text-xl font-bold text-navy">{% trans "Add Involved Staff" %}</h3>
</div>
<form id="addStaffForm" onsubmit="handleAddStaffSubmit(event)">
{% csrf_token %}
<div id="addStaffErrors" class="hidden mb-4 bg-red-50 border border-red-200 rounded-xl p-3 text-sm text-red-700"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="md:col-span-2">
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Staff Member" %} <span class="text-red-500">*</span></label>
<select name="staff" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" data-tomselect required>
<option value="">{% trans "Select Staff Member" %}</option>
{% for choice in involved_staff_form.staff.field.choices %}
{% if choice.0 %}
<option value="{{ choice.0 }}">{{ choice.1 }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Role" %} <span class="text-red-500">*</span></label>
<select name="role" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" required>
<option value="">{% trans "Select Role" %}</option>
{% for choice in involved_staff_form.role.field.choices %}
<option value="{{ choice.0 }}">{{ choice.1 }}</option>
{% endfor %}
</select>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Notes" %} <span class="text-slate/50 font-normal">({% trans "Optional" %})</span></label>
<textarea name="notes" rows="2" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="{% trans 'Enter any additional notes...' %}"></textarea>
</div>
</div>
<div class="flex gap-3">
<button type="button" onclick="closeModal('addStaffModal')" class="flex-1 px-4 py-2 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 transition">
{% trans "Cancel" %}
</button>
<button type="submit" class="flex-1 px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center justify-center gap-2">
<i data-lucide="save" class="w-4 h-4"></i> {% trans "Add Staff" %}
</button>
</div>
</form>
</div>
</div>
{% endif %}
<!-- Tab Switching JavaScript --> <!-- Tab Switching JavaScript -->
<script> <script>
function showActivationRequired() { function showActivationRequired() {
@ -990,6 +1100,29 @@ function showCloseModal() {
document.getElementById('closeModal').style.display = 'flex'; document.getElementById('closeModal').style.display = 'flex';
} }
function showLocationModal() {
document.getElementById('locationModal').style.display = 'flex';
populateLocationDropdowns();
}
function showAddDepartmentModal() {
var modal = document.getElementById('addDepartmentModal');
if (!modal) return;
modal.style.display = 'flex';
var errBox = document.getElementById('addDepartmentErrors');
if (errBox) { errBox.classList.add('hidden'); errBox.innerHTML = ''; }
if (window.lucide) lucide.createIcons();
}
function showAddStaffModal() {
var modal = document.getElementById('addStaffModal');
if (!modal) return;
modal.style.display = 'flex';
var errBox = document.getElementById('addStaffErrors');
if (errBox) { errBox.classList.add('hidden'); errBox.innerHTML = ''; }
if (window.lucide) lucide.createIcons();
}
function closeModal(modalId) { function closeModal(modalId) {
document.getElementById(modalId).style.display = 'none'; document.getElementById(modalId).style.display = 'none';
} }
@ -997,13 +1130,192 @@ function closeModal(modalId) {
// Close modal when clicking outside // Close modal when clicking outside
window.onclick = function(event) { window.onclick = function(event) {
if (event && event.target) { if (event && event.target) {
var modals = ['resolveModal', 'assignModal', 'followUpModal', 'escalateModal', 'closeModal']; var modals = ['resolveModal', 'assignModal', 'followUpModal', 'escalateModal', 'closeModal', 'locationModal', 'addDepartmentModal', 'addStaffModal'];
if (modals.indexOf(event.target.id) !== -1) { if (modals.indexOf(event.target.id) !== -1) {
event.target.style.display = 'none'; event.target.style.display = 'none';
} }
} }
}; };
// Generic AJAX submit helper for the Add Department / Add Staff modals
function handleInvolvedAddSubmit(event, url, errorBoxId) {
event.preventDefault();
var form = event.target;
var formData = new FormData(form);
var errorBox = document.getElementById(errorBoxId);
if (errorBox) { errorBox.classList.add('hidden'); errorBox.innerHTML = ''; }
fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': formData.get('csrfmiddlewaretoken') || ''
}
})
.then(function (response) { return response.json(); })
.then(function (data) {
if (data.success) {
window.location.reload();
} else {
var msgs = [];
var errs = data.errors || {};
Object.keys(errs).forEach(function (field) {
var val = errs[field];
if (Array.isArray(val)) {
val.forEach(function (item) {
msgs.push(typeof item === 'string' ? item : (item.message || JSON.stringify(item)));
});
} else if (typeof val === 'string') {
msgs.push(val);
} else if (val && val.message) {
msgs.push(val.message);
}
});
if (data.error && !msgs.length) msgs.push(data.error);
if (!msgs.length) msgs.push('{% trans "Please correct the errors below." %}');
if (errorBox) {
errorBox.innerHTML = msgs.join('<br>');
errorBox.classList.remove('hidden');
}
}
})
.catch(function (err) {
console.error('Error submitting form:', err);
if (errorBox) {
errorBox.textContent = '{% trans "An error occurred. Please try again." %}';
errorBox.classList.remove('hidden');
}
});
}
function handleAddDepartmentSubmit(event) {
handleInvolvedAddSubmit(event, '{% url "complaints:involved_department_add" complaint_pk=complaint.pk %}', 'addDepartmentErrors');
}
function handleAddStaffSubmit(event) {
handleInvolvedAddSubmit(event, '{% url "complaints:involved_staff_add" complaint_pk=complaint.pk %}', 'addStaffErrors');
}
// Location modal dependent dropdowns
function populateLocationDropdowns() {
var hospitalId = document.getElementById('locationHospitalInput').value;
var locationType = document.getElementById('locationTypeSelect').value;
var category = document.getElementById('deptCategorySelect').value;
loadLocationAreas(hospitalId, locationType);
loadLocationDepartments(hospitalId, category);
}
function loadLocationAreas(hospitalId, locationType) {
var sel = document.getElementById('areaSelect');
if (!sel || !hospitalId) return;
var current = sel.dataset.current || sel.value;
var url = '/organizations/dropdowns/areas/?hospital=' + encodeURIComponent(hospitalId);
if (locationType) url += '&location_type=' + encodeURIComponent(locationType);
fetch(url)
.then(function (r) { return r.json(); })
.then(function (items) {
sel.innerHTML = '<option value="">{% trans "Select Area" %}</option>';
(items || []).forEach(function (it) {
var opt = document.createElement('option');
opt.value = it.id;
opt.textContent = ('{{ LANG }}' === 'ar' && it.name_ar) ? it.name_ar : (it.name_en || it.name_ar || it.id);
if (String(it.id) === String(current)) opt.selected = true;
sel.appendChild(opt);
});
sel.dataset.current = '';
})
.catch(function (err) { console.error('Error loading areas:', err); });
}
function loadLocationDepartments(hospitalId, category) {
var sel = document.getElementById('departmentSelect');
if (!sel || !hospitalId) return;
var current = sel.dataset.current || sel.value;
var url = '/organizations/dropdowns/departments-by-category/?hospital=' + encodeURIComponent(hospitalId);
if (category) url += '&category=' + encodeURIComponent(category);
fetch(url)
.then(function (r) { return r.json(); })
.then(function (items) {
sel.innerHTML = '<option value="">{% trans "Select Department" %}</option>';
(items || []).forEach(function (it) {
var opt = document.createElement('option');
opt.value = it.id;
opt.textContent = ('{{ LANG }}' === 'ar' && it.name_ar) ? it.name_ar : (it.name_en || it.name || it.id);
if (it.floor) opt.setAttribute('data-floor', it.floor);
if (String(it.id) === String(current)) opt.selected = true;
sel.appendChild(opt);
});
sel.dataset.current = '';
if (sel.value) {
loadLocationSections(sel.value);
applyDepartmentFloorDefault();
}
})
.catch(function (err) { console.error('Error loading departments:', err); });
}
function loadLocationSections(deptId) {
var sel = document.getElementById('sectionSelect');
if (!sel) return;
if (!deptId) {
sel.innerHTML = '<option value="">{% trans "Select Section" %}</option>';
return;
}
var current = sel.dataset.current || sel.value;
fetch('/organizations/dropdowns/sections/' + encodeURIComponent(deptId) + '/')
.then(function (r) { return r.json(); })
.then(function (items) {
sel.innerHTML = '<option value="">{% trans "Select Section" %}</option>';
(items || []).forEach(function (it) {
var opt = document.createElement('option');
opt.value = it.id;
opt.textContent = ('{{ LANG }}' === 'ar' && it.name_ar) ? it.name_ar : (it.name_en || it.name_ar || it.id);
if (String(it.id) === String(current)) opt.selected = true;
sel.appendChild(opt);
});
sel.dataset.current = '';
})
.catch(function (err) { console.error('Error loading sections:', err); });
}
function applyDepartmentFloorDefault() {
var deptSel = document.getElementById('departmentSelect');
var floorInput = document.getElementById('floorInput');
if (!deptSel || !floorInput) return;
if (floorInput.value.trim()) return; // don't overwrite a user-entered value
var selected = deptSel.options[deptSel.selectedIndex];
if (!selected) return;
var deptFloor = selected.getAttribute('data-floor') || floorInput.getAttribute('data-default-from-dept') || '';
if (deptFloor) floorInput.value = deptFloor;
}
document.addEventListener('DOMContentLoaded', function () {
var locTypeSel = document.getElementById('locationTypeSelect');
var catSel = document.getElementById('deptCategorySelect');
var deptSel = document.getElementById('departmentSelect');
var hospInput = document.getElementById('locationHospitalInput');
if (locTypeSel) {
locTypeSel.addEventListener('change', function () {
loadLocationAreas(hospInput.value, this.value);
});
}
if (catSel) {
catSel.addEventListener('change', function () {
var secSel = document.getElementById('sectionSelect');
if (secSel) secSel.innerHTML = '<option value="">{% trans "Select Section" %}</option>';
loadLocationDepartments(hospInput.value, this.value);
});
}
if (deptSel) {
deptSel.addEventListener('change', function () {
loadLocationSections(this.value);
applyDepartmentFloorDefault();
});
}
});
// SLA Countdown Timer // SLA Countdown Timer
(function() { (function() {
var el = document.getElementById('sla-countdown'); var el = document.getElementById('sla-countdown');
@ -1055,7 +1367,7 @@ window.onclick = function(event) {
})(); })();
</script> </script>
{% include "components/send_to_modal.html" with users=assignable_users departments=hospital_departments %} {% include "components/send_to_modal.html" with users=send_to_users departments=hospital_departments email_subject=send_to_email_subject email_body=send_to_email_body %}
{% include "components/department_response_modal.html" %} {% include "components/department_response_modal.html" %}
{% endblock %} {% endblock %}

View File

@ -933,7 +933,7 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
patientLookupTimer = setTimeout(function () { patientLookupTimer = setTimeout(function () {
fetch(`/complaints/api/lookup-patient/?national_id=${encodeURIComponent(val)}`) fetch(`/complaints/public/api/lookup-patient/?national_id=${encodeURIComponent(val)}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.found) { if (data.found) {

View File

@ -598,7 +598,7 @@
<!-- Explanations --> <!-- Explanations -->
{% if explanations %} {% if explanations %}
<div class="section"> <div class="section">
<h2 class="section-title"><span class="icon">💬</span> {% trans "Staff Explanations" %}</h2> <h2 class="section-title"><span class="icon">💬</span> {% trans "Send To Department" %}</h2>
{% for exp in explanations %} {% for exp in explanations %}
<div class="explanation-card"> <div class="explanation-card">
<div class="explanation-header"> <div class="explanation-header">

View File

@ -82,7 +82,7 @@
<a href="{% url 'inquiries:inquiry_list' %}" class="hover:text-navy">{% trans "Inquiries" %}</a> <a href="{% url 'inquiries:inquiry_list' %}" class="hover:text-navy">{% trans "Inquiries" %}</a>
{% endif %} {% endif %}
<i data-lucide="chevron-right" class="w-4 h-4"></i> <i data-lucide="chevron-right" class="w-4 h-4"></i>
<span class="font-bold text-navy">{{ inquiry.reference_number|truncatechars:15 }}</span> <span class="font-bold text-navy">{{ inquiry.reference_number }}</span>
<span class="ml-2 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider <span class="ml-2 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider
{% if inquiry.status == 'open' %}bg-yellow-100 text-yellow-700 {% if inquiry.status == 'open' %}bg-yellow-100 text-yellow-700
{% elif inquiry.status == 'in_progress' %}bg-blue-100 text-blue-700 {% elif inquiry.status == 'in_progress' %}bg-blue-100 text-blue-700
@ -125,8 +125,9 @@
<nav class="bg-white px-6 flex gap-6 border-b shadow-sm mb-6 rounded-t-2xl"> <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-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> <button class="py-4 text-sm tab-inactive" onclick="switchTab('timeline')" id="tab-timeline">{% trans "Timeline" %}</button>
<button class="py-4 text-sm tab-inactive" onclick="switchTab('department')" id="tab-department">{% trans "Department Response" %}</button> {% if can_admin %}
<button class="py-4 text-sm tab-inactive" onclick="switchTab('rca')" id="tab-rca">{% trans "RCA" %}</button> <button class="py-4 text-sm tab-inactive" onclick="switchTab('rca')" id="tab-rca">{% trans "RCA" %}</button>
{% endif %}
<button class="py-4 text-sm tab-inactive" onclick="switchTab('notes')" id="tab-notes"> <button class="py-4 text-sm tab-inactive" onclick="switchTab('notes')" id="tab-notes">
{% trans "Notes" %} {% trans "Notes" %}
{% if notes_count %}<span class="ml-1 px-1.5 py-0.5 bg-slate-100 text-slate-600 text-xs rounded-full">{{ notes_count }}</span>{% endif %} {% if notes_count %}<span class="ml-1 px-1.5 py-0.5 bg-slate-100 text-slate-600 text-xs rounded-full">{{ notes_count }}</span>{% endif %}
@ -184,114 +185,8 @@
{% endif %} {% endif %}
</section> </section>
<div class="grid grid-cols-2 gap-6 mt-6">
<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="user" class="w-4 h-4"></i>
{% trans "Contact Information" %}
</h3>
<ul class="space-y-3 text-[11px]">
<li class="flex justify-between border-b border-slate-100 pb-2">
<span class="text-slate font-semibold">{% trans "Name" %}</span>
<span class="text-navy font-bold">{{ inquiry.contact_name|default:"-" }}</span>
</li>
<li class="flex justify-between border-b border-slate-100 pb-2">
<span class="text-slate font-semibold">{% trans "Phone" %}</span>
<span class="text-navy font-bold">{{ inquiry.contact_phone|default:"-" }}</span>
</li>
<li class="flex justify-between">
<span class="text-slate font-semibold">{% trans "Email" %}</span>
<span class="text-navy font-bold">{{ inquiry.contact_email|default:"-" }}</span>
</li>
</ul>
</section>
<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="building-2" class="w-4 h-4"></i>
{% trans "Organization" %}
</h3>
<ul class="space-y-3 text-[11px]">
<li class="flex justify-between border-b border-slate-100 pb-2">
<span class="text-slate font-semibold">{% trans "Hospital" %}</span>
<span class="text-navy font-bold">{{ inquiry.hospital.name }}</span>
</li>
{% if inquiry.department %}
<li class="flex justify-between border-b border-slate-100 pb-2">
<span class="text-slate font-semibold">{% trans "Department" %}</span>
<span class="text-navy font-bold">{{ inquiry.department.name }}</span>
</li>
{% endif %}
{% if inquiry.legacy_location %}
<li class="flex justify-between border-b border-slate-100 pb-2">
<span class="text-slate font-semibold">{% trans "Location" %}</span>
<span class="text-navy font-bold">{{ inquiry.legacy_location.name }}</span>
</li>
{% endif %}
{% if inquiry.legacy_main_section %}
<li class="flex justify-between border-b border-slate-100 pb-2">
<span class="text-slate font-semibold">{% trans "Section" %}</span>
<span class="text-navy font-bold">{{ inquiry.legacy_main_section.name }}</span>
</li>
{% endif %}
{% if inquiry.legacy_subsection %}
<li class="flex justify-between border-b border-slate-100 pb-2">
<span class="text-slate font-semibold">{% trans "Subsection" %}</span>
<span class="text-navy font-bold">{{ inquiry.legacy_subsection.name }}</span>
</li>
{% endif %}
<li class="flex justify-between border-b border-slate-100 pb-2">
<span class="text-slate font-semibold">{% trans "Category" %}</span>
<span class="text-navy font-bold">{{ inquiry.get_category_display|default:"-" }}</span>
</li>
{% if inquiry.taxonomy_domain or inquiry.taxonomy_category or inquiry.taxonomy_subcategory or inquiry.taxonomy_classification %}
<li class="flex justify-between border-b border-slate-100 pb-2">
<span class="text-slate font-semibold">{% trans "SHCT Taxonomy" %}</span>
<span class="text-navy font-bold text-right max-w-[60%]">
{{ inquiry.taxonomy_domain.name_en|default:"" }}
{% if inquiry.taxonomy_category %} > {{ inquiry.taxonomy_category.name_en }}{% endif %}
{% if inquiry.taxonomy_subcategory %} > {{ inquiry.taxonomy_subcategory.name_en }}{% endif %}
{% if inquiry.taxonomy_classification %} > {{ inquiry.taxonomy_classification.name_en }}{% endif %}
</span>
</li>
{% endif %}
{% if inquiry.outgoing_department %} {% if inquiry.outgoing_department %}
<li class="flex justify-between border-b border-slate-100 pb-2"> <section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 mt-6">
<span class="text-slate font-semibold">{% trans "Transferred Dept" %}</span>
<span class="flex items-center gap-1">
<span class="text-navy font-bold">{{ inquiry.outgoing_department.get_localized_name }}</span>
{% if not inquiry.department_responded_at %}
<span class="px-1.5 py-0.5 rounded-full text-[9px] font-bold bg-amber-100 text-amber-700 uppercase">{% trans "Awaiting" %}</span>
{% else %}
<span class="px-1.5 py-0.5 rounded-full text-[9px] font-bold bg-green-100 text-green-700 uppercase">{% trans "Responded" %}</span>
{% endif %}
</span>
</li>
{% endif %}
{% if inquiry.timeline_sla %}
<li class="flex justify-between">
<span class="text-slate font-semibold">{% trans "SLA" %}</span>
<span class="px-2 py-0.5 rounded-full text-[9px] font-bold uppercase
{% if inquiry.timeline_sla == '24_hours' %}bg-green-100 text-green-700
{% elif inquiry.timeline_sla == '48_hours' %}bg-yellow-100 text-yellow-700
{% elif inquiry.timeline_sla == '72_hours' %}bg-orange-100 text-orange-700
{% else %}bg-red-100 text-red-700{% endif %}">
{{ inquiry.get_timeline_sla_display }}
</span>
</li>
{% endif %}
</ul>
</section>
</div>
</div>
<div id="panel-timeline" class="tab-panel hidden">
{% include "complaints/partials/inquiry_timeline_panel.html" %}
</div>
<div id="panel-department" class="tab-panel hidden space-y-6">
{% if inquiry.outgoing_department %}
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<div class="flex items-center gap-2 mb-4 pb-4 border-b <div class="flex items-center gap-2 mb-4 pb-4 border-b
{% if inquiry.department_responded_at %}border-blue-200{% elif inquiry.dept_response_is_overdue %}border-red-200{% else %}border-amber-200{% endif %}"> {% if inquiry.department_responded_at %}border-blue-200{% elif inquiry.dept_response_is_overdue %}border-red-200{% else %}border-amber-200{% endif %}">
<div class="w-10 h-10 rounded-xl flex items-center justify-center <div class="w-10 h-10 rounded-xl flex items-center justify-center
@ -499,6 +394,10 @@
{% endif %} {% endif %}
</div> </div>
<div id="panel-timeline" class="tab-panel hidden">
{% include "complaints/partials/inquiry_timeline_panel.html" %}
</div>
<div id="panel-rca" class="tab-panel hidden"> <div id="panel-rca" class="tab-panel hidden">
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<h3 class="font-bold text-navy mb-4 flex items-center gap-2"> <h3 class="font-bold text-navy mb-4 flex items-center gap-2">
@ -606,18 +505,18 @@
<span class="text-[10px] font-bold uppercase">{% trans "Edit" %}</span> <span class="text-[10px] font-bold uppercase">{% trans "Edit" %}</span>
</a> </a>
{% endif %} {% endif %}
{% if can_edit %} {% if can_admin %}
<a href="{% url 'rca:rca_create' %}?related_model=inquiry&related_id={{ inquiry.pk }}" class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition"> <a href="{% url 'rca:rca_create' %}?related_model=inquiry&related_id={{ inquiry.pk }}" class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition">
<i data-lucide="search" class="w-5 h-5 text-slate group-hover:text-purple-600"></i> <i data-lucide="search" class="w-5 h-5 text-slate group-hover:text-purple-600"></i>
<span class="text-[10px] font-bold uppercase">{% trans "RCA" %}</span> <span class="text-[10px] font-bold uppercase">{% trans "RCA" %}</span>
</a> </a>
{% endif %}
<a href="{% url 'projects:project_create' %}?related_model=inquiry&related_id={{ inquiry.pk }}" class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition"> <a href="{% url 'projects:project_create' %}?related_model=inquiry&related_id={{ inquiry.pk }}" class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition">
<i data-lucide="folder-plus" class="w-5 h-5 text-slate group-hover:text-teal-600"></i> <i data-lucide="folder-plus" class="w-5 h-5 text-slate group-hover:text-teal-600"></i>
<span class="text-[10px] font-bold uppercase">{% trans "QI Project" %}</span> <span class="text-[10px] font-bold uppercase">{% trans "QI Project" %}</span>
</a> </a>
{% endif %}
{% if can_respond %} {% if can_respond %}
<button onclick="showSendModal('{{ inquiry.id }}', 'inquiry')" class="col-span-2 p-3 border-indigo-200 bg-indigo-50 rounded-xl hover:bg-indigo-100 flex items-center justify-center gap-2 group transition"> <button onclick="showSendModal('{{ inquiry.id }}', 'inquiry'{% if inquiry.department_id %}, '{{ inquiry.department_id }}'{% endif %})" class="col-span-2 p-3 border-indigo-200 bg-indigo-50 rounded-xl hover:bg-indigo-100 flex items-center justify-center gap-2 group transition">
<i data-lucide="send" class="w-5 h-5 text-indigo-600"></i> <i data-lucide="send" class="w-5 h-5 text-indigo-600"></i>
<span class="text-[10px] font-bold text-indigo-700 uppercase">{% trans "Send to Department" %}</span> <span class="text-[10px] font-bold text-indigo-700 uppercase">{% trans "Send to Department" %}</span>
</button> </button>
@ -652,6 +551,45 @@
</form> </form>
</section> </section>
{% endif %} {% 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> </div>
</main> </main>
@ -707,29 +645,18 @@
<p class="text-[10px] font-bold text-slate uppercase mb-1">العربية</p> <p class="text-[10px] font-bold text-slate uppercase mb-1">العربية</p>
<p class="text-sm text-slate-700" dir="rtl" id="aiSuggestionArText"></p> <p class="text-sm text-slate-700" dir="rtl" id="aiSuggestionArText"></p>
</div> </div>
<button type="button" onclick="useBothAISuggestions()" class="w-full bg-navy text-white px-4 py-2 rounded-xl font-semibold hover:bg-blue transition text-sm inline-flex items-center justify-center gap-2">
<i data-lucide="check" class="w-4 h-4"></i>
{% trans "Use Both Suggestions" %}
</button>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-semibold text-navy mb-2">{% trans "Response (English)" %}</label> <label class="block text-sm font-semibold text-navy mb-2">{% trans "Your Response" %} <span class="text-red-500">*</span></label>
<textarea name="response_en" id="responseEn" rows="6" <textarea name="response" id="responseText" rows="8"
class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-navy focus:ring-2 focus:ring-navy/20 resize-none text-sm" class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-navy focus:ring-2 focus:ring-navy/20 resize-none text-sm"
placeholder="{% trans 'Enter your response in English...' %}">{{ inquiry.response_en|default:"" }}</textarea> placeholder="{% trans 'Enter your response...' %}" required>{{ inquiry.response|default:'' }}</textarea>
</div>
<div class="mb-4">
<label class="block text-sm font-semibold text-navy mb-2">{% trans "Response (Arabic)" %}</label>
<textarea name="response_ar" id="responseAr" rows="6" dir="rtl"
class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-navy focus:ring-2 focus:ring-navy/20 resize-none text-sm"
placeholder="{% trans 'أدخل ردك باللغة العربية...' %}">{{ inquiry.response_ar|default:"" }}</textarea>
</div> </div>
<p class="text-xs text-slate-400 mt-1"> <p class="text-xs text-slate-400 mt-1">
<i data-lucide="info" class="w-3 h-3 inline mr-1"></i> <i data-lucide="info" class="w-3 h-3 inline mr-1"></i>
{% trans "At least one language is required. The response will be sent to the inquirer via SMS and Email." %} {% trans "The response will be sent to the inquirer via SMS and Email." %}
</p> </p>
</div> </div>
<div class="p-6 border-t border-slate-200 flex gap-3"> <div class="p-6 border-t border-slate-200 flex gap-3">
@ -1085,21 +1012,18 @@ function generateAIResponse() {
} }
function useAISuggestion(lang) { function useAISuggestion(lang) {
var text = '';
var card = null;
if (lang === 'en') { if (lang === 'en') {
document.getElementById('responseEn').value = document.getElementById('aiSuggestionEnText').textContent; text = document.getElementById('aiSuggestionEnText').textContent;
document.getElementById('aiSuggestionEn').classList.add('selected'); card = document.getElementById('aiSuggestionEn');
setTimeout(() => document.getElementById('aiSuggestionEn').classList.remove('selected'), 1500);
} else { } else {
document.getElementById('responseAr').value = document.getElementById('aiSuggestionArText').textContent; text = document.getElementById('aiSuggestionArText').textContent;
document.getElementById('aiSuggestionAr').classList.add('selected'); card = document.getElementById('aiSuggestionAr');
setTimeout(() => document.getElementById('aiSuggestionAr').classList.remove('selected'), 1500);
} }
} document.getElementById('responseText').value = text;
card.classList.add('selected');
function useBothAISuggestions() { setTimeout(() => card.classList.remove('selected'), 1500);
useAISuggestion('en'); useAISuggestion('ar');
document.getElementById('aiSuggestionEn').classList.add('selected');
document.getElementById('aiSuggestionAr').classList.add('selected');
} }
function reanalyzeAI() { function reanalyzeAI() {
@ -1154,7 +1078,7 @@ document.addEventListener('keydown', function(e) {
}); });
</script> </script>
{% include "components/send_to_modal.html" with users=assignable_users departments=hospital_departments %} {% include "components/send_to_modal.html" with users=send_to_users departments=hospital_departments %}
{% include "components/department_response_modal.html" %} {% include "components/department_response_modal.html" %}
{% endblock %} {% endblock %}

View File

@ -444,7 +444,7 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
patientLookupTimer = setTimeout(function () { patientLookupTimer = setTimeout(function () {
fetch(`/complaints/api/lookup-patient/?phone=${encodeURIComponent(val)}`) fetch(`/complaints/public/api/lookup-patient/?phone=${encodeURIComponent(val)}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.found) { if (data.found) {

View File

@ -15,8 +15,8 @@
<body class="bg-gray-50 min-h-screen flex items-center py-8 px-4"> <body class="bg-gray-50 min-h-screen flex items-center py-8 px-4">
<div class="w-full max-w-xl mx-auto"> <div class="w-full max-w-xl mx-auto">
<div class="bg-white rounded-2xl shadow-lg border border-gray-200 p-8 text-center"> <div class="bg-white rounded-2xl shadow-lg border border-gray-200 p-8 text-center">
<div class="w-20 h-20 mx-auto bg-amber-100 rounded-full flex items-center justify-center mb-6"> <div class="w-20 h-20 mx-auto bg-blue-100 rounded-full flex items-center justify-center mb-6">
<svg class="w-10 h-10 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-10 h-10 text-navy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg> </svg>
</div> </div>

View File

@ -15,8 +15,8 @@
<body class="bg-gray-50 min-h-screen flex items-center py-8 px-4"> <body class="bg-gray-50 min-h-screen flex items-center py-8 px-4">
<div class="w-full max-w-xl mx-auto"> <div class="w-full max-w-xl mx-auto">
<div class="bg-white rounded-2xl shadow-lg border border-gray-200 p-8 text-center"> <div class="bg-white rounded-2xl shadow-lg border border-gray-200 p-8 text-center">
<div class="w-20 h-20 mx-auto bg-amber-100 rounded-full flex items-center justify-center mb-6"> <div class="w-20 h-20 mx-auto bg-blue-100 rounded-full flex items-center justify-center mb-6">
<svg class="w-10 h-10 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-10 h-10 text-navy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg> </svg>
</div> </div>

View File

@ -11,33 +11,33 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body { font-family: 'Inter', sans-serif; } body { font-family: 'Inter', sans-serif; }
.page-header-gradient { .page-header-gradient {
background: linear-gradient(135deg, #b45309 0%, #d97706 50%, #f59e0b 100%); background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white; padding: 1.5rem 2rem; border-radius: 1rem; margin-bottom: 1.5rem; color: white; padding: 1.5rem 2rem; border-radius: 1rem; margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(217, 119, 6, 0.2); box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
} }
.form-section { .form-section {
background: #fff; border: 2px solid #e2e8f0; border-radius: 1rem; background: #fff; border: 2px solid #e2e8f0; border-radius: 1rem;
padding: 1.5rem; margin-bottom: 1.5rem; padding: 1.5rem; margin-bottom: 1.5rem;
} }
.form-section:hover { border-color: #d97706; box-shadow: 0 4px 12px rgba(217, 119, 6, 0.1); } .form-section:hover { border-color: #005696; box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1); }
.form-label { display: block; font-size: 0.875rem; font-weight: 600; color: #1e293b; margin-bottom: 0.5rem; } .form-label { display: block; font-size: 0.875rem; font-weight: 600; color: #1e293b; margin-bottom: 0.5rem; }
.form-control { .form-control {
width: 100%; padding: 0.75rem 1rem; border: 2px solid #e2e8f0; width: 100%; padding: 0.75rem 1rem; border: 2px solid #e2e8f0;
border-radius: 0.75rem; font-size: 0.875rem; transition: all 0.2s ease; border-radius: 0.75rem; font-size: 0.875rem; transition: all 0.2s ease;
} }
.form-control:focus { outline: none; border-color: #d97706; box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.1); } .form-control:focus { outline: none; border-color: #005696; box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1); }
.btn-primary { .btn-primary {
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
padding: 0.75rem 1.5rem; background: #d97706; color: white; border-radius: 0.75rem; padding: 0.75rem 1.5rem; background: #005696; color: white; border-radius: 0.75rem;
font-weight: 600; transition: all 0.2s ease; border: none; cursor: pointer; width: 100%; font-weight: 600; transition: all 0.2s ease; border: none; cursor: pointer; width: 100%;
} }
.btn-primary:hover { background: #b45309; } .btn-primary:hover { background: #007bbd; }
.btn-add { .btn-add {
display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem;
background: #fef3c7; color: #92400e; border: 2px dashed #fbbf24; border-radius: 0.75rem; background: #eef6fb; color: #005696; border: 2px dashed #93c5fd; border-radius: 0.75rem;
font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 0.875rem; font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 0.875rem;
} }
.btn-add:hover { background: #fde68a; border-color: #f59e0b; } .btn-add:hover { background: #dbeaf6; border-color: #005696; }
.btn-remove { .btn-remove {
width: 2rem; height: 2rem; display: flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; display: flex; align-items: center; justify-content: center;
background: #fee2e2; color: #dc2626; border: none; border-radius: 0.5rem; cursor: pointer; background: #fee2e2; color: #dc2626; border: none; border-radius: 0.5rem; cursor: pointer;
@ -74,7 +74,7 @@
{% endif %} {% endif %}
{% if explanation.staff %} {% if explanation.staff %}
<div class="p-4 text-white rounded-lg mb-6" style="background:#1e3a5f"> <div class="p-4 text-white rounded-lg mb-6" style="background:#005696">
<h3 class="text-sm font-semibold text-blue-200 mb-1">{% trans "Investigating as" %}</h3> <h3 class="text-sm font-semibold text-blue-200 mb-1">{% trans "Investigating as" %}</h3>
<p class="font-bold">{{ explanation.staff.first_name }} {{ explanation.staff.last_name }}</p> <p class="font-bold">{{ explanation.staff.first_name }} {{ explanation.staff.last_name }}</p>
{% if explanation.staff.department %}<p class="text-sm text-blue-100">{{ explanation.staff.department.name }}</p>{% endif %} {% if explanation.staff.department %}<p class="text-sm text-blue-100">{{ explanation.staff.department.name }}</p>{% endif %}
@ -95,8 +95,8 @@
{% if accused_staff %} {% if accused_staff %}
<div class="space-y-2"> <div class="space-y-2">
{% for s in accused_staff %} {% for s in accused_staff %}
<label class="flex items-center gap-3 p-3 border-2 border-gray-200 rounded-xl cursor-pointer hover:border-amber-400 transition"> <label class="flex items-center gap-3 p-3 border-2 border-gray-200 rounded-xl cursor-pointer hover:border-navy transition">
<input type="checkbox" name="accused_staff[]" value="{{ s.staff_id }}" checked class="w-5 h-5 rounded text-amber-600"> <input type="checkbox" name="accused_staff[]" value="{{ s.staff_id }}" checked class="w-5 h-5 rounded accent-navy">
<div class="flex-1"> <div class="flex-1">
<span class="font-semibold" style="color:#1e293b">{{ s.staff.get_full_name }}</span> <span class="font-semibold" style="color:#1e293b">{{ s.staff.get_full_name }}</span>
{% if s.staff.job_title %}<span class="text-sm text-gray-400 ml-2">{{ s.staff.job_title }}</span>{% endif %} {% if s.staff.job_title %}<span class="text-sm text-gray-400 ml-2">{{ s.staff.job_title }}</span>{% endif %}
@ -115,7 +115,7 @@
<div id="questions-container" class="space-y-3"> <div id="questions-container" class="space-y-3">
<div class="question-row flex items-start gap-2"> <div class="question-row flex items-start gap-2">
<span class="q-num mt-2 text-sm font-bold text-gray-400">1.</span> <span class="q-num mt-2 text-sm font-bold text-gray-400">1.</span>
<input type="text" name="questions[]" class="form-control flex-1" placeholder="{% trans 'Enter your question...' %}" required> <input type="text" name="questions[]" class="form-control flex-1" placeholder="{% trans 'Enter your question...' %}" onkeydown="handleQuestionKeydown(event)" required>
<button type="button" class="btn-remove mt-1" onclick="removeQuestion(this)" title="{% trans 'Remove' %}">&times;</button> <button type="button" class="btn-remove mt-1" onclick="removeQuestion(this)" title="{% trans 'Remove' %}">&times;</button>
</div> </div>
</div> </div>
@ -145,10 +145,18 @@
row.className = 'question-row flex items-start gap-2'; row.className = 'question-row flex items-start gap-2';
row.innerHTML = ` row.innerHTML = `
<span class="q-num mt-2 text-sm font-bold text-gray-400">${qCount}.</span> <span class="q-num mt-2 text-sm font-bold text-gray-400">${qCount}.</span>
<input type="text" name="questions[]" class="form-control flex-1" placeholder="Enter your question..." required> <input type="text" name="questions[]" class="form-control flex-1" placeholder="Enter your question..." onkeydown="handleQuestionKeydown(event)" required>
<button type="button" class="btn-remove mt-1" onclick="removeQuestion(this)" title="Remove">&times;</button> <button type="button" class="btn-remove mt-1" onclick="removeQuestion(this)" title="Remove">&times;</button>
`; `;
container.appendChild(row); container.appendChild(row);
var inputs = row.querySelectorAll('input[type="text"]');
if (inputs.length > 0) inputs[0].focus();
}
function handleQuestionKeydown(event) {
if (event.key === 'Enter') {
event.preventDefault();
addQuestion();
}
} }
function removeQuestion(btn) { function removeQuestion(btn) {
const container = document.getElementById('questions-container'); const container = document.getElementById('questions-container');

View File

@ -11,9 +11,9 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body { font-family: 'Inter', sans-serif; } body { font-family: 'Inter', sans-serif; }
.page-header-gradient { .page-header-gradient {
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 50%, #3b82f6 100%); background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white; padding: 1.5rem 2rem; border-radius: 1rem; margin-bottom: 1.5rem; color: white; padding: 1.5rem 2rem; border-radius: 1rem; margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(37, 99, 235, 0.2); box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
} }
.form-section { .form-section {
background: #fff; border: 2px solid #e2e8f0; border-radius: 1rem; background: #fff; border: 2px solid #e2e8f0; border-radius: 1rem;
@ -24,7 +24,7 @@
width: 100%; padding: 0.75rem 1rem; border: 2px solid #e2e8f0; width: 100%; padding: 0.75rem 1rem; border: 2px solid #e2e8f0;
border-radius: 0.75rem; font-size: 0.875rem; transition: all 0.2s ease; border-radius: 0.75rem; font-size: 0.875rem; transition: all 0.2s ease;
} }
.form-control:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); } .form-control:focus { outline: none; border-color: #005696; box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1); }
.btn-primary { .btn-primary {
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
padding: 0.75rem 1.5rem; background: #005696; color: white; border-radius: 0.75rem; padding: 0.75rem 1.5rem; background: #005696; color: white; border-radius: 0.75rem;
@ -70,8 +70,8 @@
</div> </div>
{% endfor %} {% endfor %}
<div class="p-4 rounded-xl" style="background:#fef3c7;border:1px solid #fbbf24"> <div class="p-4 rounded-xl" style="background:#eef6fb;border:1px solid #93c5fd">
<p class="text-sm" style="color:#92400e"> <p class="text-sm" style="color:#005696">
<strong>{% trans "Important:" %}</strong> {% trans "This link can only be used once. After submitting, it will expire." %} <strong>{% trans "Important:" %}</strong> {% trans "This link can only be used once. After submitting, it will expire." %}
</p> </p>
</div> </div>

View File

@ -11,9 +11,9 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body { font-family: 'Inter', sans-serif; } body { font-family: 'Inter', sans-serif; }
.page-header-gradient { .page-header-gradient {
background: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 50%, #a78bfa 100%); background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white; padding: 1.5rem 2rem; border-radius: 1rem; margin-bottom: 1.5rem; color: white; padding: 1.5rem 2rem; border-radius: 1rem; margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(139, 92, 246, 0.2); box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
} }
.form-section { .form-section {
background: #fff; border: 2px solid #e2e8f0; border-radius: 1rem; background: #fff; border: 2px solid #e2e8f0; border-radius: 1rem;
@ -24,10 +24,10 @@
width: 100%; padding: 0.75rem 1rem; border: 2px solid #e2e8f0; width: 100%; padding: 0.75rem 1rem; border: 2px solid #e2e8f0;
border-radius: 0.75rem; font-size: 0.875rem; transition: all 0.2s ease; border-radius: 0.75rem; font-size: 0.875rem; transition: all 0.2s ease;
} }
.form-control:focus { outline: none; border-color: #7c3aed; box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1); } .form-control:focus { outline: none; border-color: #005696; box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1); }
.btn-primary { .btn-primary {
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
padding: 0.75rem 1.5rem; background: #7c3aed; color: white; border-radius: 0.75rem; padding: 0.75rem 1.5rem; background: #005696; color: white; border-radius: 0.75rem;
font-weight: 600; transition: all 0.2s ease; border: none; cursor: pointer; width: 100%; font-weight: 600; transition: all 0.2s ease; border: none; cursor: pointer; width: 100%;
} }
.btn-primary:hover { background: #6d28d9; } .btn-primary:hover { background: #6d28d9; }
@ -68,7 +68,7 @@
{% for sd in staff_data %} {% for sd in staff_data %}
<div class="staff-card"> <div class="staff-card">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-gray-100"> <div class="flex items-center gap-3 mb-4 pb-3 border-b border-gray-100">
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm" style="background:#7c3aed"> <div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm" style="background:#005696">
{{ sd.staff.first_name|first }}{{ sd.staff.last_name|first }} {{ sd.staff.first_name|first }}{{ sd.staff.last_name|first }}
</div> </div>
<div> <div>

View File

@ -1,13 +1,30 @@
{% load i18n %} {% load i18n %}
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<h3 class="text-xl font-bold text-navy mb-6">{% trans "Related PX Actions" %}</h3> <div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<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">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Create Action" %}
</button>
<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" %}
</a>
<a href="{% url 'rca:rca_create' %}?related_model=complaint&related_id={{ complaint.pk }}"
class="px-3 py-2 bg-purple-600 text-white rounded-xl font-semibold hover:bg-purple-700 transition inline-flex items-center gap-2 text-sm">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Initiate RCA" %}
</a>
</div>
{% endif %}
</div>
{% if px_actions %} {% if px_actions %}
<div class="space-y-4"> <div class="space-y-3">
{% for action in px_actions %} {% for action in px_actions %}
<div class="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition"> <div class="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <div class="flex-1 min-w-0">
<h4 class="font-bold text-navy mb-1">{{ action.title }}</h4> <h4 class="font-bold text-navy mb-1">{{ action.title }}</h4>
<p class="text-slate text-sm">{{ action.description|truncatewords:20 }}</p> <p class="text-slate text-sm">{{ action.description|truncatewords:20 }}</p>
<div class="flex gap-2 mt-2"> <div class="flex gap-2 mt-2">
@ -15,7 +32,7 @@
<span class="px-2 py-1 bg-slate-100 text-slate-600 rounded-lg text-xs font-bold">{{ action.get_priority_display }}</span> <span class="px-2 py-1 bg-slate-100 text-slate-600 rounded-lg text-xs font-bold">{{ action.get_priority_display }}</span>
</div> </div>
</div> </div>
<a href="{% url 'actions:action_detail' action.id %}" class="px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2"> <a href="{% url 'actions:action_detail' action.id %}" class="px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2 text-sm shrink-0 ml-3">
{% trans "View" %} <i data-lucide="arrow-right" class="w-4 h-4"></i> {% trans "View" %} <i data-lucide="arrow-right" class="w-4 h-4"></i>
</a> </a>
</div> </div>
@ -23,25 +40,16 @@
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="text-center py-12"> <div class="text-center py-8 bg-slate-50 rounded-xl border border-dashed border-slate-200">
<i data-lucide="zap" class="w-16 h-16 mx-auto text-slate-300 mb-4"></i> <i data-lucide="zap" class="w-12 h-12 mx-auto text-slate-300 mb-3"></i>
<p class="text-slate mb-4">{% trans "No PX actions created yet" %}</p> <p class="text-slate text-sm">{% trans "No PX actions created yet" %}</p>
{% if can_edit and complaint.is_active_status %} <p class="text-slate text-xs mt-1">{% trans "Use the buttons above to create an action, QI project, or RCA." %}</p>
<button onclick="createAction()" class="px-4 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">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Create Action" %}
</button>
<a href="{% url 'projects:project_create' %}?related_model=complaint&related_id={{ complaint.pk }}"
class="px-4 py-2 bg-teal-600 text-white rounded-xl font-semibold hover:bg-teal-700 transition inline-flex items-center gap-2">
<i data-lucide="folder-plus" class="w-4 h-4"></i> {% trans "Create QI Project" %}
</a>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</section> </section>
<script> <script>
function createAction() { function createAction() {
// Redirect to action create page with complaint reference
window.location.href = "{% url 'actions:action_create' %}?source_type=complaint&complaint_id={{ complaint.id }}"; window.location.href = "{% url 'actions:action_create' %}?source_type=complaint&complaint_id={{ complaint.id }}";
} }
</script> </script>

View File

@ -1,8 +1,8 @@
{% load i18n %} {% load i18n %}
{% load hospital_filters %} {% load hospital_filters %}
<section id="aiAnalysisContent" class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section id="aiAnalysisContent" class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-bold text-navy flex items-center gap-2"> <h3 class="text-lg font-bold text-navy flex items-center gap-2">
<i data-lucide="bot" class="w-6 h-6"></i> {% trans "AI Analysis" %} <i data-lucide="bot" class="w-6 h-6"></i> {% trans "AI Analysis" %}
</h3> </h3>
{% if user.is_px_admin or user.is_hospital_admin %} {% if user.is_px_admin or user.is_hospital_admin %}

View File

@ -1,201 +1,121 @@
{% load i18n %} {% load i18n %}
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-3">
<h3 class="text-xl font-bold text-navy">{% trans "Involved Departments" %}</h3> <h3 class="text-sm font-bold text-navy uppercase tracking-wide">{% trans "Involved Departments" %}</h3>
{% if can_edit and complaint.is_active_status %} {% if can_manage_actions and complaint.is_active_status %}
<a href="{% url 'complaints:involved_department_add' complaint_pk=complaint.pk %}" <button type="button" onclick="showAddDepartmentModal()"
class="px-4 py-2 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-semibold hover:opacity-90 transition flex items-center gap-2"> class="px-2.5 py-1 text-xs bg-gradient-to-r from-navy to-blue text-white rounded-lg font-semibold hover:opacity-90 transition flex items-center gap-1">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add Department" %} <i data-lucide="plus" class="w-3.5 h-3.5"></i> {% trans "Add" %}
</a> </button>
{% endif %} {% endif %}
</div> </div>
{% if can_edit and complaint.is_active_status and ai_department_suggested %} {% if can_manage_actions and complaint.is_active_status and ai_department_suggested %}
<div class="bg-blue-50 border border-blue-200 rounded-xl p-5 mb-4"> <div class="bg-blue-50 border border-blue-200 rounded-xl p-3 mb-3 flex items-center justify-between">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i data-lucide="sparkles" class="w-5 h-5 text-blue-600"></i>
</div>
<div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h4 class="font-bold text-navy">{{ complaint.department.name }}</h4> <i data-lucide="sparkles" class="w-4 h-4 text-blue-600"></i>
<span class="px-2 py-0.5 bg-blue-100 text-blue-700 rounded-lg text-xs font-semibold">{% trans "AI Suggestion" %}</span> <span class="text-xs font-semibold text-navy">{{ complaint.department.name }}</span>
</div> <span class="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded text-[10px] font-bold">{% trans "AI" %}</span>
<p class="text-sm text-slate">{% trans "AI suggested this department based on the complaint analysis." %}</p>
</div>
</div> </div>
<form method="post" action="{% url 'complaints:confirm_ai_department_suggestion' complaint_pk=complaint.pk %}" class="inline"> <form method="post" action="{% url 'complaints:confirm_ai_department_suggestion' complaint_pk=complaint.pk %}" class="inline">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="px-4 py-2 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-semibold hover:opacity-90 transition flex items-center gap-2"> <button type="submit" class="px-2 py-1 text-xs bg-navy text-white rounded-lg font-semibold hover:bg-blue transition">
<i data-lucide="check" class="w-4 h-4"></i> {% trans "Confirm" %} {% trans "Confirm" %}
</button> </button>
</form> </form>
</div> </div>
</div>
{% endif %} {% endif %}
{% if complaint.involved_departments.exists %} {% if complaint.involved_departments.exists %}
<div class="space-y-4"> <div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-slate-100">
<th class="text-left py-2 pr-2 text-[10px] font-bold text-slate/50 uppercase">{% trans "Department" %}</th>
<th class="text-left py-2 px-2 text-[10px] font-bold text-slate/50 uppercase">{% trans "Role" %}</th>
<th class="text-left py-2 px-2 text-[10px] font-bold text-slate/50 uppercase">{% trans "Status" %}</th>
{% if can_manage_actions and complaint.is_active_status %}
<th class="text-right py-2 pl-2 text-[10px] font-bold text-slate/50 uppercase"></th>
{% endif %}
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
{% for dept in complaint.involved_departments.all %} {% for dept in complaint.involved_departments.all %}
<div class="bg-white border border-slate-200 rounded-xl p-5 hover:shadow-md transition {% if dept.is_primary %}border-l-4 border-l-navy{% endif %}"> <tr class="hover:bg-slate-50/50 transition {% if dept.is_primary %}bg-navy/5{% endif %}">
<div class="flex justify-between items-start"> <td class="py-2.5 pr-2">
<div class="flex-1"> <div class="flex items-center gap-1.5">
<div class="flex items-center gap-3 mb-2">
<h4 class="font-bold text-navy">{{ dept.department.name }}</h4>
{% if dept.is_primary %} {% if dept.is_primary %}
<span class="px-2 py-1 bg-navy text-white rounded-lg text-xs font-bold">{% trans "PRIMARY" %}</span> <span class="w-1.5 h-1.5 rounded-full bg-navy shrink-0"></span>
{% endif %} {% endif %}
<span class="px-2 py-1 bg-light text-navy rounded-lg text-xs font-semibold">{{ dept.get_role_display }}</span> <span class="font-semibold text-navy text-xs">{{ dept.department.name }}</span>
</div> </div>
{% if dept.assigned_to %} {% if dept.assigned_to %}
<div class="flex items-center gap-2 text-sm text-slate mb-2"> <p class="text-[10px] text-slate mt-0.5">
<i data-lucide="user-check" class="w-4 h-4 text-blue"></i> <i data-lucide="user-check" class="w-3 h-3 inline"></i> {{ dept.assigned_to.get_full_name }}
<span>{% trans "Assigned to:" %} <strong>{{ dept.assigned_to.get_full_name }}</strong></span> </p>
{% if dept.assigned_at %}
<span class="text-slate">({{ dept.assigned_at|date:"M d, Y" }})</span>
{% endif %} {% endif %}
</div> {% 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 %} {% endif %}
</td>
{% if dept.notes %} <td class="py-2.5 px-2">
<div class="bg-slate-50 rounded-lg p-3 mt-2"> <span class="px-1.5 py-0.5 bg-light text-navy rounded text-[10px] font-semibold">{{ dept.get_role_display }}</span>
<p class="text-sm text-slate">{{ dept.notes }}</p> </td>
</div> <td class="py-2.5 px-2">
{% endif %} {% if not dept.response_submitted %}
<span class="text-[10px] text-slate/50 italic">{% trans "No response" %}</span>
{% if dept.response_submitted %} {% elif dept.manager_review_status == 'rejected' %}
{% if 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>
<div class="rounded-lg p-3 mt-3 border bg-red-50 border-red-200">
<div class="flex items-center gap-2 mb-1">
<i data-lucide="x-circle" class="w-4 h-4 text-red-500"></i>
<span class="text-sm font-semibold text-red-700">{% trans "Rejected by Manager" %}</span>
</div>
<p class="text-xs text-slate-500">{% trans "Champion needs to re-submit." %}</p>
</div>
{% elif dept.manager_review_status == 'pending' %} {% elif dept.manager_review_status == 'pending' %}
<div class="rounded-lg p-3 mt-3 border bg-amber-50 border-amber-200"> <span class="inline-flex items-center gap-1 text-[10px] font-bold text-amber-600"><i data-lucide="clock" class="w-3 h-3"></i> {% trans "Pending Review" %}</span>
<div class="flex items-center gap-2 mb-1">
<i data-lucide="clock" class="w-4 h-4 text-amber-500"></i>
<span class="text-sm font-semibold text-amber-700">{% trans "Pending Manager Review" %}</span>
</div>
<p class="text-xs text-slate-500">{% trans "Awaiting department manager approval." %}</p>
</div>
{% elif dept.acceptance_status == 'acceptable' %} {% elif dept.acceptance_status == 'acceptable' %}
<div class="rounded-lg p-3 mt-3 border bg-green-50 border-green-200"> <span class="inline-flex items-center gap-1 text-[10px] font-bold text-green-600"><i data-lucide="check-circle" class="w-3 h-3"></i> {% trans "Accepted" %}</span>
<div class="flex items-center gap-2 mb-1">
<i data-lucide="check-circle" class="w-4 h-4 text-green-500"></i>
<span class="text-sm font-semibold text-green-700">{% trans "Accepted" %}</span>
<span class="text-xs text-slate">{{ dept.response_submitted_at|date:"M d, Y H:i" }}</span>
{% if dept.accepted_by %}
<span class="text-xs text-slate">— {% trans "Reviewed by" %} {{ dept.accepted_by.get_full_name }}</span>
{% endif %}
</div>
{% if dept.response_notes_en %}
<p class="text-sm text-slate-700 mt-1">{{ dept.response_notes_en }}</p>
{% endif %}
{% if dept.response_notes_ar %}
<p class="text-sm text-slate-700 mt-1" dir="rtl">{{ dept.response_notes_ar }}</p>
{% endif %}
{% if dept.acceptance_notes %}
<p class="text-xs text-slate mt-1 italic">{% trans "Review notes:" %} {{ dept.acceptance_notes }}</p>
{% endif %}
</div>
{% elif dept.acceptance_status == 'not_acceptable' %} {% elif dept.acceptance_status == 'not_acceptable' %}
<div class="rounded-lg p-3 mt-3 border bg-red-50 border-red-200"> <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>
<div class="flex items-center gap-2 mb-1">
<i data-lucide="x-circle" class="w-4 h-4 text-red-500"></i>
<span class="text-sm font-semibold text-red-700">{% trans "Rejected by PX Admin" %}</span>
</div>
{% if dept.acceptance_notes %}
<p class="text-xs text-slate mt-1 italic">{% trans "Reason:" %} {{ dept.acceptance_notes }}</p>
{% endif %}
</div>
{% elif dept.acceptance_status == 'pending' and dept.manager_review_status == 'approved' %} {% elif dept.acceptance_status == 'pending' and dept.manager_review_status == 'approved' %}
<div class="rounded-lg p-3 mt-3 border bg-green-50 border-green-200"> <span class="inline-flex items-center gap-1 text-[10px] font-bold text-green-600"><i data-lucide="check-circle" class="w-3 h-3"></i> {% trans "Approved" %}</span>
<div class="flex items-center gap-2 mb-1">
<i data-lucide="check-circle" class="w-4 h-4 text-green-500"></i>
<span class="text-sm font-semibold text-green-700">{% trans "Manager Approved" %}</span>
<span class="text-xs text-slate">{{ dept.response_submitted_at|date:"M d, Y H:i" }}</span>
</div>
{% if dept.response_notes_en %}
<p class="text-sm text-slate-700 mt-1">{{ dept.response_notes_en }}</p>
{% endif %}
{% if dept.response_notes_ar %}
<p class="text-sm text-slate-700 mt-1" dir="rtl">{{ dept.response_notes_ar }}</p>
{% endif %}
{% if can_review_dept_response %}
<div class="flex gap-2 mt-3">
<form method="post" action="{% url 'complaints:involved_department_review_response' pk=dept.pk %}" class="inline">
{% csrf_token %}
<input type="hidden" name="acceptance_status" value="acceptable">
<button type="submit" class="px-3 py-1.5 bg-green-600 text-white text-xs font-bold rounded-lg hover:bg-green-700 transition flex items-center gap-1">
<i data-lucide="check" class="w-3 h-3"></i> {% trans "Acceptable" %}
</button>
</form>
<form method="post" action="{% url 'complaints:involved_department_review_response' pk=dept.pk %}" class="inline">
{% csrf_token %}
<input type="hidden" name="acceptance_status" value="not_acceptable">
<input type="text" name="acceptance_notes" placeholder="{% trans 'Rejection reason...' %}" required
class="px-2 py-1 border rounded-lg text-xs w-48">
<button type="submit" class="px-3 py-1.5 bg-red-600 text-white text-xs font-bold rounded-lg hover:bg-red-700 transition flex items-center gap-1">
<i data-lucide="x" class="w-3 h-3"></i> {% trans "Not Acceptable" %}
</button>
</form>
</div>
{% endif %}
</div>
{% else %} {% else %}
<div class="rounded-lg p-3 mt-3 border bg-amber-50 border-amber-200"> <span class="inline-flex items-center gap-1 text-[10px] font-bold text-amber-600"><i data-lucide="clock" class="w-3 h-3"></i> {% trans "Pending" %}</span>
<div class="flex items-center gap-2 mb-1">
<i data-lucide="clock" class="w-4 h-4 text-amber-500"></i>
<span class="text-sm font-semibold text-amber-700">{% trans "Pending Review" %}</span>
<span class="text-xs text-slate">{{ dept.response_submitted_at|date:"M d, Y H:i" }}</span>
</div>
{% if dept.response_notes_en %}
<p class="text-sm text-slate-700 mt-1">{{ dept.response_notes_en }}</p>
{% endif %} {% endif %}
</div> </td>
{% endif %} {% if can_manage_actions and complaint.is_active_status %}
{% endif %} <td class="py-2.5 pl-2 text-right">
</div> <div class="flex items-center justify-end gap-1">
{% if can_edit and complaint.is_active_status %}
<div class="flex items-center gap-2 ml-4">
{% if not dept.response_submitted %} {% if not dept.response_submitted %}
<button type="button" <button type="button"
onclick="openDeptResponseModal('complaint', '{{ dept.pk }}', '{% url 'complaints:involved_department_response' pk=dept.pk %}', '{{ complaint.reference_number }}', '{{ dept.department.name|escapejs }}')" onclick="openDeptResponseModal('complaint', '{{ dept.pk }}', '{% url 'complaints:involved_department_response' pk=dept.pk %}', '{{ complaint.reference_number }}', '{{ dept.department.name|escapejs }}')"
class="p-2 text-slate hover:text-blue transition" title="{% trans 'Submit Response' %}"> class="p-1.5 text-slate hover:text-blue transition" title="{% trans 'Submit Response' %}">
<i data-lucide="message-square" class="w-5 h-5"></i> <i data-lucide="message-square" class="w-4 h-4"></i>
</button> </button>
{% endif %} {% endif %}
<a href="{% url 'complaints:involved_department_edit' pk=dept.pk %}" <a href="{% url 'complaints:involved_department_edit' pk=dept.pk %}"
class="p-2 text-slate hover:text-navy transition" title="{% trans 'Edit' %}"> class="p-1.5 text-slate hover:text-navy transition" title="{% trans 'Edit' %}">
<i data-lucide="edit-2" class="w-5 h-5"></i> <i data-lucide="edit-2" class="w-4 h-4"></i>
</a> </a>
<form method="post" action="{% url 'complaints:involved_department_remove' pk=dept.pk %}" <form method="post" action="{% url 'complaints:involved_department_remove' pk=dept.pk %}"
class="inline" onsubmit="return confirm('{% trans "Are you sure you want to remove this department?" %}')"> class="inline" onsubmit="return confirm('{% trans "Are you sure you want to remove this department?" %}')">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="p-2 text-slate hover:text-red-500 transition" title="{% trans 'Remove' %}"> <button type="submit" class="p-1.5 text-slate hover:text-red-500 transition" title="{% trans 'Remove' %}">
<i data-lucide="trash-2" class="w-5 h-5"></i> <i data-lucide="trash-2" class="w-4 h-4"></i>
</button> </button>
</form> </form>
</div> </div>
</td>
{% endif %} {% endif %}
</div> </tr>
</div>
{% endfor %} {% endfor %}
</tbody>
</table>
</div> </div>
{% else %} {% else %}
<div class="text-center py-12"> <div class="text-center py-8">
<i data-lucide="building-2" class="w-16 h-16 mx-auto text-slate-300 mb-4"></i> <i data-lucide="building-2" class="w-10 h-10 mx-auto text-slate-300 mb-2"></i>
<p class="text-slate mb-4">{% trans "No departments involved yet" %}</p> <p class="text-slate text-sm mb-3">{% trans "No departments involved yet" %}</p>
{% if can_edit and complaint.is_active_status %} {% if can_manage_actions and complaint.is_active_status %}
<a href="{% url 'complaints:involved_department_add' complaint_pk=complaint.pk %}" <button type="button" onclick="showAddDepartmentModal()"
class="px-4 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"> class="px-3 py-1.5 text-xs bg-gradient-to-r from-navy to-blue text-white rounded-lg font-semibold hover:opacity-90 transition inline-flex items-center gap-2">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add First Department" %} <i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add First Department" %}
</a> </button>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@ -1,37 +1,12 @@
{% load i18n %} {% load i18n %}
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<h3 class="text-xl font-bold text-navy mb-6">{% trans "Staff Explanations" %}</h3> <h3 class="text-lg font-bold text-navy mb-4">{% trans "Send To Department" %}</h3>
{% if complaint.explanation_delay_reason %}
<div class="bg-amber-50 border border-amber-200 rounded-xl p-4 mb-6">
<div class="flex items-start gap-2">
<i data-lucide="alert-circle" class="w-4 h-4 text-amber-500 mt-0.5 shrink-0"></i>
<div class="min-w-0">
<p class="text-xs font-bold text-amber-700 uppercase mb-1">{% trans "Explanation Delay Reason" %}</p>
<p class="text-sm text-amber-800">{{ complaint.explanation_delay_reason }}</p>
</div>
</div>
</div>
{% endif %}
{% if can_edit and complaint.is_active_status and explanation %}
<div class="bg-slate-50 border border-slate-200 rounded-xl p-4 mb-6">
<form method="post" action="{% url 'complaints:update_explanation_delay_reason' pk=complaint.id %}">
{% csrf_token %}
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Explanation Delay Reason" %}</label>
<textarea name="explanation_delay_reason" rows="3" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none mb-3" placeholder="{% trans 'Enter reason for delay in receiving explanation...' %}">{{ complaint.explanation_delay_reason }}</textarea>
<button type="submit" class="px-4 py-2 bg-navy text-white rounded-lg text-sm font-semibold hover:bg-blue transition inline-flex items-center gap-2">
<i data-lucide="save" class="w-4 h-4"></i> {% trans "Save" %}
</button>
</form>
</div>
{% endif %}
{% if explanations %} {% if explanations %}
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-4">
<div></div> <div></div>
{% if can_edit and complaint.is_active_status %} {% if can_manage_actions and complaint.is_active_status %}
<button type="button" onclick="showSendModal('{{ complaint.id }}', 'complaint')" class="px-4 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"> <button type="button" onclick="showSendModal('{{ complaint.id }}', 'complaint')" class="px-3 py-1.5 text-xs bg-gradient-to-r from-navy to-blue text-white rounded-lg font-semibold hover:opacity-90 transition inline-flex items-center gap-2">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Send to Department" %} <i data-lucide="plus" class="w-4 h-4"></i> {% trans "Send to Department" %}
</button> </button>
{% endif %} {% endif %}
@ -121,24 +96,24 @@
{% endif %} {% endif %}
<!-- Admin Actions (for submitted explanations) --> <!-- Admin Actions (for submitted explanations) -->
{% if can_edit and complaint.is_active_status and exp.is_used and not exp.escalated_to_manager %} {% if can_manage_actions and complaint.is_active_status and exp.is_used and not exp.escalated_to_manager %}
{% with linked_dept=exp.linked_involved_department %} {% with linked_dept=exp.linked_involved_department %}
{% if not linked_dept or linked_dept.manager_review_status == 'approved' %} {% if not linked_dept or linked_dept.manager_review_status == 'approved' %}
<div class="mt-4 pt-4 border-t border-slate-100"> <div class="mt-4 pt-4 border-t border-slate-100">
{% if exp.acceptance_status == 'pending' %} {% if exp.acceptance_status == 'pending' %}
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button onclick="markExplanation('{{ exp.id|stringformat:"s" }}', 'acceptable')" class="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-semibold hover:bg-green-700 transition inline-flex items-center gap-2"> <button onclick="markExplanation('{{ exp.id|stringformat:"s" }}', 'acceptable')" class="px-3 py-1.5 bg-green-600 text-white rounded-lg text-xs font-semibold hover:bg-green-700 transition inline-flex items-center gap-2">
<i data-lucide="check-circle" class="w-4 h-4"></i> {% trans "Acceptable" %} <i data-lucide="check-circle" class="w-4 h-4"></i> {% trans "Acceptable" %}
</button> </button>
<button onclick="markExplanation('{{ exp.id|stringformat:"s" }}', 'not_acceptable')" class="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-semibold hover:bg-red-700 transition inline-flex items-center gap-2"> <button onclick="markExplanation('{{ exp.id|stringformat:"s" }}', 'not_acceptable')" class="px-3 py-1.5 bg-red-600 text-white rounded-lg text-xs font-semibold hover:bg-red-700 transition inline-flex items-center gap-2">
<i data-lucide="x-circle" class="w-4 h-4"></i> {% trans "Not Acceptable" %} <i data-lucide="x-circle" class="w-4 h-4"></i> {% trans "Not Acceptable" %}
</button> </button>
{% if exp.staff.report_to %} {% if exp.staff.report_to %}
<button type="button" data-escalate-explanation-id="{{ exp.id }}" class="escalate-btn px-4 py-2 bg-orange-500 text-white rounded-lg text-sm font-semibold hover:bg-orange-600 transition inline-flex items-center gap-2"> <button type="button" data-escalate-explanation-id="{{ exp.id }}" class="escalate-btn px-3 py-1.5 bg-orange-500 text-white rounded-lg text-xs font-semibold hover:bg-orange-600 transition inline-flex items-center gap-2">
<i data-lucide="arrow-up-circle" class="w-4 h-4"></i> {% trans "Not Acceptable & Escalate" %} <i data-lucide="arrow-up-circle" class="w-4 h-4"></i> {% trans "Not Acceptable & Escalate" %}
</button> </button>
{% else %} {% else %}
<span class="px-4 py-2 bg-slate-100 text-slate-400 rounded-lg text-sm font-semibold inline-flex items-center gap-2 cursor-not-allowed" title="{% trans 'Cannot escalate: Staff has no manager assigned' %}"> <span class="px-3 py-1.5 bg-slate-100 text-slate-400 rounded-lg text-xs font-semibold inline-flex items-center gap-2 cursor-not-allowed" title="{% trans 'Cannot escalate: Staff has no manager assigned' %}">
<i data-lucide="arrow-up-circle" class="w-4 h-4"></i> {% trans "No Manager to Escalate" %} <i data-lucide="arrow-up-circle" class="w-4 h-4"></i> {% trans "No Manager to Escalate" %}
</span> </span>
{% endif %} {% endif %}
@ -156,24 +131,70 @@
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% for inv in exp.investigation.all %}
<div class="mt-4 pt-4 border-t border-slate-100">
<div class="flex items-center gap-2 mb-3">
<i data-lucide="search" class="w-4 h-4 text-navy"></i>
<span class="text-sm font-bold text-navy">{% trans "Investigation" %}</span>
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase
{% if inv.status == 'questions_sent' %}bg-amber-100 text-amber-700
{% elif inv.status == 'answers_received' %}bg-blue-100 text-blue-700
{% elif inv.status == 'reply_submitted' %}bg-green-100 text-green-700{% endif %}">
{{ inv.get_status_display }}
</span>
<span class="text-xs text-slate ml-auto">{{ inv.questions.count }} {% trans "questions" %}</span>
</div>
<div class="space-y-1.5 mb-3">
{% for resp in inv.responses.all %}
<div class="flex items-center gap-2 text-xs">
{% if resp.is_completed %}
<i data-lucide="check-circle" class="w-3.5 h-3.5 text-green-500 shrink-0"></i>
<span class="text-slate">{{ resp.staff.get_full_name }}</span>
<span class="text-slate ml-auto">{{ resp.completed_at|date:"M d, H:i" }}</span>
{% else %}
<i data-lucide="clock" class="w-3.5 h-3.5 text-amber-500 shrink-0"></i>
<span class="text-slate">{{ resp.staff.get_full_name }}</span>
<span class="text-amber-600 font-semibold ml-auto">{% trans "Pending" %}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% if inv.status == 'answers_received' and inv.explanation.token %}
<a href="/complaints/{{ complaint.id }}/investigate/review/{{ inv.explanation.token }}/"
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="eye" class="w-3.5 h-3.5"></i> {% trans "Review & Submit Reply" %}
</a>
{% endif %}
{% if inv.status == 'reply_submitted' and inv.final_reply %}
<div class="bg-slate-50 rounded-lg p-3 mt-2">
<p class="text-xs font-bold text-slate uppercase mb-1">{% trans "Final Reply" %}</p>
<p class="text-sm text-slate-700">{{ inv.final_reply|truncatewords:30 }}</p>
</div>
{% endif %}
</div>
{% endfor %}
<!-- Pending Actions --> <!-- Pending Actions -->
{% if can_edit and complaint.is_active_status and not exp.is_used %} {% if can_manage_actions and complaint.is_active_status and not exp.is_used %}
<div class="mt-4 pt-4 border-t border-slate-100"> <div class="mt-4 pt-4 border-t border-slate-100">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{% if not exp.reminder_sent_at %} {% if not exp.reminder_sent_at %}
<button onclick="sendExplanationReminder('{{ exp.id }}', 'first')" class="px-4 py-2 bg-amber-500 text-white rounded-lg text-sm font-semibold hover:bg-amber-600 transition inline-flex items-center gap-2"> <button onclick="sendExplanationReminder('{{ exp.id }}', 'first')" class="px-3 py-1.5 bg-amber-500 text-white rounded-lg text-xs font-semibold hover:bg-amber-600 transition inline-flex items-center gap-2">
<i data-lucide="bell" class="w-4 h-4"></i> {% trans "Send Reminder" %} <i data-lucide="bell" class="w-4 h-4"></i> {% trans "Send Reminder" %}
</button> </button>
{% elif not exp.second_reminder_sent_at %} {% elif not exp.second_reminder_sent_at %}
<button onclick="sendExplanationReminder('{{ exp.id }}', 'second')" class="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm font-semibold hover:bg-orange-600 transition inline-flex items-center gap-2"> <button onclick="sendExplanationReminder('{{ exp.id }}', 'second')" class="px-3 py-1.5 bg-orange-500 text-white rounded-lg text-xs font-semibold hover:bg-orange-600 transition inline-flex items-center gap-2">
<i data-lucide="alert-triangle" class="w-4 h-4"></i> {% trans "Send Second Reminder" %} <i data-lucide="alert-triangle" class="w-4 h-4"></i> {% trans "Send Second Reminder" %}
</button> </button>
{% else %} {% else %}
<span class="px-4 py-2 bg-slate-100 text-slate-400 rounded-lg text-sm font-semibold inline-flex items-center gap-2"> <span class="px-3 py-1.5 bg-slate-100 text-slate-400 rounded-lg text-xs font-semibold inline-flex items-center gap-2">
<i data-lucide="bell-check" class="w-4 h-4"></i> {% trans "Reminders Sent" %} <i data-lucide="bell-check" class="w-4 h-4"></i> {% trans "Reminders Sent" %}
</span> </span>
{% endif %} {% endif %}
<button onclick="resendExplanation('{{ exp.token }}')" class="px-4 py-2 bg-navy text-white rounded-lg text-sm font-semibold hover:bg-blue transition inline-flex items-center gap-2"> <button onclick="resendExplanation('{{ exp.token }}')" class="px-3 py-1.5 bg-navy text-white rounded-lg text-xs font-semibold hover:bg-blue transition inline-flex items-center gap-2">
<i data-lucide="refresh-cw" class="w-4 h-4"></i> {% trans "Resend Link" %} <i data-lucide="refresh-cw" class="w-4 h-4"></i> {% trans "Resend Link" %}
</button> </button>
</div> </div>
@ -193,11 +214,11 @@
{% else %} {% else %}
<div class="text-center py-12"> <div class="text-center py-12">
<i data-lucide="message-square" class="w-16 h-16 mx-auto text-slate-300 mb-4"></i> <i data-lucide="message-square" class="w-16 h-16 mx-auto text-slate-300 mb-4"></i>
<p class="text-slate mb-4">{% trans "No explanation requests sent yet" %}</p> <p class="text-slate mb-4">{% trans "Not requests sent yet" %}</p>
{% if can_edit and complaint.is_active_status %} {% if can_manage_actions and complaint.is_active_status %}
<a href="{% url 'complaints:send_to_department_form' pk=complaint.id %}" class="px-4 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"> <button type="button" onclick="showSendModal('{{ complaint.id }}', 'complaint')" class="px-3 py-1.5 text-xs bg-gradient-to-r from-navy to-blue text-white rounded-lg font-semibold hover:opacity-90 transition inline-flex items-center gap-2">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Send to Department" %} <i data-lucide="plus" class="w-4 h-4"></i> {% trans "Send to Department" %}
</a> </button>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@ -1,15 +1,16 @@
{% load i18n %} {% load i18n %}
<section id="pdfSummaryContent" class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section id="pdfSummaryContent" class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-bold text-navy flex items-center gap-2"> <h3 class="text-lg font-bold text-navy flex items-center gap-2">
<i data-lucide="file-text" class="w-6 h-6"></i> {% trans "PDF Report" %} <i data-lucide="file-text" class="w-6 h-6"></i> {% trans "PDF Report" %}
</h3> </h3>
</div> </div>
<p class="text-slate text-sm mb-6"> <p class="text-slate text-sm mb-4">
{% trans "Generate a complaint report PDF with AI-generated summaries." %} {% trans "Generate a complaint report PDF with AI-generated summaries." %}
</p> </p>
{% if complaint.status == 'resolved' or complaint.status == 'closed' %}
<!-- State: checking --> <!-- State: checking -->
<div id="pdfSummaryChecking" class="text-center py-10"> <div id="pdfSummaryChecking" class="text-center py-10">
<div class="inline-block w-8 h-8 border-[3px] border-navy/20 border-t-navy rounded-full animate-spin"></div> <div class="inline-block w-8 h-8 border-[3px] border-navy/20 border-t-navy rounded-full animate-spin"></div>
@ -19,7 +20,7 @@
<!-- State: empty --> <!-- State: empty -->
<div id="pdfSummaryEmpty" class="hidden"> <div id="pdfSummaryEmpty" class="hidden">
<button onclick="generateSummaries()" id="generateSummariesBtn" <button onclick="generateSummaries()" id="generateSummariesBtn"
class="inline-flex items-center gap-2.5 px-6 py-3 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-bold hover:from-blue hover:to-navy transition shadow-lg text-sm w-full justify-center"> class="inline-flex items-center gap-2.5 px-4 py-2 text-xs bg-gradient-to-r from-navy to-blue text-white rounded-lg font-bold hover:from-blue hover:to-navy transition shadow-lg w-full justify-center">
<i data-lucide="sparkles" class="w-4 h-4"></i> <i data-lucide="sparkles" class="w-4 h-4"></i>
إنشاء الملخص إنشاء الملخص
</button> </button>
@ -102,12 +103,12 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button onclick="generatePdf()" id="generatePdfBtn" <button onclick="generatePdf()" id="generatePdfBtn"
class="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-bold hover:from-blue hover:to-navy transition shadow-lg text-sm flex-1 justify-center"> class="inline-flex items-center gap-2 px-4 py-2 text-xs bg-gradient-to-r from-navy to-blue text-white rounded-lg font-bold hover:from-blue hover:to-navy transition shadow-lg flex-1 justify-center">
<i data-lucide="file-text" class="w-4 h-4"></i> <i data-lucide="file-text" class="w-4 h-4"></i>
إنشاء PDF إنشاء PDF
</button> </button>
<button onclick="generateSummaries()" <button onclick="generateSummaries()"
class="inline-flex items-center gap-2 px-4 py-3 bg-slate-100 rounded-xl text-sm text-slate hover:bg-slate-200 transition font-semibold"> class="inline-flex items-center gap-2 px-3 py-2 bg-slate-100 rounded-lg text-xs text-slate hover:bg-slate-200 transition font-semibold">
<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i> <i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>
إعادة إنشاء الملخص إعادة إنشاء الملخص
</button> </button>
@ -138,12 +139,12 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a id="pdfSummaryDownloadLink" href="#" download <a id="pdfSummaryDownloadLink" href="#" download
class="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-bold hover:from-blue hover:to-navy transition shadow-lg text-sm flex-1 justify-center"> class="inline-flex items-center gap-2 px-4 py-2 text-xs bg-gradient-to-r from-navy to-blue text-white rounded-lg font-bold hover:from-blue hover:to-navy transition shadow-lg flex-1 justify-center">
<i data-lucide="download" class="w-4 h-4"></i> <i data-lucide="download" class="w-4 h-4"></i>
تحميل PDF تحميل PDF
</a> </a>
<button onclick="toggleEditForm()" id="toggleEditBtn" <button onclick="toggleEditForm()" id="toggleEditBtn"
class="inline-flex items-center gap-2 px-4 py-3 bg-slate-100 rounded-xl text-sm text-slate hover:bg-slate-200 transition font-semibold"> class="inline-flex items-center gap-2 px-3 py-2 bg-slate-100 rounded-lg text-xs text-slate hover:bg-slate-200 transition font-semibold">
<i data-lucide="edit" class="w-3.5 h-3.5"></i> <i data-lucide="edit" class="w-3.5 h-3.5"></i>
تعديل النص تعديل النص
</button> </button>
@ -209,7 +210,7 @@
class="w-full border border-slate-200 rounded-xl p-3 text-sm text-slate focus:outline-none focus:ring-2 focus:ring-navy/20 focus:border-navy resize-y"></textarea> class="w-full border border-slate-200 rounded-xl p-3 text-sm text-slate focus:outline-none focus:ring-2 focus:ring-navy/20 focus:border-navy resize-y"></textarea>
</div> </div>
<button onclick="generatePdfFromReady()" id="regeneratePdfBtn" <button onclick="generatePdfFromReady()" id="regeneratePdfBtn"
class="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-bold hover:from-blue hover:to-navy transition shadow-lg text-sm w-full justify-center"> class="inline-flex items-center gap-2 px-4 py-2 text-xs bg-gradient-to-r from-navy to-blue text-white rounded-lg font-bold hover:from-blue hover:to-navy transition shadow-lg w-full justify-center">
<i data-lucide="refresh-cw" class="w-4 h-4"></i> <i data-lucide="refresh-cw" class="w-4 h-4"></i>
تحديث PDF تحديث PDF
</button> </button>
@ -233,8 +234,19 @@
</button> </button>
</div> </div>
</div> </div>
{% else %}
<!-- Locked: complaint not resolved yet -->
<div class="text-center py-10">
<div class="w-14 h-14 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="lock" class="w-6 h-6 text-slate-400"></i>
</div>
<p class="text-slate font-semibold mb-1">{% trans "PDF report unavailable" %}</p>
<p class="text-slate text-sm">{% trans "Resolve the complaint first to enable PDF report generation." %}</p>
</div>
{% endif %}
</section> </section>
{% if complaint.status == 'resolved' or complaint.status == 'closed' %}
<script> <script>
(function() { (function() {
let _pdfBlobUrl = null; let _pdfBlobUrl = null;
@ -438,3 +450,4 @@
}; };
})(); })();
</script> </script>
{% endif %}

View File

@ -1,17 +1,7 @@
{% load i18n %} {% load i18n %}
<div class="space-y-4"> <section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<div class="flex items-center justify-between mb-4"> <h3 class="text-lg font-bold text-navy mb-1">{% trans "Root Cause Analysis" %}</h3>
<div> <p class="text-xs text-slate mb-4">{% trans "Structured analysis to identify underlying causes and prevent recurrence." %}</p>
<h3 class="text-sm font-bold text-navy">{% trans "Root Cause Analysis" %}</h3>
<p class="text-xs text-slate mt-1">{% trans "Structured analysis to identify underlying causes and prevent recurrence." %}</p>
</div>
{% if can_edit and complaint.is_active_status %}
<a href="{% url 'rca:rca_create' %}?related_model=complaint&related_id={{ complaint.pk }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white text-xs font-bold rounded-xl hover:bg-purple-700 transition">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Initiate RCA" %}
</a>
{% endif %}
</div>
{% if linked_rcas %} {% if linked_rcas %}
<div class="space-y-3"> <div class="space-y-3">
@ -21,7 +11,7 @@
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h4 class="text-sm font-bold text-navy group-hover:text-purple-700 truncate">{{ rca.title }}</h4> <h4 class="text-sm font-bold text-navy group-hover:text-purple-700 truncate">{{ rca.title }}</h4>
<p class="text-xs text-slate mt-1 line-clamp-2">{{ rca.description|truncatewords:20 }}</p> <p class="text-xs text-slate mt-1 line-clamp-2">{{ rca.description|truncatewords:20 }}</p>
<div class="flex items-center gap-3 mt-2"> <div class="flex flex-wrap items-center gap-2 mt-2">
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase <span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase
{% if rca.status == 'draft' %}bg-slate-100 text-slate-600 {% if rca.status == 'draft' %}bg-slate-100 text-slate-600
{% elif rca.status == 'in_progress' %}bg-blue-100 text-blue-700 {% elif rca.status == 'in_progress' %}bg-blue-100 text-blue-700
@ -38,34 +28,28 @@
{{ rca.get_severity_display }} {{ rca.get_severity_display }}
</span> </span>
{% if rca.assigned_to %} {% if rca.assigned_to %}
<span class="text-[10px] text-slate"> <span class="text-[10px] text-slate inline-flex items-center gap-0.5">
<i data-lucide="user" class="w-3 h-3 inline"></i> {{ rca.assigned_to.get_full_name }} <i data-lucide="user" class="w-3 h-3"></i> {{ rca.assigned_to.get_full_name }}
</span> </span>
{% endif %} {% endif %}
<span class="text-[10px] text-slate">
{% trans "Root Causes" %}: {{ rca.root_causes.count }} &middot;
{% trans "Actions" %}: {{ rca.corrective_actions.count }}
</span>
</div> </div>
</div> </div>
<div class="flex-shrink-0 ml-3 text-right"> <div class="flex-shrink-0 ml-3 text-right">
<p class="text-[10px] text-slate">{{ rca.created_at|date:"M d, Y" }}</p> <p class="text-[10px] text-slate">{{ rca.created_at|date:"M d, Y" }}</p>
<div class="mt-1 text-[10px] text-slate">
{% trans "Root Causes" %}: {{ rca.root_causes.count }} &middot;
{% trans "Actions" %}: {{ rca.corrective_actions.count }}
</div>
</div> </div>
</div> </div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="text-center py-12 bg-slate-50 rounded-xl border border-dashed border-slate-200"> <div class="text-center py-8 bg-slate-50 rounded-xl border border-dashed border-slate-200">
<i data-lucide="search" class="w-10 h-10 text-slate-300 mx-auto mb-3"></i> <i data-lucide="search" class="w-12 h-12 text-slate-300 mx-auto mb-3"></i>
<p class="text-sm text-slate font-medium">{% trans "No Root Cause Analyses yet" %}</p> <p class="text-sm text-slate font-medium">{% trans "No Root Cause Analyses yet" %}</p>
<p class="text-xs text-slate mt-1">{% trans "Initiate an RCA to investigate the root causes of this complaint." %}</p> <p class="text-xs text-slate mt-1">{% trans "Use the Initiate RCA button above to start investigating." %}</p>
{% if can_edit and complaint.is_active_status %}
<a href="{% url 'rca:rca_create' %}?related_model=complaint&related_id={{ complaint.pk }}"
class="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-purple-600 text-white text-xs font-bold rounded-xl hover:bg-purple-700 transition">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Initiate RCA" %}
</a>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </section>

View File

@ -1,10 +1,10 @@
{% load i18n %} {% load i18n %}
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<h3 class="text-xl font-bold text-navy mb-6">{% trans "Resolution" %}</h3> <h3 class="text-lg font-bold text-navy mb-4">{% trans "Resolution" %}</h3>
{% if complaint.status == 'resolved' or complaint.status == 'closed' %} {% if complaint.status == 'resolved' or complaint.status == 'closed' %}
<div class="bg-green-50 border border-green-200 rounded-2xl p-6 mb-6"> <div class="bg-green-50 border border-green-200 rounded-2xl p-4 mb-4">
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
<i data-lucide="check-circle" class="w-6 h-6 text-green-500"></i> <i data-lucide="check-circle" class="w-6 h-6 text-green-500"></i>
<h4 class="font-bold text-green-800">{% trans "Complaint Resolved" %}</h4> <h4 class="font-bold text-green-800">{% trans "Complaint Resolved" %}</h4>
@ -47,7 +47,7 @@
</div> </div>
<!-- Satisfaction Section --> <!-- Satisfaction Section -->
<div class="bg-white border-2 border-slate-200 rounded-2xl p-6 mb-6"> <div class="bg-white border-2 border-slate-200 rounded-2xl p-4 mb-4">
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
<i data-lucide="smile" class="w-5 h-5 text-navy"></i> <i data-lucide="smile" class="w-5 h-5 text-navy"></i>
<h4 class="font-bold text-navy">{% trans "Patient Satisfaction" %}</h4> <h4 class="font-bold text-navy">{% trans "Patient Satisfaction" %}</h4>
@ -116,36 +116,15 @@
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}
<div class="bg-yellow-50 border border-yellow-200 rounded-2xl p-6 mb-6">
<div class="flex items-center gap-2 mb-4">
<i data-lucide="clock" class="w-6 h-6 text-yellow-500"></i>
<h4 class="font-bold text-yellow-800">{% trans "Pending Resolution" %}</h4>
</div>
<p class="text-slate mb-4">{% trans "This complaint has not been resolved yet." %}</p>
</div>
{% if can_edit and complaint.is_active_status %} {% if can_manage_actions and complaint.is_active_status %}
{% if complaint.assigned_to == current_user %} {% if complaint.assigned_to == current_user or can_manage_actions %}
<form method="post" action="{% url 'complaints:complaint_change_status' pk=complaint.pk %}" id="resolutionForm"> <form method="post" action="{% url 'complaints:complaint_change_status' pk=complaint.pk %}" id="resolutionForm">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="status" value="resolved"> <input type="hidden" name="status" value="resolved">
<!-- AI Generate Resolution Button -->
{% if explanations %}
<div class="mb-4">
<button type="button" onclick="generateAIResolution()" id="aiGenerateBtn" class="w-full px-4 py-3 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-semibold hover:opacity-90 transition flex items-center justify-center gap-2">
<i data-lucide="sparkles" class="w-5 h-5"></i>
<span>{% trans "Analyze Complaint & Generate Resolution Note" %}</span>
</button>
<div id="aiLoading" class="hidden mt-3 text-center">
<div class="inline-flex items-center gap-2 text-slate">
<i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i>
<span>{% trans "AI is analyzing complaint and explanations..." %}</span>
</div>
</div>
</div>
<!-- AI Generated Resolutions Selection --> <!-- AI Generated Resolutions Selection -->
{% if explanations %}
<div id="aiResolutionSelection" class="hidden mb-4"> <div id="aiResolutionSelection" class="hidden mb-4">
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Select AI Generated Resolution" %}</label> <label class="block text-sm font-semibold text-slate mb-2">{% trans "Select AI Generated Resolution" %}</label>
<div class="space-y-3"> <div class="space-y-3">
@ -197,9 +176,27 @@
<textarea name="resolution_outcome_other" id="resolutionOutcomeOther" rows="3" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="{% trans 'Specify who was in wrong/right...' %}"></textarea> <textarea name="resolution_outcome_other" id="resolutionOutcomeOther" rows="3" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="{% trans 'Specify who was in wrong/right...' %}"></textarea>
</div> </div>
<button type="submit" class="w-full px-6 py-3 bg-green-500 text-white rounded-xl font-bold hover:bg-green-600 transition flex items-center justify-center gap-2"> <!-- Action buttons: AI Generate + Mark as Resolved -->
<div class="flex gap-3">
{% if explanations %}
<button type="button" onclick="generateAIResolution()" id="aiGenerateBtn"
class="flex-1 px-3 py-2 text-sm bg-gradient-to-r from-navy to-blue text-white rounded-lg font-semibold hover:opacity-90 transition flex items-center justify-center gap-2">
<i data-lucide="sparkles" class="w-5 h-5"></i>
<span>{% trans "Analyze & Generate" %}</span>
</button>
{% endif %}
<button type="submit" class="flex-1 px-3 py-2 text-sm bg-green-500 text-white rounded-lg font-bold hover:bg-green-600 transition flex items-center justify-center gap-2">
<i data-lucide="check-circle" class="w-5 h-5"></i> {% trans "Mark as Resolved" %} <i data-lucide="check-circle" class="w-5 h-5"></i> {% trans "Mark as Resolved" %}
</button> </button>
</div>
{% if explanations %}
<div id="aiLoading" class="hidden mt-3 text-center">
<div class="inline-flex items-center gap-2 text-slate">
<i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i>
<span>{% trans "AI is analyzing complaint and explanations..." %}</span>
</div>
</div>
{% endif %}
</form> </form>
{% else %} {% else %}
<!-- Show message that activation is required to resolve --> <!-- Show message that activation is required to resolve -->

View File

@ -1,102 +1,98 @@
{% load i18n %} {% load i18n %}
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-3">
<h3 class="text-xl font-bold text-navy">{% trans "Involved Staff" %}</h3> <h3 class="text-sm font-bold text-navy uppercase tracking-wide">{% trans "Involved Staff" %}</h3>
{% if can_edit and complaint.is_active_status %} {% if can_manage_actions and complaint.is_active_status %}
<a href="{% url 'complaints:involved_staff_add' complaint_pk=complaint.pk %}" <button type="button" onclick="showAddStaffModal()"
class="px-4 py-2 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-semibold hover:opacity-90 transition flex items-center gap-2"> class="px-2.5 py-1 text-xs bg-gradient-to-r from-navy to-blue text-white rounded-lg font-semibold hover:opacity-90 transition flex items-center gap-1">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add Staff" %} <i data-lucide="plus" class="w-3.5 h-3.5"></i> {% trans "Add" %}
</a> </button>
{% endif %} {% endif %}
</div> </div>
{% if complaint.involved_staff.exists %} {% if complaint.involved_staff.exists %}
<div class="space-y-4"> <div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-slate-100">
<th class="text-left py-2 pr-2 text-[10px] font-bold text-slate/50 uppercase">{% trans "Staff Member" %}</th>
<th class="text-left py-2 px-2 text-[10px] font-bold text-slate/50 uppercase">{% trans "Role" %}</th>
<th class="text-left py-2 px-2 text-[10px] font-bold text-slate/50 uppercase">{% trans "Status" %}</th>
{% if can_manage_actions and complaint.is_active_status %}
<th class="text-right py-2 pl-2 text-[10px] font-bold text-slate/50 uppercase"></th>
{% endif %}
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
{% for staff_inv in complaint.involved_staff.all %} {% for staff_inv in complaint.involved_staff.all %}
<div class="bg-white border border-slate-200 rounded-xl p-5 hover:shadow-md transition"> <tr class="hover:bg-slate-50/50 transition">
<div class="flex justify-between items-start"> <td class="py-2.5 pr-2">
<div class="flex-1"> <div class="flex items-center gap-1.5">
<div class="flex items-center gap-3 mb-2"> <div class="w-7 h-7 bg-light rounded-full flex items-center justify-center shrink-0">
<div class="w-10 h-10 bg-light rounded-full flex items-center justify-center"> <i data-lucide="user" class="w-3.5 h-3.5 text-navy"></i>
<i data-lucide="user" class="w-5 h-5 text-navy"></i>
</div> </div>
<div> <div class="min-w-0">
<h4 class="font-bold text-navy">{{ staff_inv.staff.get_localized_name }}</h4> <p class="font-semibold text-navy text-xs truncate">{{ staff_inv.staff.get_localized_name }}</p>
<span class="px-2 py-0.5 bg-light text-navy rounded text-xs font-semibold">{{ staff_inv.get_role_display }}</span>
</div>
</div>
{% if staff_inv.staff.department %} {% if staff_inv.staff.department %}
<div class="flex items-center gap-2 text-sm text-slate mb-2"> <p class="text-[10px] text-slate truncate">{{ staff_inv.staff.department.name }}</p>
<i data-lucide="building-2" class="w-4 h-4 text-slate"></i>
<span>{{ staff_inv.staff.department.name }}</span>
</div>
{% endif %} {% endif %}
</div>
</div>
{% if staff_inv.notes %} {% if staff_inv.notes %}
<div class="bg-slate-50 rounded-lg p-3 mt-2"> <p class="text-[10px] text-slate/70 mt-1 italic max-w-xs truncate">{{ staff_inv.notes|truncatechars:60 }}</p>
<p class="text-sm text-slate">{{ staff_inv.notes }}</p>
</div>
{% endif %} {% 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">{{ staff_inv.get_role_display }}</span>
</td>
<td class="py-2.5 px-2">
{% if staff_inv.explanation_received %} {% if staff_inv.explanation_received %}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mt-3"> <span class="inline-flex items-center gap-1 text-[10px] font-bold text-green-600"><i data-lucide="check-circle" class="w-3 h-3"></i> {% trans "Responded" %}</span>
<div class="flex items-center gap-2 mb-1">
<i data-lucide="message-circle" class="w-4 h-4 text-blue"></i>
<span class="text-sm font-semibold text-blue-700">{% trans "Response Submitted" %}</span>
<span class="text-xs text-slate">{{ staff_inv.explanation_received_at|date:"M d, Y H:i" }}</span>
</div>
{% if staff_inv.explanation %}
<p class="text-sm text-slate-700 mt-1">{{ staff_inv.explanation }}</p>
{% endif %}
</div>
{% elif staff_inv.explanation_requested %} {% elif staff_inv.explanation_requested %}
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mt-3"> <span class="inline-flex items-center gap-1 text-[10px] font-bold text-amber-600"><i data-lucide="clock" class="w-3 h-3"></i> {% trans "Pending" %}</span>
<div class="flex items-center gap-2"> {% else %}
<i data-lucide="clock" class="w-4 h-4 text-yellow-500"></i> <span class="text-[10px] text-slate/50 italic"></span>
<span class="text-sm font-semibold text-yellow-700">{% trans "Response Requested" %}</span>
<span class="text-xs text-slate">{{ staff_inv.explanation_requested_at|date:"M d, Y" }}</span>
</div>
</div>
{% endif %} {% endif %}
</div> </td>
{% if can_manage_actions and complaint.is_active_status %}
{% if can_edit and complaint.is_active_status %} <td class="py-2.5 pl-2 text-right">
<div class="flex items-center gap-2 ml-4"> <div class="flex items-center justify-end gap-1">
{% if not staff_inv.explanation_received %} {% if not staff_inv.explanation_received %}
<form method="post" action="{% url 'complaints:involved_staff_explanation' pk=staff_inv.pk %}" class="inline"> <form method="post" action="{% url 'complaints:involved_staff_explanation' pk=staff_inv.pk %}" class="inline">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="p-2 text-slate hover:text-blue transition" title="{% trans 'Submit Response' %}"> <button type="submit" class="p-1.5 text-slate hover:text-blue transition" title="{% trans 'Submit Response' %}">
<i data-lucide="message-square" class="w-5 h-5"></i> <i data-lucide="message-square" class="w-4 h-4"></i>
</button> </button>
</form> </form>
{% endif %} {% endif %}
<a href="{% url 'complaints:involved_staff_edit' pk=staff_inv.pk %}" <a href="{% url 'complaints:involved_staff_edit' pk=staff_inv.pk %}"
class="p-2 text-slate hover:text-navy transition" title="{% trans 'Edit' %}"> class="p-1.5 text-slate hover:text-navy transition" title="{% trans 'Edit' %}">
<i data-lucide="edit-2" class="w-5 h-5"></i> <i data-lucide="edit-2" class="w-4 h-4"></i>
</a> </a>
<form method="post" action="{% url 'complaints:involved_staff_remove' pk=staff_inv.pk %}" <form method="post" action="{% url 'complaints:involved_staff_remove' pk=staff_inv.pk %}"
class="inline" onsubmit="return confirm('{% trans "Are you sure you want to remove this staff member?" %}')"> class="inline" onsubmit="return confirm('{% trans "Are you sure you want to remove this staff member?" %}')">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="p-2 text-slate hover:text-red-500 transition" title="{% trans 'Remove' %}"> <button type="submit" class="p-1.5 text-slate hover:text-red-500 transition" title="{% trans 'Remove' %}">
<i data-lucide="trash-2" class="w-5 h-5"></i> <i data-lucide="trash-2" class="w-4 h-4"></i>
</button> </button>
</form> </form>
</div> </div>
</td>
{% endif %} {% endif %}
</div> </tr>
</div>
{% endfor %} {% endfor %}
</tbody>
</table>
</div> </div>
{% else %} {% else %}
<div class="text-center py-12"> <div class="text-center py-8">
<i data-lucide="users" class="w-16 h-16 mx-auto text-slate-300 mb-4"></i> <i data-lucide="users" class="w-10 h-10 mx-auto text-slate-300 mb-2"></i>
<p class="text-slate mb-4">{% trans "No staff members involved yet" %}</p> <p class="text-slate text-sm mb-3">{% trans "No staff members involved yet" %}</p>
{% if can_edit and complaint.is_active_status %} {% if can_manage_actions and complaint.is_active_status %}
<a href="{% url 'complaints:involved_staff_add' complaint_pk=complaint.pk %}" <button type="button" onclick="showAddStaffModal()"
class="px-4 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"> class="px-3 py-1.5 text-xs bg-gradient-to-r from-navy to-blue text-white rounded-lg font-semibold hover:opacity-90 transition inline-flex items-center gap-2">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add First Staff" %} <i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add First Staff" %}
</a> </button>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@ -1,6 +1,6 @@
{% load i18n %} {% load i18n %}
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<h3 class="text-xl font-bold text-navy mb-6">{% trans "Timeline" %}</h3> <h3 class="text-lg font-bold text-navy mb-4">{% trans "Timeline" %}</h3>
{% if not stage_timeline.stages %} {% if not stage_timeline.stages %}
<div class="text-center py-12"> <div class="text-center py-12">

View File

@ -96,22 +96,22 @@ header.glass-card {
{% endfor %} {% endfor %}
</div> </div>
<div class="max-w-4xl mx-auto px-4 py-8 md:py-12"> <div class="max-w-4xl mx-auto px-4 py-6 md:py-12">
<!-- Logo Banner --> <!-- Logo Banner -->
<div class="rounded-3xl shadow-2xl overflow-hidden mb-8 text-center animate-fade-in"> <div class="rounded-2xl shadow-lg overflow-hidden mb-6 text-center animate-fade-in">
<div class="bg-white w-full py-8 px-6 flex items-center justify-center"> <div class="bg-white w-full py-6 px-4 flex items-center justify-center">
<img src="{% static 'img/hh-logo.png' %}" alt="Al Hammadi Hospital" class="max-h-16 w-auto object-contain"> <img src="{% static 'img/hh-logo.png' %}" alt="Al Hammadi Hospital" class="max-h-12 md:max-h-16 w-auto object-contain">
</div> </div>
<div class="bg-white p-8"> <div class="bg-white p-4 md:p-8">
<h1 class="text-2xl font-bold text-navy mb-3">{% trans "Track Your Complaint" %}</h1> <h1 class="text-xl md:text-2xl font-bold text-navy mb-2">{% trans "Track Your Complaint" %}</h1>
<p class="text-slate text-base max-w-xl mx-auto"> <p class="text-slate text-sm md:text-base max-w-xl mx-auto">
{% trans "Enter your reference number below to see real-time updates on your request." %} {% trans "Enter your reference number below to see real-time updates on your request." %}
</p> </p>
</div> </div>
</div> </div>
<!-- Search Form --> <!-- Search Form -->
<div class="glass-card rounded-3xl shadow-2xl p-8 mb-8 animate-fade-in"> <div class="glass-card rounded-2xl shadow-lg p-5 md:p-8 mb-6 animate-fade-in">
<form method="POST" class="max-w-lg mx-auto"> <form method="POST" class="max-w-lg mx-auto">
{% csrf_token %} {% csrf_token %}
<div class="relative group"> <div class="relative group">
@ -121,18 +121,18 @@ header.glass-card {
<input <input
type="text" type="text"
name="reference_number" name="reference_number"
class="w-full pl-12 pr-6 py-5 border-2 border-slate-100 rounded-2xl text-navy text-lg focus:ring-4 focus:ring-blue/10 focus:border-blue transition-all duration-300 bg-white/80 placeholder:text-slate/30" class="w-full pl-12 pr-4 py-4 md:py-5 border-2 border-slate-100 rounded-xl md:rounded-2xl text-navy text-base md:text-lg focus:ring-4 focus:ring-blue/10 focus:border-blue transition-all duration-300 bg-white/80 placeholder:text-slate/30"
placeholder="{% trans 'e.g., CMP-20240101-123456' %}" placeholder="{% trans 'e.g., CMP-20240101-123456' %}"
value="{{ reference_number }}" value="{{ reference_number }}"
required required
> >
</div> </div>
<button type="submit" class="w-full mt-4 bg-navy hover:bg-navy/90 text-white px-8 py-5 rounded-2xl font-bold text-lg transition-all duration-300 shadow-lg shadow-navy/20 hover:shadow-xl hover:-translate-y-1 flex items-center justify-center gap-3"> <button type="submit" class="w-full mt-3 md:mt-4 bg-navy hover:bg-navy/90 text-white px-6 md:px-8 py-4 md:py-5 rounded-xl md:rounded-2xl font-bold text-base md:text-lg transition-all duration-300 shadow-lg shadow-navy/20 hover:shadow-xl flex items-center justify-center gap-3">
<i data-lucide="crosshair" class="w-5 h-5"></i> <i data-lucide="crosshair" class="w-5 h-5"></i>
{% trans "Track Status" %} {% trans "Track Status" %}
</button> </button>
</form> </form>
<p class="text-center text-slate/50 text-xs mt-6 uppercase tracking-widest font-semibold"> <p class="text-center text-slate/50 text-xs mt-4 uppercase tracking-widest font-semibold">
<i data-lucide="info" class="w-3 h-3 inline mr-1"></i> <i data-lucide="info" class="w-3 h-3 inline mr-1"></i>
{% trans "Found in your confirmation email" %} {% trans "Found in your confirmation email" %}
</p> </p>
@ -152,74 +152,58 @@ header.glass-card {
{% if complaint %} {% if complaint %}
<div class="animate-slide-up" style="animation-delay: 0.1s"> <div class="animate-slide-up" style="animation-delay: 0.1s">
<div class="bg-white rounded-3xl shadow-2xl p-6 md:p-8 mb-6"> <div class="bg-white rounded-2xl shadow-lg p-4 md:p-6 mb-6">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-3">
<div> <div>
<span class="text-xs font-bold text-slate/40 uppercase tracking-widest block mb-1">{% trans "Case Reference" %}</span> <span class="text-[10px] font-bold text-slate/40 uppercase tracking-wider block">{% trans "Case Reference" %}</span>
<h2 class="text-3xl font-black text-navy">{{ complaint.reference_number }}</h2> <h2 class="text-xl md:text-2xl font-black text-navy">{{ complaint.reference_number }}</h2>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-2">
<div class="text-right hidden md:block"> <div class="px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider
<span class="text-xs font-bold text-slate/40 uppercase tracking-widest block mb-1">{% trans "Current Status" %}</span> {% if public_status.css == 'amber' %}bg-amber-50 text-amber-700
<p class="font-bold text-navy">{{ public_status.label }}</p> {% elif public_status.css == 'blue' %}bg-blue-50 text-blue-700
</div> {% elif public_status.css == 'emerald' %}bg-emerald-50 text-emerald-700
<div class="px-6 py-3 rounded-2xl text-sm font-black uppercase tracking-wider shadow-sm border-b-4 {% elif public_status.css == 'rose' %}bg-rose-50 text-rose-700
{% if public_status.css == 'amber' %}bg-amber-50 text-amber-700 border-amber-200 {% else %}bg-slate-50 text-slate-700{% endif %}">
{% elif public_status.css == 'blue' %}bg-blue-50 text-blue-700 border-blue-200
{% elif public_status.css == 'emerald' %}bg-emerald-50 text-emerald-700 border-emerald-200
{% elif public_status.css == 'rose' %}bg-rose-50 text-rose-700 border-rose-200
{% else %}bg-slate-50 text-slate-700 border-slate-200{% endif %}">
{{ public_status.label }} {{ public_status.label }}
</div> </div>
{% if complaint.escalated_at %} {% if complaint.escalated_at %}
<div class="px-4 py-2 rounded-2xl text-xs font-bold uppercase tracking-wider bg-red-50 text-red-700 border border-red-200 flex items-center gap-2"> <div class="px-3 py-2 rounded-xl text-[10px] font-bold uppercase tracking-wider bg-red-50 text-red-700 flex items-center gap-1">
<i data-lucide="alert-triangle" class="w-4 h-4"></i> <i data-lucide="alert-triangle" class="w-3.5 h-3.5"></i>
{% trans "Escalated" %} {% trans "Escalated" %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="mt-8 h-2 w-full bg-slate-100 rounded-full overflow-hidden"> <div class="flex flex-wrap items-center gap-x-6 gap-y-1 text-xs text-slate/60">
<span class="inline-flex items-center gap-1.5">
<i data-lucide="calendar" class="w-3.5 h-3.5"></i>
{{ complaint.created_at|date:"M d, Y" }}
</span>
<span class="inline-flex items-center gap-1.5">
<i data-lucide="building" class="w-3.5 h-3.5"></i>
{{ complaint.department.name|default:"General" }}
</span>
{% if complaint.due_at %}
<span class="inline-flex items-center gap-1.5 {% if complaint.is_overdue %}text-rose-500 font-bold{% endif %}">
<i data-lucide="clock" class="w-3.5 h-3.5"></i>
{% if complaint.is_overdue %}{% trans "Overdue" %}{% else %}{{ complaint.due_at|date:"M d, H:i" }}{% endif %}
</span>
{% endif %}
</div>
<div class="mt-3 h-1.5 w-full bg-slate-100 rounded-full overflow-hidden">
<div class="h-full bg-navy transition-all duration-1000" <div class="h-full bg-navy transition-all duration-1000"
style="width: {{ public_status.progress }}%"> style="width: {{ public_status.progress }}%">
</div> </div>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10"> <div class="bg-white rounded-2xl shadow-lg p-5 md:p-8">
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm transition-hover hover:shadow-md"> <h3 class="text-lg md:text-2xl font-bold text-navy mb-6 md:mb-10 flex items-center gap-3">
<i data-lucide="calendar" class="w-5 h-5 text-blue mb-3"></i>
<span class="block text-xs font-bold text-slate/50 uppercase">{% trans "Submitted" %}</span>
<p class="font-bold text-navy">{{ complaint.created_at|date:"M d, Y" }}</p>
</div>
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm transition-hover hover:shadow-md">
<i data-lucide="building" class="w-5 h-5 text-blue mb-3"></i>
<span class="block text-xs font-bold text-slate/50 uppercase">{% trans "Department" %}</span>
<p class="font-bold text-navy truncate">{{ complaint.department.name|default:"General" }}</p>
</div>
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm transition-hover hover:shadow-md relative overflow-hidden">
<i data-lucide="clock" class="w-5 h-5 {% if complaint.is_overdue %}text-rose-500{% else %}text-blue{% endif %} mb-3"></i>
<span class="block text-xs font-bold text-slate/50 uppercase">{% trans "SLA Deadline" %}</span>
<p class="font-bold text-navy">{{ complaint.due_at|date:"M d, H:i" }}</p>
{% if complaint.due_at and complaint.status != 'resolved' and complaint.status != 'closed' and complaint.status != 'cancelled' %}
<p id="sla-countdown" class="text-xs font-bold mt-1"
data-due-at="{{ complaint.due_at|date:'c' }}"
data-overdue="{{ complaint.is_overdue|yesno:'true,false' }}"></p>
{% endif %}
{% if complaint.is_overdue %}
<span class="absolute top-2 right-2 flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-rose-500"></span>
</span>
{% endif %}
</div>
</div>
<div class="bg-white rounded-3xl shadow-2xl p-8 md:p-10">
<h3 class="text-2xl font-bold text-navy mb-10 flex items-center gap-3">
<div class="p-2 bg-navy text-white rounded-lg"> <div class="p-2 bg-navy text-white rounded-lg">
<i data-lucide="list-checks" class="w-5 h-5"></i> <i data-lucide="list-checks" class="w-4 h-4 md:w-5 md:h-5"></i>
</div> </div>
{% trans "Resolution Journey" %} {% trans "Resolution Journey" %}
</h3> </h3>
@ -227,26 +211,27 @@ header.glass-card {
{% if public_updates %} {% if public_updates %}
<div class="space-y-1"> <div class="space-y-1">
{% for update in public_updates %} {% for update in public_updates %}
<div class="timeline-item flex gap-6 pb-10 relative"> <div class="timeline-item flex gap-3 md:gap-6 pb-6 md:pb-10 relative">
<div class="timeline-dot shrink-0 relative z-10"> <div class="timeline-dot shrink-0 relative z-10">
<div class="w-12 h-12 rounded-2xl flex items-center justify-center shadow-sm border-2 border-white <div class="w-10 h-10 md:w-12 md:h-12 rounded-2xl flex items-center justify-center shadow-sm border-2 border-white
{% if update.update_type == 'status_change' %}bg-amber-100 text-amber-600 {% if update.update_type == 'resolution' %}bg-emerald-100 text-emerald-600
{% elif update.update_type == 'resolution' %}bg-emerald-100 text-emerald-600
{% else %}bg-blue-50 text-blue-600{% endif %}"> {% else %}bg-blue-50 text-blue-600{% endif %}">
<i data-lucide="{% if update.update_type == 'status_change' %}refresh-cw{% elif update.update_type == 'resolution' %}check-circle-2{% else %}message-square{% endif %}" class="w-6 h-6"></i> <i data-lucide="{% if update.update_type == 'resolution' %}check-circle-2{% else %}message-square{% endif %}" class="w-5 h-5 md:w-6 md:h-6"></i>
</div> </div>
</div> </div>
<div class="flex-1 pt-1"> <div class="flex-1 pt-1 min-w-0">
<div class="flex flex-col md:flex-row md:items-center justify-between mb-2"> <div class="flex flex-col md:flex-row md:items-center justify-between mb-2">
<h4 class="font-black text-navy text-lg"> <h4 class="font-black text-navy text-base md:text-lg">
{% if update.update_type == 'status_change' %}{% trans "Status Updated" %} {% if update.update_type == 'resolution' %}{% trans "Final Resolution" %}
{% elif update.update_type == 'resolution' %}{% trans "Final Resolution" %} {% else %}{% trans "Department Response" %}{% endif %}
{% else %}{% trans "Update Received" %}{% endif %}
</h4> </h4>
<time class="text-sm font-medium text-slate/40">{{ update.created_at|date:"F j, Y • g:i A" }}</time> <time class="text-xs md:text-sm font-medium text-slate/40">{{ update.created_at|date:"F j, Y • g:i A" }}</time>
</div> </div>
{% if update.department_name %}
<p class="text-[10px] md:text-xs font-semibold text-slate/60 uppercase tracking-wide mb-2">{{ update.department_name }}</p>
{% endif %}
{% if update.message %} {% if update.message %}
<div class="bg-slate-50/50 rounded-2xl p-5 border border-slate-100 text-slate-700 leading-relaxed shadow-inner"> <div class="bg-slate-50/50 rounded-xl md:rounded-2xl p-4 md:p-5 border border-slate-100 text-slate-700 leading-relaxed text-sm shadow-inner">
{{ update.message|linebreaks }} {{ update.message|linebreaks }}
</div> </div>
{% endif %} {% endif %}

View File

@ -18,38 +18,15 @@
<p id="drmSubject" class="text-sm text-slate-700"></p> <p id="drmSubject" class="text-sm text-slate-700"></p>
</div> </div>
<div id="drmSingleField"> <div>
<label class="block text-sm font-semibold text-slate-700 mb-2"> <label class="block text-sm font-semibold text-slate-700 mb-2">
{% trans "Your Response" %} <span class="text-red-500">*</span> {% trans "Your Response" %} <span class="text-red-500">*</span>
</label> </label>
<textarea id="drmResponseNotes" rows="5" <textarea id="drmResponseNotes" rows="6"
class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl text-slate-700 focus:outline-none focus:border-navy focus:ring-2 focus:ring-navy/20 resize-none text-sm" class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl text-slate-700 focus:outline-none focus:border-navy focus:ring-2 focus:ring-navy/20 resize-none text-sm"
placeholder="{% trans "Enter your department's response..." %}"></textarea> placeholder="{% trans "Enter your department's response..." %}"></textarea>
</div> </div>
<div id="drmBilingualFields" class="hidden space-y-3">
<div>
<label class="block text-sm font-semibold text-slate-700 mb-2">
{% trans "Response (English)" %}
</label>
<textarea id="drmResponseEn" rows="4"
class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl text-slate-700 focus:outline-none focus:border-navy focus:ring-2 focus:ring-navy/20 resize-none text-sm"
placeholder="{% trans 'Enter your response in English...' %}"></textarea>
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 mb-2">
{% trans "Response (Arabic)" %}
</label>
<textarea id="drmResponseAr" rows="4" dir="rtl"
class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl text-slate-700 focus:outline-none focus:border-navy focus:ring-2 focus:ring-navy/20 resize-none text-sm"
placeholder="{% trans 'أدخل ردك باللغة العربية...' %}"></textarea>
</div>
<p class="text-xs text-slate">
<i data-lucide="info" class="w-3 h-3 inline mr-1"></i>
{% trans "At least one language is required." %}
</p>
</div>
<div id="drmError" class="hidden text-sm text-red-600 bg-red-50 border border-red-200 p-3 rounded-lg"></div> <div id="drmError" class="hidden text-sm text-red-600 bg-red-50 border border-red-200 p-3 rounded-lg"></div>
<div class="flex gap-3 pt-2"> <div class="flex gap-3 pt-2">
@ -75,23 +52,8 @@ function openDeptResponseModal(type, pk, url, reference, subject, existingEn, ex
document.getElementById('drmRef').textContent = reference || ''; document.getElementById('drmRef').textContent = reference || '';
document.getElementById('drmSubject').textContent = subject || ''; document.getElementById('drmSubject').textContent = subject || '';
document.getElementById('drmResponseNotes').value = existingEn || existingAr || '';
const singleField = document.getElementById('drmSingleField'); document.getElementById('drmError').classList.add('hidden');
const bilingualFields = document.getElementById('drmBilingualFields');
const errorEl = document.getElementById('drmError');
errorEl.classList.add('hidden');
if (type === 'complaint') {
bilingualFields.classList.remove('hidden');
singleField.classList.add('hidden');
document.getElementById('drmResponseEn').value = existingEn || '';
document.getElementById('drmResponseAr').value = existingAr || '';
} else {
singleField.classList.add('hidden');
bilingualFields.classList.remove('hidden');
document.getElementById('drmResponseEn').value = existingEn || '';
document.getElementById('drmResponseAr').value = existingAr || '';
}
document.getElementById('deptResponseModal').classList.remove('hidden'); document.getElementById('deptResponseModal').classList.remove('hidden');
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
@ -107,23 +69,18 @@ function submitDeptResponse() {
const submitBtn = document.getElementById('drmSubmitBtn'); const submitBtn = document.getElementById('drmSubmitBtn');
errorEl.classList.add('hidden'); errorEl.classList.add('hidden');
const value = document.getElementById('drmResponseNotes').value.trim();
if (!value) {
errorEl.textContent = '{% trans "Please enter a response." %}';
errorEl.classList.remove('hidden');
return;
}
let body = {}; let body = {};
if (_drmConfig.type === 'complaint') { if (_drmConfig.type === 'complaint') {
body.response_notes_en = document.getElementById('drmResponseEn').value.trim(); body.response_notes = value;
body.response_notes_ar = document.getElementById('drmResponseAr').value.trim();
if (!body.response_notes_en && !body.response_notes_ar) {
errorEl.textContent = '{% trans "Please enter a response in at least one language." %}';
errorEl.classList.remove('hidden');
return;
}
} else { } else {
body.response_en = document.getElementById('drmResponseEn').value.trim(); body.response_en = value;
body.response_ar = document.getElementById('drmResponseAr').value.trim();
if (!body.response_en && !body.response_ar) {
errorEl.textContent = '{% trans "Please enter a response in at least one language." %}';
errorEl.classList.remove('hidden');
return;
}
} }
submitBtn.disabled = true; submitBtn.disabled = true;

View File

@ -11,7 +11,7 @@ Required context variables:
{% endcomment %} {% endcomment %}
<div id="sendToModal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center p-4 backdrop-blur-sm"> <div id="sendToModal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center p-4 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg animate-in"> <div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl animate-in max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-slate-200"> <div class="p-6 border-b border-slate-200">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-xl font-bold text-navy flex items-center gap-2"> <h3 class="text-xl font-bold text-navy flex items-center gap-2">
@ -87,6 +87,23 @@ Required context variables:
placeholder="{% trans 'Add context or instructions...' %}"></textarea> placeholder="{% trans 'Add context or instructions...' %}"></textarea>
</div> </div>
{% if email_subject %}
<!-- Email Preview (editable) -->
<div class="mb-4 border-t border-slate-100 pt-4">
<p class="text-xs font-bold text-slate uppercase mb-3">{% trans "Email Preview" %} <span class="text-slate-400 font-normal normal-case">({% trans "editable" %})</span></p>
<div class="mb-3">
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Subject" %}</label>
<input type="text" name="email_subject" value="{{ email_subject }}"
class="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-navy/20 focus:border-navy outline-none">
</div>
<div>
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Body" %}</label>
<textarea name="email_body" rows="10"
class="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-navy/20 focus:border-navy outline-none font-mono whitespace-pre">{{ email_body }}</textarea>
</div>
</div>
{% endif %}
<!-- Error Message --> <!-- Error Message -->
<div id="sendError" class="hidden text-sm text-red-600 bg-red-50 border border-red-200 p-3 rounded-lg"></div> <div id="sendError" class="hidden text-sm text-red-600 bg-red-50 border border-red-200 p-3 rounded-lg"></div>
<div id="sendSuccess" class="hidden text-sm text-green-600 bg-green-50 border border-green-200 p-3 rounded-lg"></div> <div id="sendSuccess" class="hidden text-sm text-green-600 bg-green-50 border border-green-200 p-3 rounded-lg"></div>
@ -106,15 +123,25 @@ Required context variables:
</div> </div>
<script> <script>
function showSendModal(itemId, itemType) { function showSendModal(itemId, itemType, preselectDeptId) {
document.getElementById('sendToForm').reset();
document.getElementById('sendItemId').value = itemId; document.getElementById('sendItemId').value = itemId;
document.getElementById('sendItemType').value = itemType; document.getElementById('sendItemType').value = itemType;
document.getElementById('sendToModal').classList.remove('hidden'); document.getElementById('sendToModal').classList.remove('hidden');
document.getElementById('sendError').classList.add('hidden'); document.getElementById('sendError').classList.add('hidden');
document.getElementById('sendSuccess').classList.add('hidden'); document.getElementById('sendSuccess').classList.add('hidden');
document.getElementById('sendToForm').reset();
document.getElementById('contactPersonSection').classList.add('hidden'); document.getElementById('contactPersonSection').classList.add('hidden');
if (preselectDeptId) {
switchRecipientType('department');
var deptSelect = document.getElementById('departmentSelect');
if (deptSelect) {
deptSelect.value = preselectDeptId;
loadDepartmentContacts(preselectDeptId);
}
} else {
switchRecipientType('person'); switchRecipientType('person');
}
} }
function closeSendModal() { function closeSendModal() {
@ -127,8 +154,11 @@ function switchRecipientType(type) {
const contactPersonSection = document.getElementById('contactPersonSection'); const contactPersonSection = document.getElementById('contactPersonSection');
const personLabel = document.getElementById('recipientLabelPerson'); const personLabel = document.getElementById('recipientLabelPerson');
const deptLabel = document.getElementById('recipientLabelDepartment'); const deptLabel = document.getElementById('recipientLabelDepartment');
const personRadio = document.querySelector('input[name="recipient_type"][value="person"]');
const deptRadio = document.querySelector('input[name="recipient_type"][value="department"]');
if (type === 'person') { if (type === 'person') {
if (personRadio) personRadio.checked = true;
personSection.classList.remove('hidden'); personSection.classList.remove('hidden');
departmentSection.classList.add('hidden'); departmentSection.classList.add('hidden');
contactPersonSection.classList.add('hidden'); contactPersonSection.classList.add('hidden');
@ -137,6 +167,7 @@ function switchRecipientType(type) {
deptLabel.classList.add('border-slate-200', 'text-slate-500'); deptLabel.classList.add('border-slate-200', 'text-slate-500');
deptLabel.classList.remove('border-navy', 'bg-navy/5', 'text-navy', 'font-semibold'); deptLabel.classList.remove('border-navy', 'bg-navy/5', 'text-navy', 'font-semibold');
} else { } else {
if (deptRadio) deptRadio.checked = true;
personSection.classList.add('hidden'); personSection.classList.add('hidden');
departmentSection.classList.remove('hidden'); departmentSection.classList.remove('hidden');
deptLabel.classList.add('border-navy', 'bg-navy/5', 'text-navy', 'font-semibold'); deptLabel.classList.add('border-navy', 'bg-navy/5', 'text-navy', 'font-semibold');

View File

@ -127,15 +127,15 @@ header.glass-card {
{% endfor %} {% endfor %}
</div> </div>
<div class="max-w-4xl mx-auto px-4 py-8 md:py-12"> <div class="max-w-4xl mx-auto px-4 py-6 md:py-12">
<!-- Logo Banner --> <!-- Logo Banner -->
<div class="rounded-3xl shadow-2xl overflow-hidden mb-8 text-center animate-fade-in"> <div class="rounded-2xl shadow-lg overflow-hidden mb-6 text-center animate-fade-in">
<div class="bg-white w-full py-8 px-6 flex items-center justify-center"> <div class="bg-white w-full py-6 px-4 flex items-center justify-center">
<img src="{% static 'img/hh-logo.png' %}" alt="Al Hammadi Hospital" class="max-h-16 w-auto object-contain"> <img src="{% static 'img/hh-logo.png' %}" alt="Al Hammadi Hospital" class="max-h-12 md:max-h-16 w-auto object-contain">
</div> </div>
<div class="bg-white p-8"> <div class="bg-white p-4 md:p-8">
<h1 class="text-2xl font-bold text-navy mb-3">{% trans "Track Your Submission" %}</h1> <h1 class="text-xl md:text-2xl font-bold text-navy mb-2">{% trans "Track Your Submission" %}</h1>
<p class="text-slate text-base max-w-xl mx-auto"> <p class="text-slate text-sm md:text-base max-w-xl mx-auto">
{% trans "Select a category below and enter your reference number to check the status." %} {% trans "Select a category below and enter your reference number to check the status." %}
</p> </p>
</div> </div>
@ -221,32 +221,30 @@ header.glass-card {
<!-- Results --> <!-- Results -->
<div id="resultsBox" class="hidden animate-slide-up"> <div id="resultsBox" class="hidden animate-slide-up">
<!-- Status Header --> <!-- Status Header (compact) -->
<div class="bg-white rounded-3xl shadow-2xl p-6 md:p-8 mb-6"> <div class="bg-white rounded-2xl shadow-lg p-4 md:p-6 mb-6">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-3">
<div> <div>
<span class="text-xs font-bold text-slate/40 uppercase tracking-widest block mb-1" id="resultRefLabel"></span> <span class="text-[10px] font-bold text-slate/40 uppercase tracking-wider block" id="resultRefLabel"></span>
<h2 class="text-3xl font-black text-navy" id="resultReference"></h2> <h2 class="text-xl md:text-2xl font-black text-navy" id="resultReference"></h2>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-2">
<div id="resultStatusBadge" class="px-6 py-3 rounded-2xl text-sm font-black uppercase tracking-wider shadow-sm border-b-4"></div> <div id="resultStatusBadge" class="px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider"></div>
<div id="resultEscalated" class="px-4 py-2 rounded-2xl text-xs font-bold uppercase tracking-wider bg-red-50 text-red-700 border border-red-200 flex items-center gap-2 hidden"> <div id="resultEscalated" class="px-3 py-2 rounded-xl text-[10px] font-bold uppercase tracking-wider bg-red-50 text-red-700 flex items-center gap-1 hidden">
<i data-lucide="alert-triangle" class="w-4 h-4"></i> <i data-lucide="alert-triangle" class="w-3.5 h-3.5"></i>
{% trans "Escalated" %} {% trans "Escalated" %}
</div> </div>
</div> </div>
</div> </div>
<div class="mt-8 h-2 w-full bg-slate-100 rounded-full overflow-hidden"> <div id="resultInfoCards" class="flex flex-wrap items-center gap-x-6 gap-y-1 text-xs text-slate/60"></div>
<div class="mt-3 h-1.5 w-full bg-slate-100 rounded-full overflow-hidden">
<div id="resultProgressBar" class="h-full bg-navy transition-all duration-1000" style="width: 0%"></div> <div id="resultProgressBar" class="h-full bg-navy transition-all duration-1000" style="width: 0%"></div>
</div> </div>
</div> </div>
<!-- Info Cards -->
<div id="resultInfoCards" class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10"></div>
<!-- Timeline --> <!-- Timeline -->
<div class="bg-white rounded-3xl shadow-2xl p-8 md:p-10"> <div id="timelineBox" class="bg-white rounded-2xl shadow-lg p-5 md:p-8">
<h3 class="text-2xl font-bold text-navy mb-10 flex items-center gap-3"> <h3 class="text-lg md:text-2xl font-bold text-navy mb-6 md:mb-10 flex items-center gap-3">
<div class="p-2 bg-navy text-white rounded-lg"> <div class="p-2 bg-navy text-white rounded-lg">
<i data-lucide="list-checks" class="w-5 h-5"></i> <i data-lucide="list-checks" class="w-5 h-5"></i>
</div> </div>
@ -306,9 +304,9 @@ header.glass-card {
<!-- Back Link --> <!-- Back Link -->
<div class="text-center mt-8"> <div class="text-center mt-8">
<a href="{% url 'core:public_submit_landing' %}" class="text-sm text-blue hover:text-blue-700 transition font-medium"> <a href="{% url 'core:public_submit_landing' %}" class="inline-flex items-center gap-2 px-6 py-3 bg-navy text-white rounded-xl font-bold text-sm hover:bg-blue transition shadow-lg">
<i data-lucide="arrow-left" class="w-4 h-4 inline mr-1"></i> <i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back to Submit Feedback" %} {% trans "Submit New Feedback" %}
</a> </a>
</div> </div>
</div> </div>
@ -365,7 +363,12 @@ document.addEventListener('DOMContentLoaded', function() {
btn.innerHTML = originalBtn; btn.innerHTML = originalBtn;
document.getElementById('loadingBox').classList.add('hidden'); document.getElementById('loadingBox').classList.add('hidden');
if (data.found) { if (data.found && data.expired) {
document.getElementById('errorText').textContent = "{% trans 'This tracking link has expired. Tracking is available for 5 days after resolution. Please contact the PX team if you need assistance.' %}";
document.getElementById('errorBox').classList.remove('hidden');
document.getElementById('errorBox').style.display = 'flex';
lucide.createIcons();
} else if (data.found) {
renderResults(data); renderResults(data);
} else { } else {
document.getElementById('errorText').textContent = data.error || "{% trans 'No submission found with this reference number.' %}"; document.getElementById('errorText').textContent = data.error || "{% trans 'No submission found with this reference number.' %}";
@ -414,14 +417,11 @@ document.addEventListener('DOMContentLoaded', function() {
var cardsHtml = ''; var cardsHtml = '';
(data.info_cards || []).forEach(function(card) { (data.info_cards || []).forEach(function(card) {
var iconColor = card.alert ? 'text-rose-500' : (card.severity === 'critical' || card.severity === 'high' ? 'text-rose-500' : card.severity === 'medium' ? 'text-amber-500' : 'text-blue'); var iconColor = card.alert ? 'text-rose-500' : 'text-slate/60';
var alertDot = card.alert ? '<span class="absolute top-2 right-2 flex h-2 w-2"><span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span><span class="relative inline-flex rounded-full h-2 w-2 bg-rose-500"></span></span>' : ''; cardsHtml += '<span class="inline-flex items-center gap-1.5 ' + (card.alert ? 'text-rose-500 font-bold' : '') + '">' +
cardsHtml += '<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm hover:shadow-md transition relative overflow-hidden">' + '<i data-lucide="' + card.icon + '" class="w-3.5 h-3.5 ' + iconColor + '"></i>' +
'<i data-lucide="' + card.icon + '" class="w-5 h-5 ' + iconColor + ' mb-3"></i>' + card.value +
'<span class="block text-xs font-bold text-slate/50 uppercase">' + card.label + '</span>' + '</span>';
'<p class="font-bold text-navy truncate">' + card.value + '</p>' +
alertDot +
'</div>';
}); });
document.getElementById('resultInfoCards').innerHTML = cardsHtml; document.getElementById('resultInfoCards').innerHTML = cardsHtml;
@ -433,20 +433,23 @@ document.addEventListener('DOMContentLoaded', function() {
noTimelineEl.classList.add('hidden'); noTimelineEl.classList.add('hidden');
var tlHtml = ''; var tlHtml = '';
timeline.forEach(function(item) { timeline.forEach(function(item) {
var iconBg = item.type === 'status_change' ? 'bg-amber-100 text-amber-600' : (item.type === 'resolution' || item.type === 'response' ? 'bg-emerald-100 text-emerald-600' : 'bg-blue-50 text-blue-600'); var iconBg = item.type === 'resolution' ? 'bg-emerald-100 text-emerald-600' : 'bg-blue-50 text-blue-600';
tlHtml += '<div class="timeline-item flex gap-6 pb-10 relative">' + tlHtml += '<div class="timeline-item flex gap-3 md:gap-6 pb-6 md:pb-10 relative">' +
'<div class="timeline-dot shrink-0 relative z-10">' + '<div class="timeline-dot shrink-0 relative z-10">' +
'<div class="w-12 h-12 rounded-2xl flex items-center justify-center shadow-sm border-2 border-white ' + iconBg + '">' + '<div class="w-10 h-10 md:w-12 md:h-12 rounded-2xl flex items-center justify-center shadow-sm border-2 border-white ' + iconBg + '">' +
'<i data-lucide="' + item.icon + '" class="w-6 h-6"></i>' + '<i data-lucide="' + item.icon + '" class="w-5 h-5 md:w-6 md:h-6"></i>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div class="flex-1 pt-1">' + '<div class="flex-1 pt-1 min-w-0">' +
'<div class="flex flex-col md:flex-row md:items-center justify-between mb-2">' + '<div class="flex flex-col md:flex-row md:items-center justify-between mb-2">' +
'<h4 class="font-black text-navy text-lg">' + item.title + '</h4>' + '<h4 class="font-black text-navy text-base md:text-lg">' + item.title + '</h4>' +
'<time class="text-sm font-medium text-slate/40">' + item.created_at + '</time>' + '<time class="text-xs md:text-sm font-medium text-slate/40">' + item.created_at + '</time>' +
'</div>'; '</div>';
if (item.department) {
tlHtml += '<p class="text-[10px] md:text-xs font-semibold text-slate/60 uppercase tracking-wide mb-2">' + item.department + '</p>';
}
if (item.comment) { if (item.comment) {
tlHtml += '<div class="bg-slate-50/50 rounded-2xl p-5 border border-slate-100 text-slate-700 leading-relaxed shadow-inner">' + item.comment.replace(/\n/g, '<br>') + '</div>'; tlHtml += '<div class="bg-slate-50/50 rounded-xl md:rounded-2xl p-4 md:p-5 border border-slate-100 text-slate-700 leading-relaxed text-sm shadow-inner">' + item.comment.replace(/\n/g, '<br>') + '</div>';
} }
tlHtml += '</div></div>'; tlHtml += '</div></div>';
}); });
@ -460,31 +463,21 @@ document.addEventListener('DOMContentLoaded', function() {
var responseBox = document.getElementById('responseBox'); var responseBox = document.getElementById('responseBox');
var responseContent = document.getElementById('responseContent'); var responseContent = document.getElementById('responseContent');
var timelineBox = document.getElementById('timelineBox');
var resp = data.response || {}; var resp = data.response || {};
if (resp.has_response && (resp.en || resp.ar)) { if (resp.has_response && (resp.en || resp.ar)) {
var rHtml = '<div class="space-y-6">'; var responseText = resp.ar || resp.en;
if (resp.en) { var isRtl = !!resp.ar;
rHtml += '<div class="bg-emerald-50/50 rounded-2xl p-6 border border-emerald-100">' + var rHtml = '<div class="bg-emerald-50/50 rounded-2xl p-6 border border-emerald-100"' + (isRtl ? ' dir="rtl"' : '') + '>' +
'<div class="flex items-center gap-2 mb-3">' + '<div class="text-slate-700 leading-relaxed whitespace-pre-line"' + (isRtl ? ' style="text-align: right;"' : '') + '>' + escapeHtml(responseText) + '</div>' +
'<span class="text-xs font-bold text-emerald-600 uppercase tracking-wider">English</span>' +
'</div>' +
'<div class="text-slate-700 leading-relaxed whitespace-pre-line">' + escapeHtml(resp.en) + '</div>' +
'</div>'; '</div>';
}
if (resp.ar) {
rHtml += '<div class="bg-emerald-50/50 rounded-2xl p-6 border border-emerald-100" dir="rtl">' +
'<div class="flex items-center gap-2 mb-3">' +
'<span class="text-xs font-bold text-emerald-600 uppercase tracking-wider">العربية</span>' +
'</div>' +
'<div class="text-slate-700 leading-relaxed whitespace-pre-line" style="text-align: right;">' + escapeHtml(resp.ar) + '</div>' +
'</div>';
}
rHtml += '</div>';
responseContent.innerHTML = rHtml; responseContent.innerHTML = rHtml;
responseBox.classList.remove('hidden'); responseBox.classList.remove('hidden');
timelineBox.classList.add('hidden');
} else { } else {
responseContent.innerHTML = ''; responseContent.innerHTML = '';
responseBox.classList.add('hidden'); responseBox.classList.add('hidden');
timelineBox.classList.remove('hidden');
} }
var satSection = document.getElementById('satisfactionSection'); var satSection = document.getElementById('satisfactionSection');

View File

@ -262,7 +262,7 @@
{% else %}bg-green-100 text-green-700{% endif %}"> {% else %}bg-green-100 text-green-700{% endif %}">
{{ action.priority }} {{ action.priority }}
</span> </span>
{% if can_edit %} {% if can_admin %}
<form method="post" action="{% url 'feedback:feedback_create_action' feedback.id %}"> <form method="post" action="{% url 'feedback:feedback_create_action' feedback.id %}">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action_title" value="{{ action.action_en }}"> <input type="hidden" name="action_title" value="{{ action.action_en }}">
@ -326,6 +326,22 @@
<hr class="border-slate-100"> <hr class="border-slate-100">
{% if feedback.department %}
<div>
<form method="post" action="{% url 'feedback:feedback_send_to_department' feedback.id %}">
{% csrf_token %}
<label class="text-[10px] font-bold text-slate uppercase tracking-wider">{% trans "Notify Department" %}</label>
<textarea name="note" rows="2" placeholder="{% trans 'Optional message to department...' %}" class="w-full mt-1 px-3 py-2 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-navy"></textarea>
<button type="submit" class="w-full mt-2 px-4 py-2 bg-indigo-600 text-white text-sm font-semibold rounded-lg hover:bg-indigo-700 transition flex items-center justify-center gap-2">
<i data-lucide="send" class="w-3.5 h-3.5"></i>{% trans "Send to Department" %}
</button>
</form>
</div>
<hr class="border-slate-100">
{% endif %}
{% if can_admin %}
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-bold text-slate uppercase tracking-wider">{% trans "Linked Actions" %}</label> <label class="text-[10px] font-bold text-slate uppercase tracking-wider">{% trans "Linked Actions" %}</label>
<a href="{% url 'rca:rca_create' %}?related_model=feedback&related_id={{ feedback.pk }}" <a href="{% url 'rca:rca_create' %}?related_model=feedback&related_id={{ feedback.pk }}"
@ -339,6 +355,7 @@
{% trans "Create QI Project" %} {% trans "Create QI Project" %}
</a> </a>
</div> </div>
{% endif %}
</div> </div>
</section> </section>
{% endif %} {% endif %}

View File

@ -211,6 +211,18 @@
{% endif %}{# end core feedback section #} {% endif %}{# end core feedback section #}
<!-- QI Projects -->
<a href="{% url 'projects:project_list' %}"
class="flex items-center gap-3 p-3 rounded-lg transition {% if '/projects/' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
<i data-lucide="folder-kanban" class="w-5 h-5 flex-shrink-0"></i>
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "QI Projects" %}</span>
</a>
<a href="{% url 'projects:my_tasks' %}"
class="flex items-center gap-3 p-3 rounded-lg transition {% if '/projects/my-tasks/' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
<i data-lucide="list-checks" class="w-5 h-5 flex-shrink-0"></i>
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "My QI Tasks" %}</span>
</a>
<!-- ===== SECTION 3: PEOPLE & RECORDS ===== --> <!-- ===== SECTION 3: PEOPLE & RECORDS ===== -->
<!-- Patients --> <!-- Patients -->

View File

@ -10,7 +10,7 @@
<h2 class="text-2xl font-bold text-gray-800"> <h2 class="text-2xl font-bold text-gray-800">
{% block page_title %} {% block page_title %}
{% if user.first_name %} {% if user.first_name %}
{% trans "Good morning" %}, {{ user.first_name }}! ☀️ {% trans "Welcome" %}, {{ user.first_name }}
{% else %} {% else %}
{% trans "Dashboard" %} {% trans "Dashboard" %}
{% endif %} {% endif %}
@ -24,12 +24,12 @@
<!-- Right Side Actions --> <!-- Right Side Actions -->
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<!-- Search --> <!-- Search -->
<div class="hidden md:flex items-center relative"> {% comment %} <div class="hidden md:flex items-center relative">
<i data-lucide="search" class="w-5 h-5 absolute left-4 text-gray-400"></i> <i data-lucide="search" class="w-5 h-5 absolute left-4 text-gray-400"></i>
<input type="text" <input type="text"
placeholder="{% trans 'Search...' %}" placeholder="{% trans 'Search...' %}"
class="pl-12 pr-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition w-64"> class="pl-12 pr-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition w-64">
</div> </div> {% endcomment %}
<!-- Notifications --> <!-- Notifications -->
<div class="relative"> <div class="relative">

View File

@ -72,8 +72,9 @@
<button class="py-4 text-sm tab-active" onclick="switchTab('details')" id="tab-details">{% trans "Details" %}</button> <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> <button class="py-4 text-sm tab-inactive" onclick="switchTab('timeline')" id="tab-timeline">{% trans "Timeline" %}</button>
<button class="py-4 text-sm tab-inactive" onclick="switchTab('attachments')" id="tab-attachments">{% trans "Attachments" %}</button> <button class="py-4 text-sm tab-inactive" onclick="switchTab('attachments')" id="tab-attachments">{% trans "Attachments" %}</button>
<button class="py-4 text-sm tab-inactive" onclick="switchTab('department')" id="tab-department">{% trans "Department Response" %}</button> {% if can_admin %}
<button class="py-4 text-sm tab-inactive" onclick="switchTab('rca')" id="tab-rca">{% trans "RCA" %}</button> <button class="py-4 text-sm tab-inactive" onclick="switchTab('rca')" id="tab-rca">{% trans "RCA" %}</button>
{% endif %}
<button class="py-4 text-sm tab-inactive" onclick="switchTab('notes')" id="tab-notes"> <button class="py-4 text-sm tab-inactive" onclick="switchTab('notes')" id="tab-notes">
{% trans "Notes" %} {% trans "Notes" %}
{% if generic_notes_count %}<span class="ml-1 px-1.5 py-0.5 bg-slate-100 text-slate-600 text-xs rounded-full">{{ generic_notes_count }}</span>{% endif %} {% if generic_notes_count %}<span class="ml-1 px-1.5 py-0.5 bg-slate-100 text-slate-600 text-xs rounded-full">{{ generic_notes_count }}</span>{% endif %}
@ -83,7 +84,7 @@
<main class="grid grid-cols-12 gap-6"> <main class="grid grid-cols-12 gap-6">
<div class="col-span-8 space-y-6"> <div class="col-span-8 space-y-6">
<div id="panel-details" class="tab-panel"> <div id="panel-details" class="tab-panel space-y-6">
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<div class="bg-slate-50 p-4 rounded-xl border-l-4 border-blue"> <div class="bg-slate-50 p-4 rounded-xl border-l-4 border-blue">
<p class="text-sm leading-relaxed text-slate" style="white-space: pre-wrap;">{{ observation.description }}</p> <p class="text-sm leading-relaxed text-slate" style="white-space: pre-wrap;">{{ observation.description }}</p>
@ -202,46 +203,7 @@
{% endif %} {% endif %}
</div> </div>
</section> </section>
</div> {% if observation.sent_to_department and observation.assigned_department %}
<div id="panel-timeline" class="tab-panel hidden">
{% include "observations/partials/observation_timeline_panel.html" %}
</div>
<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 flex items-center gap-2">
<i data-lucide="paperclip" class="w-5 h-5 text-blue"></i>
{% trans "Attachments" %} {% if attachments %}({{ attachments.count }}){% endif %}
</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">
<div class="flex items-center gap-3">
<i data-lucide="file" class="w-5 h-5 text-blue"></i>
<div>
<div class="font-semibold text-gray-800">{{ attachment.filename }}</div>
<div class="text-xs text-slate">{{ attachment.file_type }} - {{ attachment.file_size|filesizeformat }}</div>
</div>
</div>
<a href="{{ attachment.file.url }}" target="_blank" class="p-2 rounded-lg bg-navy text-white hover:bg-blue transition">
<i data-lucide="download" class="w-4 h-4"></i>
</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-8">
<i data-lucide="paperclip" class="w-12 h-12 mx-auto mb-3 text-gray-300"></i>
<p class="text-slate text-sm">{% trans "No attachments" %}</p>
</div>
{% endif %}
</section>
</div>
<div id="panel-department" class="tab-panel hidden">
{% if observation.assigned_department %}
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<div class="flex items-center gap-2 mb-4 pb-4 border-b <div class="flex items-center gap-2 mb-4 pb-4 border-b
{% if observation.department_responded_at %}border-blue-200{% elif observation.dept_response_is_overdue %}border-red-200{% else %}border-amber-200{% endif %}"> {% if observation.department_responded_at %}border-blue-200{% elif observation.dept_response_is_overdue %}border-red-200{% else %}border-amber-200{% endif %}">
@ -415,7 +377,62 @@
</div> </div>
</section> </section>
{% endif %} {% endif %}
{% if observation.responded_at and observation.response %}
<section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<h3 class="text-lg font-bold text-navy mb-4">{% trans "Response to Reporter" %}</h3>
<div class="bg-emerald-50 border border-emerald-200 rounded-xl p-4">
<div class="flex items-center gap-2 mb-3">
<i data-lucide="check-circle" class="w-5 h-5 text-emerald-600"></i>
<span class="font-bold text-emerald-800">{% trans "Response Sent" %}</span>
{% if observation.responded_by %}
<span class="text-xs text-slate ml-2">— {{ observation.responded_by.get_full_name }} • {{ observation.responded_at|date:"M d, Y H:i" }}</span>
{% endif %}
</div> </div>
<div class="bg-white/60 rounded-lg p-3">
<p class="text-sm text-slate-700 leading-relaxed">{{ observation.response|linebreaks }}</p>
</div>
</div>
</section>
{% endif %}
</div>
<div id="panel-timeline" class="tab-panel hidden">
{% include "observations/partials/observation_timeline_panel.html" %}
</div>
<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 flex items-center gap-2">
<i data-lucide="paperclip" class="w-5 h-5 text-blue"></i>
{% trans "Attachments" %} {% if attachments %}({{ attachments.count }}){% endif %}
</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">
<div class="flex items-center gap-3">
<i data-lucide="file" class="w-5 h-5 text-blue"></i>
<div>
<div class="font-semibold text-gray-800">{{ attachment.filename }}</div>
<div class="text-xs text-slate">{{ attachment.file_type }} - {{ attachment.file_size|filesizeformat }}</div>
</div>
</div>
<a href="{{ attachment.file.url }}" target="_blank" class="p-2 rounded-lg bg-navy text-white hover:bg-blue transition">
<i data-lucide="download" class="w-4 h-4"></i>
</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-8">
<i data-lucide="paperclip" class="w-12 h-12 mx-auto mb-3 text-gray-300"></i>
<p class="text-slate text-sm">{% trans "No attachments" %}</p>
</div>
{% endif %}
</section>
</div>
<div id="panel-rca" class="tab-panel hidden"> <div id="panel-rca" class="tab-panel hidden">
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
@ -457,10 +474,11 @@
</div> </div>
<div class="col-span-4 space-y-6"> <div class="col-span-4 space-y-6">
{% if observation.status not in 'resolved,closed' %}
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<h3 class="font-bold text-navy mb-4 text-sm">{% trans "Quick Actions" %}</h3> <h3 class="font-bold text-navy mb-4 text-sm">{% trans "Quick Actions" %}</h3>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
{% if observation.status == 'new' and not observation.activated_at %} {% if observation.status == 'open' and not observation.activated_at %}
{% if can_convert %} {% if can_convert %}
<form method="post" action="{% url 'observations:observation_activate' observation.id %}" class="contents"> <form method="post" action="{% url 'observations:observation_activate' observation.id %}" class="contents">
{% csrf_token %} {% csrf_token %}
@ -485,13 +503,19 @@
</div> </div>
{% endif %} {% endif %}
{% else %} {% else %}
{% if can_convert and observation.status not in 'closed,cancelled' %}
<button onclick="showRespondModal()" class="col-span-2 p-3 border-navy bg-navy text-white rounded-xl hover:bg-blue transition flex items-center justify-center gap-2 group">
<i data-lucide="message-square" class="w-5 h-5"></i>
<span class="text-[10px] font-bold uppercase">{% trans "Respond to Reporter" %}</span>
</button>
{% endif %}
{% if can_convert %} {% if can_convert %}
<button onclick="document.getElementById('obsAssignForm').classList.toggle('hidden')" class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition"> <button onclick="document.getElementById('obsAssignForm').classList.toggle('hidden')" class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition">
<i data-lucide="user-plus" class="w-5 h-5 text-slate group-hover:text-blue"></i> <i data-lucide="user-plus" class="w-5 h-5 text-slate group-hover:text-blue"></i>
<span class="text-[10px] font-bold uppercase">{% if observation.assigned_to %}{% trans "Reassign" %}{% else %}{% trans "Assign" %}{% endif %}</span> <span class="text-[10px] font-bold uppercase">{% if observation.assigned_to %}{% trans "Reassign" %}{% else %}{% trans "Assign" %}{% endif %}</span>
</button> </button>
{% endif %} {% endif %}
{% if can_convert and not observation.action_id %} {% if can_admin and not observation.action_id %}
<a href="{% url 'observations:observation_convert_to_action' observation.id %}" class="p-3 border-green-200 bg-green-50 rounded-xl hover:bg-green-100 flex flex-col items-center gap-2 group transition"> <a href="{% url 'observations:observation_convert_to_action' observation.id %}" class="p-3 border-green-200 bg-green-50 rounded-xl hover:bg-green-100 flex flex-col items-center gap-2 group transition">
<i data-lucide="arrow-right-circle" class="w-5 h-5 text-green-600"></i> <i data-lucide="arrow-right-circle" class="w-5 h-5 text-green-600"></i>
<span class="text-[10px] font-bold text-green-700 uppercase">{% trans "Convert" %}</span> <span class="text-[10px] font-bold text-green-700 uppercase">{% trans "Convert" %}</span>
@ -519,16 +543,16 @@
<span class="text-[10px] font-bold text-red-600 uppercase">{% trans "Escalate" %}</span> <span class="text-[10px] font-bold text-red-600 uppercase">{% trans "Escalate" %}</span>
</button> </button>
{% endif %} {% endif %}
{% if can_triage %} {% if can_admin %}
<a href="{% url 'rca:rca_create' %}?related_model=observation&related_id={{ observation.pk }}" class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition"> <a href="{% url 'rca:rca_create' %}?related_model=observation&related_id={{ observation.pk }}" class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition">
<i data-lucide="search" class="w-5 h-5 text-slate group-hover:text-purple-600"></i> <i data-lucide="search" class="w-5 h-5 text-slate group-hover:text-purple-600"></i>
<span class="text-[10px] font-bold uppercase">{% trans "RCA" %}</span> <span class="text-[10px] font-bold uppercase">{% trans "RCA" %}</span>
</a> </a>
{% endif %}
<a href="{% url 'projects:project_create' %}?related_model=observation&related_id={{ observation.pk }}" class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition"> <a href="{% url 'projects:project_create' %}?related_model=observation&related_id={{ observation.pk }}" class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition">
<i data-lucide="folder-plus" class="w-5 h-5 text-slate group-hover:text-teal-600"></i> <i data-lucide="folder-plus" class="w-5 h-5 text-slate group-hover:text-teal-600"></i>
<span class="text-[10px] font-bold uppercase">{% trans "QI Project" %}</span> <span class="text-[10px] font-bold uppercase">{% trans "QI Project" %}</span>
</a> </a>
{% endif %}
{% if observation.status == 'resolved' or observation.status == 'closed' %} {% if observation.status == 'resolved' or observation.status == 'closed' %}
{% if can_triage %} {% if can_triage %}
<form method="post" action="{% url 'observations:observation_reopen' observation.id %}" class="col-span-2 contents"> <form method="post" action="{% url 'observations:observation_reopen' observation.id %}" class="col-span-2 contents">
@ -554,6 +578,7 @@
{% endif %} {% endif %}
</div> </div>
</section> </section>
{% endif %}
{% if can_convert %} {% if can_convert %}
<section id="obsAssignForm" class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hidden"> <section id="obsAssignForm" class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hidden">
@ -676,6 +701,45 @@
</form> </form>
</section> </section>
{% endif %} {% 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> </div>
</main> </main>
@ -714,8 +778,147 @@ function switchObsRecipientType(type) {
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons(); if (typeof lucide !== 'undefined') lucide.createIcons();
}); });
function showRespondModal() {
document.getElementById('respondModal').style.display = 'flex';
if (typeof lucide !== 'undefined') lucide.createIcons();
}
function closeModal(modalId) {
document.getElementById(modalId).style.display = 'none';
}
function getCSRFToken() {
return document.querySelector('[name=csrfmiddlewaretoken]')?.value
|| document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('csrftoken='))?.split('=')[1] || '';
}
function generateObsAIResponse() {
const btn = document.getElementById('generateAiBtn');
const suggestionsDiv = document.getElementById('aiSuggestions');
btn.disabled = true;
btn.innerHTML = '<span class="inline-block w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></span> {% trans "Generating..." %}';
suggestionsDiv.classList.add('hidden');
fetch('{% url "observations:observation_generate_ai_response" observation.pk %}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCSRFToken() },
credentials: 'same-origin'
})
.then(response => { if (!response.ok) return response.json().then(data => { throw new Error(data.error || 'Request failed'); }); return response.json(); })
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i data-lucide="sparkles" class="w-4 h-4"></i> {% trans "Generate AI Response" %}';
lucide.createIcons();
if (data.success) {
document.getElementById('aiSuggestionEnText').textContent = data.response_en;
document.getElementById('aiSuggestionArText').textContent = data.response_ar;
suggestionsDiv.classList.remove('hidden');
} else { alert(data.error || '{% trans "Failed to generate response" %}'); }
})
.catch(error => {
console.error('Error:', error);
btn.disabled = false;
btn.innerHTML = '<i data-lucide="sparkles" class="w-4 h-4"></i> {% trans "Generate AI Response" %}';
lucide.createIcons();
alert(error.message || '{% trans "An error occurred while generating response" %}');
});
}
function useObsAISuggestion(lang) {
var text = '';
var card = null;
if (lang === 'en') {
text = document.getElementById('aiSuggestionEnText').textContent;
card = document.getElementById('aiSuggestionEn');
} else {
text = document.getElementById('aiSuggestionArText').textContent;
card = document.getElementById('aiSuggestionAr');
}
document.getElementById('responseText').value = text;
card.classList.add('border-navy', 'bg-navy/5');
setTimeout(() => card.classList.remove('border-navy', 'bg-navy/5'), 1500);
}
</script> </script>
<!-- Respond to Reporter Modal -->
<div id="respondModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-slate-200">
<div class="flex items-center justify-between">
<h3 class="text-xl font-bold text-navy flex items-center gap-2">
<i data-lucide="message-square" class="w-5 h-5"></i>
{% trans "Send Response to Reporter" %}
</h3>
<button type="button" onclick="closeModal('respondModal')" class="text-slate-400 hover:text-slate-600 transition">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
</div>
<form method="post" action="{% url 'observations:observation_respond' observation.pk %}" id="respondForm">
{% csrf_token %}
<div class="p-6">
{% if observation.short_description_en or observation.short_description_ar %}
<div class="bg-light/50 border border-slate-200 rounded-2xl p-4 mb-5">
<div class="flex items-center gap-2 mb-2">
<i data-lucide="sparkles" class="w-4 h-4 text-navy"></i>
<span class="text-sm font-bold text-navy">{% trans "AI Summary" %}</span>
</div>
{% if observation.short_description_en %}
<p class="text-sm text-slate-700 leading-relaxed">{{ observation.short_description_en }}</p>
{% endif %}
{% if observation.short_description_ar %}
<p class="text-sm text-slate-700 leading-relaxed mt-2" dir="rtl">{{ observation.short_description_ar }}</p>
{% endif %}
</div>
{% endif %}
<div class="mb-5">
<button type="button" id="generateAiBtn" onclick="generateObsAIResponse()" class="w-full inline-flex items-center justify-center gap-2 px-4 py-3 bg-navy text-white rounded-xl font-bold hover:bg-blue transition text-sm shadow-lg">
<i data-lucide="sparkles" class="w-4 h-4"></i>
{% trans "Generate AI Response" %}
</button>
</div>
<div id="aiSuggestions" class="hidden mb-5 space-y-3">
<div class="flex items-center gap-2 mb-2">
<i data-lucide="sparkles" class="w-4 h-4 text-navy"></i>
<span class="text-sm font-semibold text-navy">{% trans "AI Generated Response (click to use)" %}</span>
</div>
<div id="aiSuggestionEn" onclick="useObsAISuggestion('en')" class="border-2 border-slate-200 rounded-xl p-3 cursor-pointer hover:border-navy transition">
<p class="text-[10px] font-bold text-slate uppercase mb-1">English</p>
<p class="text-sm text-slate-700" id="aiSuggestionEnText"></p>
</div>
<div id="aiSuggestionAr" onclick="useObsAISuggestion('ar')" class="border-2 border-slate-200 rounded-xl p-3 cursor-pointer hover:border-navy transition">
<p class="text-[10px] font-bold text-slate uppercase mb-1">العربية</p>
<p class="text-sm text-slate-700" dir="rtl" id="aiSuggestionArText"></p>
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-semibold text-navy mb-2">{% trans "Your Response" %} <span class="text-red-500">*</span></label>
<textarea name="response" id="responseText" rows="8"
class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-navy focus:ring-2 focus:ring-navy/20 resize-none text-sm"
placeholder="{% trans "Enter your response to the reporter..." %}" required>{{ observation.response|default:'' }}</textarea>
</div>
<p class="text-xs text-slate-400 mt-1">
<i data-lucide="info" class="w-3 h-3 inline mr-1"></i>
{% trans "At least one language is required. The response will be visible to the reporter on the tracking page." %}
</p>
</div>
<div class="p-6 border-t border-slate-200 flex gap-3">
<button type="submit" class="flex-1 px-4 py-2.5 bg-navy text-white rounded-xl font-semibold hover:bg-navy/90 transition text-sm inline-flex items-center justify-center gap-2">
<i data-lucide="send" class="w-4 h-4"></i>
{% if observation.responded_at %}{% trans "Update Response" %}{% else %}{% trans "Send Response" %}{% endif %}
</button>
<button type="button" onclick="closeModal('respondModal')" class="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl font-semibold text-slate-600 hover:bg-slate-50 transition text-sm">
{% trans "Cancel" %}
</button>
</div>
</form>
</div>
</div>
<!-- Escalate Observation Modal --> <!-- Escalate Observation Modal -->
<div id="escalateModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center"> <div id="escalateModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div class="bg-white rounded-2xl p-6 w-full max-w-2xl mx-4 shadow-2xl max-h-[90vh] overflow-y-auto"> <div class="bg-white rounded-2xl p-6 w-full max-w-2xl mx-4 shadow-2xl max-h-[90vh] overflow-y-auto">

View File

@ -135,7 +135,7 @@
</a> </a>
</div> </div>
{% if pending_actions %} {% if pending_actions and can_respond %}
<div class="bg-white rounded-xl shadow-sm border-2 border-red-200 p-5 mb-6"> <div class="bg-white rounded-xl shadow-sm border-2 border-red-200 p-5 mb-6">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-red-50 rounded-lg flex items-center justify-center"> <div class="w-10 h-10 bg-red-50 rounded-lg flex items-center justify-center">
@ -224,6 +224,153 @@
</div> </div>
{% endif %} {% endif %}
{% if active_investigations and can_respond %}
<div class="bg-white rounded-xl shadow-sm border 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="search" class="w-5 h-5 text-blue-600"></i>
</div>
<div>
<h3 class="text-sm font-bold text-navy">{% trans "Active Investigations" %}</h3>
<p class="text-xs text-slate-400">{{ active_investigations|length }} {% trans "investigation(s) in progress" %}</p>
</div>
</div>
<div class="space-y-3">
{% for inv in active_investigations %}
<div class="border border-slate-200 rounded-xl p-4 hover:border-blue-300 transition">
<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>
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase whitespace-nowrap
{% if inv.status == 'questions_sent' %}bg-amber-100 text-amber-700
{% elif inv.status == 'answers_received' %}bg-blue-100 text-blue-700{% endif %}">
{{ inv.get_status_display }}
</span>
</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="help-circle" class="w-3.5 h-3.5"></i>
{{ inv.questions.count }} {% trans "questions" %}
</span>
<span class="inline-flex items-center gap-1">
{% with answered=inv.responses.all|length %}
<i data-lucide="users" class="w-3.5 h-3.5"></i>
{{ answered }} {% trans "staff" %}
{% endwith %}
</span>
</div>
<div class="space-y-1 mb-3">
{% for resp in inv.responses.all %}
<div class="flex items-center gap-2 text-xs">
{% if resp.is_completed %}
<i data-lucide="check-circle" class="w-3.5 h-3.5 text-green-500 shrink-0"></i>
<span class="text-slate">{{ resp.staff.get_full_name }}</span>
<span class="text-slate ml-auto">{{ resp.completed_at|date:"M d, H:i" }}</span>
{% else %}
<i data-lucide="clock" class="w-3.5 h-3.5 text-amber-500 shrink-0"></i>
<span class="text-slate">{{ resp.staff.get_full_name }}</span>
<span class="text-amber-600 font-semibold ml-auto">{% trans "Pending" %}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% if inv.status == 'answers_received' and inv.explanation.token %}
<a href="/complaints/{{ inv.complaint.id }}/investigate/review/{{ inv.explanation.token }}/"
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="eye" class="w-3.5 h-3.5"></i> {% trans "Review & Submit Reply" %}
</a>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if my_pending_responses or my_assigned_complaints %}
<div class="bg-white rounded-xl shadow-sm border-2 border-amber-200 p-5 mb-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-amber-50 rounded-lg flex items-center justify-center">
<i data-lucide="bell-ring" class="w-5 h-5 text-amber-600"></i>
</div>
<div>
<h3 class="text-sm font-bold text-navy">{% trans "Action Required From You" %}</h3>
<p class="text-xs text-slate-400">{% trans "Items assigned to you that need your response" %}</p>
</div>
</div>
<div class="space-y-3">
{% for resp in my_pending_responses %}
<div class="border border-amber-200 rounded-xl p-4 bg-amber-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=resp.investigation.complaint.pk %}" class="font-mono text-xs font-bold text-navy hover:underline">
{{ resp.investigation.complaint.reference_number }}
</a>
<p class="text-sm text-slate mt-0.5">{{ resp.investigation.complaint.title|truncatechars:60 }}</p>
</div>
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase whitespace-nowrap bg-amber-100 text-amber-700">
{% trans "Investigation" %}
</span>
</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="help-circle" class="w-3.5 h-3.5"></i>
{{ resp.investigation.questions.count }} {% trans "questions to answer" %}
</span>
{% if resp.investigation.champion %}
<span class="inline-flex items-center gap-1">
<i data-lucide="user" class="w-3.5 h-3.5"></i>
{{ resp.investigation.champion.get_full_name }}
</span>
{% endif %}
</div>
<a href="/complaints/{{ resp.investigation.complaint.id }}/investigate/respond/{{ resp.token }}/"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-500 text-white rounded-lg text-xs font-semibold hover:bg-amber-600 transition">
<i data-lucide="message-square" class="w-3.5 h-3.5"></i> {% trans "Answer Questions" %}
</a>
</div>
{% endfor %}
{% for c in my_assigned_complaints %}
<div class="border border-blue-200 rounded-xl p-4 bg-blue-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=c.pk %}" class="font-mono text-xs font-bold text-navy hover:underline">
{{ c.reference_number }}
</a>
<p class="text-sm text-slate mt-0.5">{{ c.title|truncatechars:60 }}</p>
</div>
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase whitespace-nowrap bg-blue-100 text-blue-700">
{% trans "Assigned to You" %}
</span>
</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="alert-circle" class="w-3.5 h-3.5"></i>
{{ c.get_severity_display }}
</span>
<span class="inline-flex items-center gap-1">
<i data-lucide="clock" class="w-3.5 h-3.5"></i>
{% if c.is_overdue %}
<span class="text-red-600 font-semibold">{% trans "Overdue" %}</span>
{% else %}
{{ c.due_at|date:"M d, H:i" }}
{% endif %}
</span>
</div>
<a href="{% url 'complaints:complaint_detail' pk=c.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 Complaint" %}
</a>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Department Head Info --> <!-- Department Head Info -->
{% if staff_head %} {% 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"> <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,12 +1,12 @@
{% load i18n %} {% load i18n %}
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
<h3 class="font-bold text-navy mb-4 flex items-center gap-2"> <h3 class="font-bold text-navy mb-4 flex items-center gap-2">
<i data-lucide="message-square" class="w-5 h-5 text-blue"></i> <i data-lucide="message-square" class="w-5 h-5 text-blue"></i>
{% trans "Notes" %} {% trans "Notes" %}
</h3> </h3>
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<form method="post" action="{% url 'core:add_note' %}" class="mb-6"> <form method="post" action="{% url 'core:add_note' %}" class="mb-4">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="content_type_id" value="{{ content_type_id }}"> <input type="hidden" name="content_type_id" value="{{ content_type_id }}">
<input type="hidden" name="object_id" value="{{ object_id }}"> <input type="hidden" name="object_id" value="{{ object_id }}">

View File

@ -0,0 +1,87 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% trans "My QI Tasks" %} - PX360{% endblock %}
{% block content %}
<header class="mb-6">
<div class="flex items-center gap-2 text-sm text-slate mb-2">
<a href="{% url 'projects:project_list' %}" class="hover:text-navy">{% trans "QI Projects" %}</a>
<i data-lucide="chevron-right" class="w-4 h-4"></i>
<span class="font-bold text-navy">{% trans "My Tasks" %}</span>
</div>
<h1 class="text-2xl font-bold text-navy">{% trans "My QI Tasks" %}</h1>
</header>
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-5">
<p class="text-[10px] uppercase font-bold text-slate-500 mb-1">{% trans "Total" %}</p>
<p class="text-3xl font-bold text-navy">{{ total }}</p>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-5">
<p class="text-[10px] uppercase font-bold text-slate-500 mb-1">{% trans "Pending" %}</p>
<p class="text-3xl font-bold text-amber-600">{{ pending }}</p>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-5">
<p class="text-[10px] uppercase font-bold text-slate-500 mb-1">{% trans "Completed" %}</p>
<p class="text-3xl font-bold text-green-600">{{ completed }}</p>
</div>
</div>
{% if grouped %}
{% for project, tasks in grouped.items %}
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 mb-4">
<div class="flex items-center gap-2 mb-4">
<i data-lucide="folder-kanban" class="w-5 h-5 text-navy"></i>
<a href="{% url 'projects:project_detail' pk=project.pk %}" class="text-lg font-bold text-navy hover:text-blue-600">
{{ project.name }}
</a>
<span class="text-xs px-2 py-0.5 rounded-full font-bold
{% if project.status == 'completed' %}bg-green-100 text-green-700
{% elif project.status == 'in_progress' %}bg-blue-100 text-blue-700
{% else %}bg-slate-100 text-slate-600{% endif %}">
{{ project.get_status_display }}
</span>
</div>
<div class="space-y-2">
{% for task in tasks %}
<div class="flex items-center gap-3 p-3 rounded-xl {% if task.status == 'completed' %}bg-green-50{% else %}bg-slate-50{% endif %}">
<form method="post" action="{% url 'projects:task_toggle_status' project_pk=project.pk task_pk=task.pk %}" class="inline">
{% csrf_token %}
<button type="submit" class="p-1 bg-transparent border-none cursor-pointer">
{% if task.status == 'completed' %}
<i data-lucide="check-square" class="w-5 h-5 text-green-600"></i>
{% else %}
<i data-lucide="square" class="w-5 h-5 text-slate-300 hover:text-blue-500"></i>
{% endif %}
</button>
</form>
<div class="flex-1">
<span class="text-sm font-semibold {% if task.status == 'completed' %}line-through text-slate-400{% else %}text-navy{% endif %}">
{{ task.title }}
</span>
{% if task.description %}
<p class="text-xs text-slate-500 mt-0.5">{{ task.description|truncatewords:20 }}</p>
{% endif %}
</div>
{% if task.due_date %}
<span class="text-xs {% if task.due_date < today and task.status != 'completed' %}text-red-600 font-bold{% else %}text-slate-500{% endif %}">
{{ task.due_date|date:"M d" }}
</span>
{% endif %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase
{% if task.status == 'completed' %}bg-green-100 text-green-700{% else %}bg-amber-100 text-amber-700{% endif %}">
{{ task.get_status_display }}
</span>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-12 text-center">
<i data-lucide="check-circle" class="w-12 h-12 text-green-500 mx-auto mb-3"></i>
<p class="text-slate-600 font-semibold">{% trans "You have no QI tasks assigned." %}</p>
</div>
{% endif %}
{% endblock %}

View File

@ -2,7 +2,7 @@
<tr id="task-{{ task.pk }}"> <tr id="task-{{ task.pk }}">
<!-- Toggle --> <!-- Toggle -->
<td class="text-center"> <td class="text-center">
{% if can_edit %} {% if can_edit or can_toggle or task.assigned_to.user_id == request.user.id %}
<form method="post" <form method="post"
action="{% url 'projects:htmx_task_toggle' project_pk=project.pk task_pk=task.pk %}" action="{% url 'projects:htmx_task_toggle' project_pk=project.pk task_pk=task.pk %}"
hx-post="{% url 'projects:htmx_task_toggle' project_pk=project.pk task_pk=task.pk %}" hx-post="{% url 'projects:htmx_task_toggle' project_pk=project.pk task_pk=task.pk %}"