feat: QI Projects — team-member task management + My Tasks + notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m41s
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:
parent
1ee9ae807b
commit
badb6a9ebf
@ -123,7 +123,7 @@ def precompute_dashboard_cache_task(self):
|
||||
from django.contrib.auth import 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():
|
||||
# Fallback: use first superuser
|
||||
|
||||
@ -97,7 +97,10 @@ def appreciation_detail(request, pk):
|
||||
)
|
||||
|
||||
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):
|
||||
messages.error(request, _("You don't have permission to view this appreciation."))
|
||||
return redirect("appreciation:appreciation_list")
|
||||
@ -145,7 +148,10 @@ def appreciation_activate(request, pk):
|
||||
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
|
||||
|
||||
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):
|
||||
messages.error(request, _("Permission denied."))
|
||||
return redirect("appreciation:appreciation_list")
|
||||
@ -240,7 +246,10 @@ def appreciation_send(request, pk):
|
||||
return redirect("appreciation:appreciation_detail", pk=pk)
|
||||
|
||||
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):
|
||||
messages.error(request, _("Permission denied."))
|
||||
return redirect("appreciation:appreciation_list")
|
||||
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -281,6 +281,18 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
area = models.ForeignKey(
|
||||
"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
|
||||
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"
|
||||
)
|
||||
|
||||
# Satisfaction
|
||||
satisfaction = models.CharField(
|
||||
max_length=20, blank=True, default="",
|
||||
choices=[("satisfied", "Satisfied"), ("neutral", "Neutral"), ("dissatisfied", "Dissatisfied"), ("no_response", "No Response")],
|
||||
)
|
||||
satisfaction_set_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Metadata (stores AI analysis, form data, etc.)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
|
||||
@ -83,7 +83,7 @@ class ComplaintService:
|
||||
return True
|
||||
if complaint.assigned_to and complaint.assigned_to == user:
|
||||
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 False
|
||||
|
||||
@ -372,7 +372,8 @@ class ComplaintService:
|
||||
resolution_outcome_other="",
|
||||
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.")
|
||||
|
||||
if not new_status:
|
||||
@ -567,6 +568,139 @@ class ComplaintService:
|
||||
"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
|
||||
def send_to_department(
|
||||
complaint,
|
||||
@ -839,6 +973,34 @@ This is an automated message from PX360 Complaint Management System."""
|
||||
"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
|
||||
def post_create_hooks(complaint, created_by, request=None):
|
||||
from apps.complaints.tasks import analyze_complaint_with_ai, notify_admins_new_complaint
|
||||
|
||||
@ -605,6 +605,8 @@ def complaint_detail(request, pk):
|
||||
|
||||
complaint = get_object_or_404(complaint_queryset, pk=pk)
|
||||
|
||||
ComplaintService.ensure_involved_records(complaint)
|
||||
|
||||
user = request.user
|
||||
if not user.is_px_admin():
|
||||
if user.is_hospital_admin() and complaint.hospital != user.hospital:
|
||||
@ -680,11 +682,27 @@ def complaint_detail(request, pk):
|
||||
"attachments": attachments,
|
||||
"px_actions": px_actions,
|
||||
"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,
|
||||
"base_layout": base_layout,
|
||||
"source_user": source_user,
|
||||
"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,
|
||||
"ai_department_suggested": (
|
||||
bool(complaint.department)
|
||||
@ -1706,7 +1724,10 @@ def complaint_escalate(request, pk):
|
||||
|
||||
# Check permission
|
||||
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.")
|
||||
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 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.")
|
||||
|
||||
year = request.GET.get("year")
|
||||
@ -2087,7 +2111,10 @@ def complaint_export_quarterly_calculations(request):
|
||||
from apps.complaints.utils import export_quarterly_calculations
|
||||
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.")
|
||||
|
||||
year = request.GET.get("year")
|
||||
@ -2124,7 +2151,10 @@ def complaint_export_yearly_calculations(request):
|
||||
from apps.complaints.utils import export_yearly_calculations
|
||||
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.")
|
||||
|
||||
year = request.GET.get("year")
|
||||
@ -2471,12 +2501,22 @@ def inquiry_detail(request, pk):
|
||||
"stage_timeline": stage_timeline,
|
||||
"attachments": attachments,
|
||||
"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,
|
||||
"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": (
|
||||
user.is_px_admin()
|
||||
or user.is_hospital_admin()
|
||||
or user.is_px_management()
|
||||
or user.is_px_employee()
|
||||
or inquiry.assigned_to == user
|
||||
or (
|
||||
user.is_champion()
|
||||
@ -2484,8 +2524,19 @@ def inquiry_detail(request, pk):
|
||||
and user.department in [inquiry.department, inquiry.outgoing_department]
|
||||
)
|
||||
),
|
||||
"can_review_dept_response": user.is_px_admin() or user.is_hospital_admin(),
|
||||
"can_send_reminder": 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()
|
||||
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,
|
||||
"source_user": source_user,
|
||||
"linked_rcas": linked_rcas,
|
||||
@ -2526,7 +2577,10 @@ def inquiry_send_to_staff(request, 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."))
|
||||
return redirect("inquiries:inquiry_detail", pk=pk)
|
||||
|
||||
@ -2715,7 +2769,10 @@ def inquiry_edit(request, pk):
|
||||
inquiry = get_object_or_404(Inquiry, pk=pk)
|
||||
|
||||
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."))
|
||||
return redirect("inquiries:inquiry_detail", pk=inquiry.pk)
|
||||
|
||||
@ -2790,7 +2847,10 @@ def inquiry_activate(request, pk):
|
||||
|
||||
# Check permission
|
||||
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.")
|
||||
return redirect("inquiries:inquiry_detail", pk=pk)
|
||||
|
||||
@ -2938,7 +2998,10 @@ def inquiry_change_status(request, pk):
|
||||
|
||||
# Check permission
|
||||
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.")
|
||||
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.")
|
||||
return redirect("inquiries:inquiry_detail", pk=pk)
|
||||
|
||||
response_en = request.POST.get("response_en", "").strip()
|
||||
response_ar = request.POST.get("response_ar", "").strip()
|
||||
response = response_en or response_ar
|
||||
response = request.POST.get("response", "").strip()
|
||||
|
||||
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)
|
||||
|
||||
inquiry.response = response
|
||||
inquiry.response_en = response_en
|
||||
inquiry.response_ar = response_ar
|
||||
inquiry.response_en = ""
|
||||
inquiry.response_ar = ""
|
||||
inquiry.responded_at = timezone.now()
|
||||
inquiry.responded_by = request.user
|
||||
inquiry.status = "resolved"
|
||||
@ -3173,6 +3234,20 @@ def inquiry_respond(request, pk):
|
||||
return redirect("inquiries:inquiry_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def inquiry_update_satisfaction(request, pk):
|
||||
"""Update inquiry satisfaction."""
|
||||
inquiry = get_object_or_404(Inquiry, pk=pk)
|
||||
satisfaction = request.POST.get("satisfaction", "").strip()
|
||||
if satisfaction in ("satisfied", "neutral", "dissatisfied", "no_response"):
|
||||
inquiry.satisfaction = satisfaction
|
||||
inquiry.satisfaction_set_at = timezone.now()
|
||||
inquiry.save(update_fields=["satisfaction", "satisfaction_set_at"])
|
||||
messages.success(request, _("Satisfaction updated."))
|
||||
return redirect("inquiries:inquiry_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def inquiry_transfer_to_department(request, pk):
|
||||
@ -3185,7 +3260,7 @@ def inquiry_transfer_to_department(request, pk):
|
||||
user.is_px_admin()
|
||||
or user.is_hospital_admin()
|
||||
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."))
|
||||
return redirect("inquiries:inquiry_detail", pk=pk)
|
||||
@ -3336,7 +3411,7 @@ def inquiry_escalate(request, pk):
|
||||
if not (
|
||||
user.is_px_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."))
|
||||
return redirect("inquiries:inquiry_detail", pk=pk)
|
||||
@ -3430,7 +3505,7 @@ def inquiry_send_to(request, pk):
|
||||
user.is_px_admin()
|
||||
or user.is_hospital_admin()
|
||||
or user.is_department_manager()
|
||||
or user.is_px_management()
|
||||
or user.is_px_management() or user.is_px_employee()
|
||||
):
|
||||
return JsonResponse({
|
||||
"success": False,
|
||||
@ -3711,7 +3786,10 @@ def inquiry_review_dept_response(request, pk):
|
||||
inquiry = get_object_or_404(Inquiry, pk=pk)
|
||||
|
||||
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.")
|
||||
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)
|
||||
|
||||
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.")
|
||||
return redirect("inquiries:inquiry_detail", pk=pk)
|
||||
|
||||
@ -4934,7 +5015,10 @@ def escalation_rule_list(request):
|
||||
|
||||
# Check permission
|
||||
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.")
|
||||
return redirect("accounts:settings")
|
||||
|
||||
@ -4988,7 +5072,10 @@ def escalation_rule_create(request):
|
||||
|
||||
# Check permission
|
||||
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.")
|
||||
return redirect("accounts:settings")
|
||||
|
||||
@ -5038,7 +5125,10 @@ def escalation_rule_edit(request, pk):
|
||||
|
||||
# Check permission
|
||||
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.")
|
||||
return redirect("accounts:settings")
|
||||
|
||||
@ -5095,7 +5185,10 @@ def escalation_rule_delete(request, pk):
|
||||
|
||||
# Check permission
|
||||
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.")
|
||||
return redirect("accounts:settings")
|
||||
|
||||
@ -5134,7 +5227,10 @@ def complaint_threshold_list(request):
|
||||
|
||||
# Check permission
|
||||
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.")
|
||||
return redirect("accounts:settings")
|
||||
|
||||
@ -5188,7 +5284,10 @@ def complaint_threshold_create(request):
|
||||
|
||||
# Check permission
|
||||
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.")
|
||||
return redirect("accounts:settings")
|
||||
|
||||
@ -5238,7 +5337,10 @@ def complaint_threshold_edit(request, pk):
|
||||
|
||||
# Check permission
|
||||
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.")
|
||||
return redirect("accounts:settings")
|
||||
|
||||
@ -5295,7 +5397,10 @@ def complaint_threshold_delete(request, pk):
|
||||
|
||||
# Check permission
|
||||
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.")
|
||||
return redirect("accounts:settings")
|
||||
|
||||
@ -5725,7 +5830,10 @@ def involved_department_review_response(request, pk):
|
||||
complaint = involved_dept.complaint
|
||||
|
||||
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."))
|
||||
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
|
||||
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."))
|
||||
return redirect("dashboard:index")
|
||||
return redirect("dashboard:command-center")
|
||||
|
||||
# Base queryset
|
||||
queryset = GovernmentTicket.objects.select_related("source", "department", "assigned_to").all()
|
||||
@ -6984,7 +7092,10 @@ def government_ticket_export(request):
|
||||
@require_http_methods(["POST"])
|
||||
def complaint_soft_delete(request, 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."))
|
||||
complaint.soft_delete(user=request.user)
|
||||
messages.success(request, _("Complaint moved to trash."))
|
||||
@ -6995,7 +7106,10 @@ def complaint_soft_delete(request, pk):
|
||||
@require_http_methods(["POST"])
|
||||
def complaint_restore(request, pk):
|
||||
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."))
|
||||
complaint.restore()
|
||||
messages.success(request, _("Complaint restored successfully."))
|
||||
@ -7006,7 +7120,10 @@ def complaint_restore(request, pk):
|
||||
@require_http_methods(["POST"])
|
||||
def inquiry_restore(request, pk):
|
||||
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."))
|
||||
inquiry.restore()
|
||||
messages.success(request, _("Inquiry restored successfully."))
|
||||
@ -7015,7 +7132,10 @@ def inquiry_restore(request, pk):
|
||||
|
||||
@login_required
|
||||
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."))
|
||||
|
||||
deleted_complaints = Complaint.all_objects.filter(is_deleted=True).select_related(
|
||||
|
||||
@ -52,6 +52,7 @@ urlpatterns = [
|
||||
name="update_explanation_delay_reason",
|
||||
),
|
||||
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>/escalate/", ui_views.complaint_escalate, name="complaint_escalate"),
|
||||
path("<uuid:pk>/activate/", ui_views.complaint_activate, name="complaint_activate"),
|
||||
|
||||
@ -17,6 +17,7 @@ urlpatterns = [
|
||||
path("<uuid:pk>/reopen/", ui_views.inquiry_reopen, name="inquiry_reopen"),
|
||||
path("<uuid:pk>/add-note/", ui_views.inquiry_add_note, name="inquiry_add_note"),
|
||||
path("<uuid:pk>/respond/", ui_views.inquiry_respond, name="inquiry_respond"),
|
||||
path("<uuid:pk>/update-satisfaction/", ui_views.inquiry_update_satisfaction, name="inquiry_update_satisfaction"),
|
||||
path("<uuid:pk>/transfer-to-department/", ui_views.inquiry_transfer_to_department, name="inquiry_transfer_to_department"),
|
||||
path("<uuid:pk>/department-response/", ui_views.inquiry_department_response, name="inquiry_department_response"),
|
||||
path("<uuid:pk>/review-dept-response/", ui_views.inquiry_review_dept_response, name="inquiry_review_dept_response"),
|
||||
|
||||
@ -1328,9 +1328,10 @@ This is an automated message from PX360 Complaint Management System.
|
||||
complaint = self.get_object()
|
||||
|
||||
# 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(
|
||||
{"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")
|
||||
@ -4024,7 +4025,9 @@ def champion_start_investigation(request, complaint_id, 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:
|
||||
try:
|
||||
NotificationService.send_email(
|
||||
@ -4054,6 +4057,7 @@ def champion_start_investigation(request, complaint_id, token):
|
||||
</div>
|
||||
""",
|
||||
related_object=complaint,
|
||||
user=staff_user,
|
||||
)
|
||||
inv_response.email_sent_at = timezone.now()
|
||||
inv_response.save(update_fields=["email_sent_at"])
|
||||
@ -4061,6 +4065,16 @@ def champion_start_investigation(request, complaint_id, token):
|
||||
import logging
|
||||
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)
|
||||
if staff_phone:
|
||||
try:
|
||||
@ -4146,12 +4160,15 @@ def staff_investigation_form(request, complaint_id, token):
|
||||
investigation.save(update_fields=["status"])
|
||||
|
||||
champion = investigation.champion
|
||||
if champion and champion.email:
|
||||
domain = request.get_host()
|
||||
review_url = f"https://{domain}/complaints/{complaint.id}/investigate/review/{investigation.explanation.token}/"
|
||||
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()
|
||||
review_url = f"https://{domain}/complaints/{complaint.id}/investigate/review/{investigation.explanation.token}/"
|
||||
|
||||
if champion_email:
|
||||
try:
|
||||
NotificationService.send_email(
|
||||
email=champion.email,
|
||||
email=champion_email,
|
||||
subject=f"All Investigation Responses Received - Complaint #{complaint.reference_number}",
|
||||
message=(
|
||||
f"Dear {champion.get_full_name()},\n\n"
|
||||
@ -4174,9 +4191,21 @@ def staff_investigation_form(request, complaint_id, token):
|
||||
</div>
|
||||
""",
|
||||
related_object=complaint,
|
||||
user=champion_user,
|
||||
)
|
||||
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(
|
||||
complaint=complaint,
|
||||
|
||||
34
apps/core/management/commands/get_e2e_project_state.py
Normal file
34
apps/core/management/commands/get_e2e_project_state.py
Normal 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'}")
|
||||
86
apps/core/management/commands/seed_e2e_project.py
Normal file
86
apps/core/management/commands/seed_e2e_project.py
Normal 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}")
|
||||
@ -441,10 +441,22 @@ def _track_complaint(reference):
|
||||
except Complaint.DoesNotExist:
|
||||
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
|
||||
|
||||
public_updates = list(
|
||||
complaint.updates.filter(update_type__in=["status_change", "resolution"])
|
||||
complaint.updates.filter(update_type="resolution")
|
||||
.order_by("-created_at")[:20]
|
||||
)
|
||||
|
||||
@ -457,19 +469,32 @@ def _track_complaint(reference):
|
||||
|
||||
timeline = []
|
||||
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 ""
|
||||
for internal, public_label in _status_map.items():
|
||||
msg = msg.replace(internal, public_label)
|
||||
timeline.append({
|
||||
"type": u.update_type,
|
||||
"icon": icon,
|
||||
"title": title,
|
||||
"icon": "check-circle-2",
|
||||
"title": "Final Resolution",
|
||||
"comment": msg,
|
||||
"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 = [
|
||||
{"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"},
|
||||
@ -521,14 +546,6 @@ def _track_inquiry(reference):
|
||||
sm = status_map.get(inquiry.status, {"label": inquiry.get_status_display(), "progress": 15, "css": "amber"})
|
||||
|
||||
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 = [
|
||||
{"icon": "calendar", "label": "Submitted", "value": inquiry.created_at.strftime("%b %d, %Y")},
|
||||
@ -556,10 +573,11 @@ def _track_inquiry(reference):
|
||||
"info_cards": info_cards,
|
||||
"timeline": timeline,
|
||||
"response": {
|
||||
"has_response": bool(inquiry.department_response_en or inquiry.department_response_ar),
|
||||
"en": inquiry.department_response_en or "",
|
||||
"ar": inquiry.department_response_ar or "",
|
||||
"has_response": bool(inquiry.response_en or inquiry.response_ar or inquiry.response),
|
||||
"en": inquiry.response_en or inquiry.response or "",
|
||||
"ar": inquiry.response_ar or "",
|
||||
},
|
||||
"satisfaction": inquiry.satisfaction or "",
|
||||
})
|
||||
|
||||
|
||||
@ -581,14 +599,6 @@ def _track_observation(reference):
|
||||
}
|
||||
|
||||
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 = [
|
||||
{"icon": "calendar", "label": "Submitted", "value": observation.created_at.strftime("%b %d, %Y")},
|
||||
@ -608,10 +618,11 @@ def _track_observation(reference):
|
||||
"info_cards": info_cards,
|
||||
"timeline": timeline,
|
||||
"response": {
|
||||
"has_response": bool(observation.department_response_en or observation.department_response_ar),
|
||||
"en": observation.department_response_en or "",
|
||||
"ar": observation.department_response_ar or "",
|
||||
"has_response": bool(observation.response_en or observation.response_ar or observation.response),
|
||||
"en": observation.response_en or observation.response or "",
|
||||
"ar": observation.response_ar or "",
|
||||
},
|
||||
"satisfaction": observation.satisfaction or "",
|
||||
})
|
||||
|
||||
|
||||
@ -746,9 +757,8 @@ def add_note(request):
|
||||
@require_POST
|
||||
@csrf_exempt
|
||||
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 apps.complaints.models import Complaint
|
||||
|
||||
reference = request.POST.get("reference", "").strip()
|
||||
satisfaction = request.POST.get("satisfaction", "").strip()
|
||||
@ -760,16 +770,28 @@ def public_set_satisfaction(request):
|
||||
if satisfaction not in valid_choices:
|
||||
return JsonResponse({"success": False, "error": "Invalid satisfaction value."}, status=400)
|
||||
|
||||
upper = reference.upper()
|
||||
|
||||
try:
|
||||
complaint = Complaint.objects.get(reference_number__iexact=reference)
|
||||
except Complaint.DoesNotExist:
|
||||
return JsonResponse({"success": False, "error": "Complaint not found."}, status=404)
|
||||
if upper.startswith("CMP-"):
|
||||
from apps.complaints.models import Complaint
|
||||
obj = Complaint.objects.get(reference_number__iexact=reference)
|
||||
elif upper.startswith("INQ-"):
|
||||
from apps.complaints.models import Inquiry
|
||||
obj = Inquiry.objects.get(reference_number__iexact=reference)
|
||||
elif upper.startswith("OBS-"):
|
||||
from apps.observations.models import Observation
|
||||
obj = Observation.objects.get(tracking_code__iexact=reference)
|
||||
else:
|
||||
return JsonResponse({"success": False, "error": "Unrecognized reference format."}, status=400)
|
||||
except Exception:
|
||||
return JsonResponse({"success": False, "error": "Not found."}, status=404)
|
||||
|
||||
if complaint.status not in ("resolved", "closed") or not complaint.resolution:
|
||||
return JsonResponse({"success": False, "error": "Satisfaction can only be set for resolved complaints."}, status=400)
|
||||
if obj.status not in ("resolved", "closed"):
|
||||
return JsonResponse({"success": False, "error": "Satisfaction can only be set for resolved items."}, status=400)
|
||||
|
||||
complaint.satisfaction = satisfaction
|
||||
complaint.satisfaction_set_at = timezone.now()
|
||||
complaint.save(update_fields=["satisfaction", "satisfaction_set_at", "updated_at"])
|
||||
obj.satisfaction = satisfaction
|
||||
obj.satisfaction_set_at = timezone.now()
|
||||
obj.save(update_fields=["satisfaction", "satisfaction_set_at", "updated_at"])
|
||||
|
||||
return JsonResponse({"success": True, "satisfaction": complaint.satisfaction})
|
||||
return JsonResponse({"success": True, "satisfaction": obj.satisfaction})
|
||||
|
||||
@ -660,7 +660,7 @@ def my_dashboard(request):
|
||||
# 5. QI Project Tasks
|
||||
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)
|
||||
if selected_hospital:
|
||||
tasks_qs = tasks_qs.filter(project__hospital=selected_hospital)
|
||||
|
||||
@ -19,6 +19,7 @@ urlpatterns = [
|
||||
# Workflow actions
|
||||
path("<uuid:pk>/assign/", views.feedback_assign, name="feedback_assign"),
|
||||
path("<uuid:pk>/change-status/", views.feedback_change_status, name="feedback_change_status"),
|
||||
path("<uuid:pk>/send-to-department/", views.feedback_send_to_department, name="feedback_send_to_department"),
|
||||
path("<uuid:pk>/add-response/", views.feedback_add_response, name="feedback_add_response"),
|
||||
# Toggle actions
|
||||
path("<uuid:pk>/toggle-featured/", views.feedback_toggle_featured, name="feedback_toggle_featured"),
|
||||
|
||||
@ -251,7 +251,13 @@ def feedback_detail(request, pk):
|
||||
"attachments": attachments,
|
||||
"assignable_users": assignable_users,
|
||||
"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,
|
||||
"content_type_id": feedback_ct.pk,
|
||||
"object_id": feedback.pk,
|
||||
@ -692,7 +698,12 @@ def feedback_assign(request, pk):
|
||||
|
||||
# Check permission
|
||||
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.")
|
||||
return redirect("feedback:feedback_detail", pk=pk)
|
||||
|
||||
@ -742,7 +753,12 @@ def feedback_change_status(request, pk):
|
||||
|
||||
# Check permission
|
||||
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.")
|
||||
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.")
|
||||
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)
|
||||
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -587,6 +587,23 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
)
|
||||
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
|
||||
closed_at = models.DateTimeField(null=True, blank=True)
|
||||
closed_by = models.ForeignKey(
|
||||
|
||||
@ -55,6 +55,10 @@ urlpatterns = [
|
||||
path("<uuid:pk>/reopen/", views.observation_reopen, name="observation_reopen"),
|
||||
# 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
|
||||
path("<uuid:pk>/send-to-department/", views.observation_send_to_department, name="observation_send_to_department"),
|
||||
# Escalate observation
|
||||
|
||||
@ -629,12 +629,13 @@ def observation_detail(request, pk):
|
||||
"note_form": note_form,
|
||||
"status_choices": ObservationStatus.choices,
|
||||
"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_send_to_department": user.is_px_admin() or user.is_hospital_admin() 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_review_dept_response": user.is_px_admin() or user.is_hospital_admin(),
|
||||
"can_send_reminder": user.is_px_admin() or user.is_hospital_admin(),
|
||||
"can_delete": 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_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_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() 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_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,
|
||||
}
|
||||
|
||||
@ -771,7 +772,10 @@ def observation_assign(request, pk):
|
||||
observation = get_object_or_404(Observation, pk=pk)
|
||||
|
||||
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.")
|
||||
return redirect("observations:observation_detail", pk=pk)
|
||||
|
||||
@ -824,7 +828,10 @@ def observation_activate(request, pk):
|
||||
observation = get_object_or_404(Observation, pk=pk)
|
||||
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."))
|
||||
return redirect("observations:observation_detail", pk=pk)
|
||||
|
||||
@ -874,7 +881,10 @@ def observation_reopen(request, pk):
|
||||
observation = get_object_or_404(Observation, pk=pk)
|
||||
|
||||
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.")
|
||||
return redirect("observations:observation_detail", pk=pk)
|
||||
|
||||
@ -930,6 +940,160 @@ def observation_add_note(request, 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
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def observation_convert_to_action(request, pk):
|
||||
@ -940,7 +1104,10 @@ def observation_convert_to_action(request, pk):
|
||||
|
||||
# Check permission
|
||||
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.")
|
||||
return redirect("observations:observation_detail", pk=pk)
|
||||
|
||||
@ -1006,7 +1173,7 @@ def observation_send_to_department(request, pk):
|
||||
user.is_px_admin()
|
||||
or user.is_hospital_admin()
|
||||
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."))
|
||||
return redirect("observations:observation_detail", pk=pk)
|
||||
@ -1161,7 +1328,7 @@ def observation_escalate(request, pk):
|
||||
if not (
|
||||
user.is_px_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."))
|
||||
return redirect("observations:observation_detail", pk=pk)
|
||||
@ -1265,7 +1432,7 @@ def observation_send_to(request, pk):
|
||||
user.is_px_admin()
|
||||
or user.is_hospital_admin()
|
||||
or user.is_department_manager()
|
||||
or user.is_px_management()
|
||||
or user.is_px_management() or user.is_px_employee()
|
||||
):
|
||||
return JsonResponse({
|
||||
"success": False,
|
||||
@ -1565,7 +1732,10 @@ def observation_review_dept_response(request, pk):
|
||||
observation = get_object_or_404(Observation, pk=pk)
|
||||
|
||||
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.")
|
||||
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)
|
||||
|
||||
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.")
|
||||
return redirect("observations:observation_detail", pk=pk)
|
||||
|
||||
@ -1890,7 +2063,10 @@ def get_client_ip(request):
|
||||
@require_http_methods(["POST"])
|
||||
def observation_soft_delete(request, 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."))
|
||||
observation.soft_delete(user=request.user)
|
||||
messages.success(request, _("Observation moved to trash."))
|
||||
@ -1901,7 +2077,10 @@ def observation_soft_delete(request, pk):
|
||||
@require_http_methods(["POST"])
|
||||
def observation_restore(request, pk):
|
||||
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."))
|
||||
observation.restore()
|
||||
messages.success(request, _("Observation restored successfully."))
|
||||
|
||||
@ -112,6 +112,8 @@ class DepartmentSerializer(serializers.ModelSerializer):
|
||||
"phone",
|
||||
"email",
|
||||
"location",
|
||||
"sub_location",
|
||||
"floor",
|
||||
"status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
|
||||
@ -2021,7 +2021,7 @@ def department_detail(request, pk):
|
||||
).order_by("first_name", "last_name")
|
||||
|
||||
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
|
||||
|
||||
# 1. Complaint Department Responses (new)
|
||||
@ -2051,7 +2051,13 @@ def department_detail(request, pk):
|
||||
"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(
|
||||
department=department,
|
||||
response_submitted=True,
|
||||
@ -2196,6 +2202,25 @@ def department_detail(request, pk):
|
||||
),
|
||||
"pending_actions": 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)
|
||||
|
||||
|
||||
@ -8,3 +8,6 @@ class ProjectsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.projects'
|
||||
verbose_name = 'Projects'
|
||||
|
||||
def ready(self):
|
||||
import apps.projects.signals # noqa: F401
|
||||
|
||||
@ -72,12 +72,14 @@ class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
|
||||
),
|
||||
"project_lead": forms.Select(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||
"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(
|
||||
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(
|
||||
@ -129,15 +131,17 @@ class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
|
||||
hospital_id=hospital_id, status="active"
|
||||
).order_by("name")
|
||||
|
||||
# Filter user choices based on hospital
|
||||
from apps.core.utils import get_assignable_users
|
||||
assignable = get_assignable_users(Hospital.objects.get(pk=hospital_id)) if hospital_id else User.objects.none()
|
||||
self.fields["project_lead"].queryset = assignable
|
||||
self.fields["team_members"].queryset = assignable
|
||||
# Filter staff choices based on hospital
|
||||
from apps.organizations.models import Staff
|
||||
staff_qs = Staff.objects.filter(
|
||||
hospital_id=hospital_id, status="active"
|
||||
).order_by("first_name", "last_name")
|
||||
self.fields["project_lead"].queryset = staff_qs
|
||||
self.fields["team_members"].queryset = staff_qs
|
||||
else:
|
||||
self.fields["department"].queryset = Department.objects.none()
|
||||
self.fields["project_lead"].queryset = User.objects.none()
|
||||
self.fields["team_members"].queryset = User.objects.none()
|
||||
self.fields["project_lead"].queryset = Staff.objects.none() if False else []
|
||||
self.fields["team_members"].queryset = []
|
||||
|
||||
|
||||
class QIProjectTaskForm(forms.ModelForm):
|
||||
@ -242,8 +246,10 @@ class QIProjectTaskForm(forms.ModelForm):
|
||||
|
||||
# Filter assigned_to choices based on project hospital
|
||||
if self.project and self.project.hospital:
|
||||
from apps.core.utils import get_assignable_users
|
||||
self.fields["assigned_to"].queryset = get_assignable_users(self.project.hospital)
|
||||
from apps.organizations.models import Staff
|
||||
self.fields["assigned_to"].queryset = Staff.objects.filter(
|
||||
hospital=self.project.hospital, status="active"
|
||||
).order_by("first_name", "last_name")
|
||||
else:
|
||||
self.fields["assigned_to"].queryset = User.objects.none()
|
||||
|
||||
@ -400,11 +406,13 @@ class ConvertToProjectForm(forms.Form):
|
||||
).order_by("name")
|
||||
|
||||
# Filter project lead by hospital
|
||||
from apps.core.utils import get_assignable_users
|
||||
self.fields["project_lead"].queryset = get_assignable_users(self.user.hospital)
|
||||
from apps.organizations.models import Staff
|
||||
self.fields["project_lead"].queryset = Staff.objects.filter(
|
||||
hospital=self.user.hospital, status="active"
|
||||
).order_by("first_name", "last_name")
|
||||
else:
|
||||
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)
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -61,7 +61,9 @@ class QIProject(UUIDModel, TimeStampedModel):
|
||||
)
|
||||
|
||||
# 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
|
||||
created_by = models.ForeignKey(
|
||||
@ -69,7 +71,7 @@ class QIProject(UUIDModel, TimeStampedModel):
|
||||
)
|
||||
|
||||
# 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 = models.CharField(
|
||||
@ -137,7 +139,7 @@ class QIProjectTask(UUIDModel, TimeStampedModel):
|
||||
|
||||
# Assignment
|
||||
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
|
||||
|
||||
38
apps/projects/signals.py
Normal file
38
apps/projects/signals.py
Normal 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}")
|
||||
@ -21,6 +21,37 @@ from .forms import ConvertToProjectForm, QIProjectForm, QIProjectTaskForm, QIPro
|
||||
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
|
||||
@login_required
|
||||
def project_list(request):
|
||||
@ -28,7 +59,7 @@ def project_list(request):
|
||||
# Exclude templates from the list
|
||||
queryset = (
|
||||
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")
|
||||
)
|
||||
|
||||
@ -102,7 +133,7 @@ def project_detail(request, pk):
|
||||
|
||||
project = get_object_or_404(
|
||||
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"),
|
||||
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)
|
||||
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."))
|
||||
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:
|
||||
messages.error(request, _("You don't have permission to update tasks in this project."))
|
||||
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()
|
||||
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 = {
|
||||
"project": project,
|
||||
@ -1060,7 +1092,8 @@ def focus_phase_edit(request, pk, phase):
|
||||
|
||||
team_members = project.team_members.all()
|
||||
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 = {
|
||||
"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
|
||||
|
||||
|
||||
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
|
||||
@login_required
|
||||
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):
|
||||
return HttpResponse(_("Permission denied"), status=403)
|
||||
|
||||
if not _can_manage_task(project, task, user):
|
||||
return HttpResponse(_("Permission denied"), status=403)
|
||||
|
||||
if task.status == "completed":
|
||||
task.status = "pending"
|
||||
task.completed_date = None
|
||||
@ -1138,12 +1191,13 @@ def htmx_task_toggle_status(request, project_pk, task_pk):
|
||||
task.save()
|
||||
|
||||
can_edit = _get_can_edit(user)
|
||||
can_toggle = _can_manage_task(project, task, user)
|
||||
today = timezone.now().date()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"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
|
||||
team_members = project.team_members.all()
|
||||
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(
|
||||
request,
|
||||
|
||||
@ -7,6 +7,7 @@ app_name = "projects"
|
||||
urlpatterns = [
|
||||
# QI Project Views
|
||||
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/from-template/<uuid:template_pk>/", ui_views.project_create, name="project_create_from_template"),
|
||||
path("<uuid:pk>/", ui_views.project_detail, name="project_detail"),
|
||||
|
||||
173
e2e/tests/workflows/qi-projects-workflow.spec.ts
Normal file
173
e2e/tests/workflows/qi-projects-workflow.spec.ts
Normal 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');
|
||||
});
|
||||
@ -62,6 +62,9 @@
|
||||
.inner-tab-inactive {
|
||||
color: #64748b;
|
||||
}
|
||||
.ts-dropdown {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -159,11 +162,6 @@
|
||||
{% 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 %}
|
||||
</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 %}"
|
||||
{% 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 }})
|
||||
@ -174,25 +172,30 @@
|
||||
{% 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 %}
|
||||
</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">
|
||||
{% trans "PX Actions" %} ({{ px_actions.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 %}
|
||||
<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>
|
||||
{% 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 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"
|
||||
{% 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('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" %}
|
||||
{% 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 %}"
|
||||
{% 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 %}"
|
||||
<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('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" %}
|
||||
{% 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>
|
||||
{% 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">
|
||||
@ -203,14 +206,6 @@
|
||||
{% 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 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"
|
||||
{% 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" %}
|
||||
@ -219,11 +214,6 @@
|
||||
{% 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"
|
||||
{% 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>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
@ -233,9 +223,10 @@
|
||||
<div class="col-span-8 space-y-6">
|
||||
|
||||
<!-- Details Tab -->
|
||||
<div id="panel-details" class="tab-panel">
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<div id="panel-details" class="tab-panel space-y-6">
|
||||
<section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
|
||||
{% if complaint.description %}
|
||||
<p class="text-[10px] font-bold text-slate uppercase mb-2">{% trans "Complaint Description" %}</p>
|
||||
<div class="bg-slate-50 p-4 rounded-xl border-l-4 border-blue mb-4">
|
||||
<p class="text-sm leading-relaxed text-slate italic">"{{ complaint.description }}"</p>
|
||||
</div>
|
||||
@ -243,13 +234,77 @@
|
||||
|
||||
<div class="grid grid-cols-5 gap-4 py-4">
|
||||
<div>
|
||||
<p class="text-[10px] font-bold text-slate uppercase">{% trans "Location" %}</p>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<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">
|
||||
{% if complaint.legacy_location %}{{ complaint.legacy_location.name_en }}{% else %}-{% endif %}
|
||||
</p>
|
||||
{% if complaint.legacy_main_section %}
|
||||
<p class="text-xs text-slate">{{ complaint.legacy_main_section.name_en }}{% if complaint.legacy_subsection %} > {{ complaint.legacy_subsection.name_en }}{% endif %}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-bold text-slate uppercase">{% trans "Severity" %}</p>
|
||||
@ -371,16 +426,16 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% include "complaints/partials/pdf_summary_panel.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Departments Tab -->
|
||||
<div id="panel-departments" class="tab-panel hidden">
|
||||
{% include "complaints/partials/departments_panel.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Staff Tab -->
|
||||
<div id="panel-staff" class="tab-panel hidden">
|
||||
{% include "complaints/partials/staff_panel.html" %}
|
||||
<div id="panel-departments" class="tab-panel hidden space-y-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{% include "complaints/partials/departments_panel.html" %}
|
||||
{% include "complaints/partials/staff_panel.html" %}
|
||||
</div>
|
||||
{% include "complaints/partials/explanation_panel.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Timeline Tab -->
|
||||
@ -393,9 +448,10 @@
|
||||
{% include "complaints/partials/attachments_panel.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Actions Tab -->
|
||||
<div id="panel-actions" class="tab-panel hidden">
|
||||
<!-- Actions & RCA Tab -->
|
||||
<div id="panel-actions" class="tab-panel hidden space-y-6">
|
||||
{% include "complaints/partials/actions_panel.html" %}
|
||||
{% include "complaints/partials/rca_panel.html" %}
|
||||
</div>
|
||||
|
||||
<!-- AI Analysis Tab -->
|
||||
@ -403,12 +459,7 @@
|
||||
{% include "complaints/partials/ai_panel.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Explanation Tab -->
|
||||
<div id="panel-explanation" class="tab-panel hidden">
|
||||
{% include "complaints/partials/explanation_panel.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Resolution Tab -->
|
||||
<!-- Resolution & PDF Tab -->
|
||||
<div id="panel-resolution" class="tab-panel hidden">
|
||||
{% include "complaints/partials/resolution_panel.html" %}
|
||||
</div>
|
||||
@ -418,18 +469,9 @@
|
||||
{% include "complaints/partials/adverse_actions_panel.html" %}
|
||||
</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">
|
||||
{% include "partials/notes_panel.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel-pdf" class="tab-panel hidden">
|
||||
{% include "complaints/partials/pdf_summary_panel.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column (Sidebar) -->
|
||||
@ -440,7 +482,7 @@
|
||||
<h3 class="font-bold text-navy mb-4 text-sm">{% trans "Quick Actions" %}</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{% 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">
|
||||
<i data-lucide="user-plus" class="w-4 h-4 text-blue"></i>
|
||||
<span class="text-[10px] font-bold text-blue uppercase">
|
||||
@ -461,8 +503,8 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if complaint.assigned_to == current_user %}
|
||||
<!-- Action buttons only shown when activated -->
|
||||
{% if complaint.assigned_to == current_user or can_manage_actions %}
|
||||
<!-- 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">
|
||||
<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>
|
||||
@ -493,7 +535,7 @@
|
||||
</button>
|
||||
{% else %}
|
||||
<!-- 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">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="status" value="cancelled">
|
||||
@ -547,157 +589,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</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' %}
|
||||
<!-- Patient Contact Status -->
|
||||
@ -939,6 +830,225 @@
|
||||
</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 -->
|
||||
<script>
|
||||
function showActivationRequired() {
|
||||
@ -990,6 +1100,29 @@ function showCloseModal() {
|
||||
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) {
|
||||
document.getElementById(modalId).style.display = 'none';
|
||||
}
|
||||
@ -997,13 +1130,192 @@ function closeModal(modalId) {
|
||||
// Close modal when clicking outside
|
||||
window.onclick = function(event) {
|
||||
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) {
|
||||
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
|
||||
(function() {
|
||||
var el = document.getElementById('sla-countdown');
|
||||
@ -1055,7 +1367,7 @@ window.onclick = function(event) {
|
||||
})();
|
||||
</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" %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@ -933,7 +933,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
return;
|
||||
}
|
||||
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(data => {
|
||||
if (data.found) {
|
||||
|
||||
@ -598,7 +598,7 @@
|
||||
<!-- Explanations -->
|
||||
{% if explanations %}
|
||||
<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 %}
|
||||
<div class="explanation-card">
|
||||
<div class="explanation-header">
|
||||
|
||||
@ -82,7 +82,7 @@
|
||||
<a href="{% url 'inquiries:inquiry_list' %}" class="hover:text-navy">{% trans "Inquiries" %}</a>
|
||||
{% endif %}
|
||||
<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
|
||||
{% if inquiry.status == 'open' %}bg-yellow-100 text-yellow-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">
|
||||
<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('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>
|
||||
{% endif %}
|
||||
<button class="py-4 text-sm tab-inactive" onclick="switchTab('notes')" id="tab-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 %}
|
||||
@ -184,114 +185,8 @@
|
||||
{% endif %}
|
||||
</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 %}
|
||||
<li class="flex justify-between border-b border-slate-100 pb-2">
|
||||
<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">
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 mt-6">
|
||||
<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 %}">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center
|
||||
@ -499,6 +394,10 @@
|
||||
{% endif %}
|
||||
</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">
|
||||
<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">
|
||||
@ -606,18 +505,18 @@
|
||||
<span class="text-[10px] font-bold uppercase">{% trans "Edit" %}</span>
|
||||
</a>
|
||||
{% 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">
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% 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>
|
||||
<span class="text-[10px] font-bold text-indigo-700 uppercase">{% trans "Send to Department" %}</span>
|
||||
</button>
|
||||
@ -652,6 +551,45 @@
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if inquiry.status == 'resolved' or inquiry.status == 'closed' %}
|
||||
<section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
|
||||
<h3 class="font-bold text-navy mb-3 text-sm flex items-center gap-2">
|
||||
<i data-lucide="smile" class="w-4 h-4"></i> {% trans "Satisfaction" %}
|
||||
</h3>
|
||||
{% if inquiry.satisfaction %}
|
||||
<div class="mb-3">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold
|
||||
{% if inquiry.satisfaction == 'satisfied' %}bg-green-100 text-green-800 border border-green-300
|
||||
{% elif inquiry.satisfaction == 'neutral' %}bg-yellow-100 text-yellow-800 border border-yellow-300
|
||||
{% elif inquiry.satisfaction == 'dissatisfied' %}bg-red-100 text-red-800 border border-red-300
|
||||
{% else %}bg-slate-100 text-slate-600 border border-slate-300{% endif %}">
|
||||
{{ inquiry.get_satisfaction_display }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'inquiries:inquiry_update_satisfaction' inquiry.pk %}">
|
||||
{% csrf_token %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="submit" name="satisfaction" value="satisfied"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
|
||||
{% if inquiry.satisfaction == 'satisfied' %}border-green-500 bg-green-50 text-green-700{% else %}border-slate-200 text-slate-600 hover:border-green-400{% endif %}">
|
||||
<i data-lucide="thumbs-up" class="w-3.5 h-3.5"></i> {% trans "Satisfied" %}
|
||||
</button>
|
||||
<button type="submit" name="satisfaction" value="neutral"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
|
||||
{% if inquiry.satisfaction == 'neutral' %}border-yellow-500 bg-yellow-50 text-yellow-700{% else %}border-slate-200 text-slate-600 hover:border-yellow-400{% endif %}">
|
||||
<i data-lucide="minus-circle" class="w-3.5 h-3.5"></i> {% trans "Neutral" %}
|
||||
</button>
|
||||
<button type="submit" name="satisfaction" value="dissatisfied"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
|
||||
{% if inquiry.satisfaction == 'dissatisfied' %}border-red-500 bg-red-50 text-red-700{% else %}border-slate-200 text-slate-600 hover:border-red-400{% endif %}">
|
||||
<i data-lucide="thumbs-down" class="w-3.5 h-3.5"></i> {% trans "Dissatisfied" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -707,29 +645,18 @@
|
||||
<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>
|
||||
<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 class="mb-4">
|
||||
<label class="block text-sm font-semibold text-navy mb-2">{% trans "Response (English)" %}</label>
|
||||
<textarea name="response_en" id="responseEn" rows="6"
|
||||
<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 in English...' %}">{{ inquiry.response_en|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>
|
||||
placeholder="{% trans 'Enter your response...' %}" required>{{ inquiry.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 sent to the inquirer via SMS and Email." %}
|
||||
{% trans "The response will be sent to the inquirer via SMS and Email." %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6 border-t border-slate-200 flex gap-3">
|
||||
@ -1085,21 +1012,18 @@ function generateAIResponse() {
|
||||
}
|
||||
|
||||
function useAISuggestion(lang) {
|
||||
var text = '';
|
||||
var card = null;
|
||||
if (lang === 'en') {
|
||||
document.getElementById('responseEn').value = document.getElementById('aiSuggestionEnText').textContent;
|
||||
document.getElementById('aiSuggestionEn').classList.add('selected');
|
||||
setTimeout(() => document.getElementById('aiSuggestionEn').classList.remove('selected'), 1500);
|
||||
text = document.getElementById('aiSuggestionEnText').textContent;
|
||||
card = document.getElementById('aiSuggestionEn');
|
||||
} else {
|
||||
document.getElementById('responseAr').value = document.getElementById('aiSuggestionArText').textContent;
|
||||
document.getElementById('aiSuggestionAr').classList.add('selected');
|
||||
setTimeout(() => document.getElementById('aiSuggestionAr').classList.remove('selected'), 1500);
|
||||
text = document.getElementById('aiSuggestionArText').textContent;
|
||||
card = document.getElementById('aiSuggestionAr');
|
||||
}
|
||||
}
|
||||
|
||||
function useBothAISuggestions() {
|
||||
useAISuggestion('en'); useAISuggestion('ar');
|
||||
document.getElementById('aiSuggestionEn').classList.add('selected');
|
||||
document.getElementById('aiSuggestionAr').classList.add('selected');
|
||||
document.getElementById('responseText').value = text;
|
||||
card.classList.add('selected');
|
||||
setTimeout(() => card.classList.remove('selected'), 1500);
|
||||
}
|
||||
|
||||
function reanalyzeAI() {
|
||||
@ -1154,7 +1078,7 @@ document.addEventListener('keydown', function(e) {
|
||||
});
|
||||
</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" %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@ -444,7 +444,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
return;
|
||||
}
|
||||
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(data => {
|
||||
if (data.found) {
|
||||
|
||||
@ -15,8 +15,8 @@
|
||||
<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="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">
|
||||
<svg class="w-10 h-10 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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-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>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@ -15,8 +15,8 @@
|
||||
<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="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">
|
||||
<svg class="w-10 h-10 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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-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>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@ -11,33 +11,33 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
.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;
|
||||
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 {
|
||||
background: #fff; border: 2px solid #e2e8f0; border-radius: 1rem;
|
||||
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-control {
|
||||
width: 100%; padding: 0.75rem 1rem; border: 2px solid #e2e8f0;
|
||||
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 {
|
||||
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%;
|
||||
}
|
||||
.btn-primary:hover { background: #b45309; }
|
||||
.btn-primary:hover { background: #007bbd; }
|
||||
.btn-add {
|
||||
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;
|
||||
}
|
||||
.btn-add:hover { background: #fde68a; border-color: #f59e0b; }
|
||||
.btn-add:hover { background: #dbeaf6; border-color: #005696; }
|
||||
.btn-remove {
|
||||
width: 2rem; height: 2rem; display: flex; align-items: center; justify-content: center;
|
||||
background: #fee2e2; color: #dc2626; border: none; border-radius: 0.5rem; cursor: pointer;
|
||||
@ -74,7 +74,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% 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>
|
||||
<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 %}
|
||||
@ -95,8 +95,8 @@
|
||||
{% if accused_staff %}
|
||||
<div class="space-y-2">
|
||||
{% 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">
|
||||
<input type="checkbox" name="accused_staff[]" value="{{ s.staff_id }}" checked class="w-5 h-5 rounded text-amber-600">
|
||||
<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 accent-navy">
|
||||
<div class="flex-1">
|
||||
<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 %}
|
||||
@ -115,7 +115,7 @@
|
||||
<div id="questions-container" class="space-y-3">
|
||||
<div class="question-row flex items-start gap-2">
|
||||
<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' %}">×</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -145,10 +145,18 @@
|
||||
row.className = 'question-row flex items-start gap-2';
|
||||
row.innerHTML = `
|
||||
<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">×</button>
|
||||
`;
|
||||
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) {
|
||||
const container = document.getElementById('questions-container');
|
||||
|
||||
@ -11,9 +11,9 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
.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;
|
||||
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 {
|
||||
background: #fff; border: 2px solid #e2e8f0; border-radius: 1rem;
|
||||
@ -24,7 +24,7 @@
|
||||
width: 100%; padding: 0.75rem 1rem; border: 2px solid #e2e8f0;
|
||||
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 {
|
||||
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;
|
||||
@ -70,8 +70,8 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="p-4 rounded-xl" style="background:#fef3c7;border:1px solid #fbbf24">
|
||||
<p class="text-sm" style="color:#92400e">
|
||||
<div class="p-4 rounded-xl" style="background:#eef6fb;border:1px solid #93c5fd">
|
||||
<p class="text-sm" style="color:#005696">
|
||||
<strong>{% trans "Important:" %}</strong> {% trans "This link can only be used once. After submitting, it will expire." %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -11,9 +11,9 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
.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;
|
||||
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 {
|
||||
background: #fff; border: 2px solid #e2e8f0; border-radius: 1rem;
|
||||
@ -24,10 +24,10 @@
|
||||
width: 100%; padding: 0.75rem 1rem; border: 2px solid #e2e8f0;
|
||||
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 {
|
||||
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%;
|
||||
}
|
||||
.btn-primary:hover { background: #6d28d9; }
|
||||
@ -68,7 +68,7 @@
|
||||
{% for sd in staff_data %}
|
||||
<div class="staff-card">
|
||||
<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 }}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@ -1,13 +1,30 @@
|
||||
{% load i18n %}
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<h3 class="text-xl font-bold text-navy mb-6">{% trans "Related PX Actions" %}</h3>
|
||||
|
||||
<section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
|
||||
<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 %}
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
{% for action in px_actions %}
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition">
|
||||
<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>
|
||||
<p class="text-slate text-sm">{{ action.description|truncatewords:20 }}</p>
|
||||
<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>
|
||||
</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>
|
||||
</a>
|
||||
</div>
|
||||
@ -23,25 +40,16 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<i data-lucide="zap" class="w-16 h-16 mx-auto text-slate-300 mb-4"></i>
|
||||
<p class="text-slate mb-4">{% trans "No PX actions created yet" %}</p>
|
||||
{% if can_edit and complaint.is_active_status %}
|
||||
<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 class="text-center py-8 bg-slate-50 rounded-xl border border-dashed border-slate-200">
|
||||
<i data-lucide="zap" class="w-12 h-12 mx-auto text-slate-300 mb-3"></i>
|
||||
<p class="text-slate text-sm">{% trans "No PX actions created yet" %}</p>
|
||||
<p class="text-slate text-xs mt-1">{% trans "Use the buttons above to create an action, QI project, or RCA." %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function createAction() {
|
||||
// Redirect to action create page with complaint reference
|
||||
window.location.href = "{% url 'actions:action_create' %}?source_type=complaint&complaint_id={{ complaint.id }}";
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
{% load i18n %}
|
||||
{% load hospital_filters %}
|
||||
<section id="aiAnalysisContent" class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-bold text-navy flex items-center gap-2">
|
||||
<section id="aiAnalysisContent" 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 flex items-center gap-2">
|
||||
<i data-lucide="bot" class="w-6 h-6"></i> {% trans "AI Analysis" %}
|
||||
</h3>
|
||||
{% if user.is_px_admin or user.is_hospital_admin %}
|
||||
|
||||
@ -1,201 +1,121 @@
|
||||
{% load i18n %}
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-xl font-bold text-navy">{% trans "Involved Departments" %}</h3>
|
||||
{% if can_edit and complaint.is_active_status %}
|
||||
<a href="{% url 'complaints:involved_department_add' complaint_pk=complaint.pk %}"
|
||||
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">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add Department" %}
|
||||
</a>
|
||||
<section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-sm font-bold text-navy uppercase tracking-wide">{% trans "Involved Departments" %}</h3>
|
||||
{% if can_manage_actions and complaint.is_active_status %}
|
||||
<button type="button" onclick="showAddDepartmentModal()"
|
||||
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-3.5 h-3.5"></i> {% trans "Add" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if can_edit 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="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">
|
||||
<h4 class="font-bold text-navy">{{ complaint.department.name }}</h4>
|
||||
<span class="px-2 py-0.5 bg-blue-100 text-blue-700 rounded-lg text-xs font-semibold">{% trans "AI Suggestion" %}</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate">{% trans "AI suggested this department based on the complaint analysis." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{% url 'complaints:confirm_ai_department_suggestion' complaint_pk=complaint.pk %}" class="inline">
|
||||
{% 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">
|
||||
<i data-lucide="check" class="w-4 h-4"></i> {% trans "Confirm" %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% 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-3 mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="sparkles" class="w-4 h-4 text-blue-600"></i>
|
||||
<span class="text-xs font-semibold text-navy">{{ complaint.department.name }}</span>
|
||||
<span class="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded text-[10px] font-bold">{% trans "AI" %}</span>
|
||||
</div>
|
||||
<form method="post" action="{% url 'complaints:confirm_ai_department_suggestion' complaint_pk=complaint.pk %}" class="inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="px-2 py-1 text-xs bg-navy text-white rounded-lg font-semibold hover:bg-blue transition">
|
||||
{% trans "Confirm" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if complaint.involved_departments.exists %}
|
||||
<div class="space-y-4">
|
||||
{% 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 %}">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h4 class="font-bold text-navy">{{ dept.department.name }}</h4>
|
||||
{% if dept.is_primary %}
|
||||
<span class="px-2 py-1 bg-navy text-white rounded-lg text-xs font-bold">{% trans "PRIMARY" %}</span>
|
||||
{% endif %}
|
||||
<span class="px-2 py-1 bg-light text-navy rounded-lg text-xs font-semibold">{{ dept.get_role_display }}</span>
|
||||
</div>
|
||||
|
||||
{% if dept.assigned_to %}
|
||||
<div class="flex items-center gap-2 text-sm text-slate mb-2">
|
||||
<i data-lucide="user-check" class="w-4 h-4 text-blue"></i>
|
||||
<span>{% trans "Assigned to:" %} <strong>{{ dept.assigned_to.get_full_name }}</strong></span>
|
||||
{% if dept.assigned_at %}
|
||||
<span class="text-slate">({{ dept.assigned_at|date:"M d, Y" }})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<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 %}
|
||||
|
||||
{% if dept.notes %}
|
||||
<div class="bg-slate-50 rounded-lg p-3 mt-2">
|
||||
<p class="text-sm text-slate">{{ dept.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if dept.response_submitted %}
|
||||
{% if dept.manager_review_status == 'rejected' %}
|
||||
<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' %}
|
||||
<div class="rounded-lg p-3 mt-3 border bg-amber-50 border-amber-200">
|
||||
<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' %}
|
||||
<div class="rounded-lg p-3 mt-3 border bg-green-50 border-green-200">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
{% for dept in complaint.involved_departments.all %}
|
||||
<tr class="hover:bg-slate-50/50 transition {% if dept.is_primary %}bg-navy/5{% endif %}">
|
||||
<td class="py-2.5 pr-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
{% if dept.is_primary %}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-navy shrink-0"></span>
|
||||
{% endif %}
|
||||
<span class="font-semibold text-navy text-xs">{{ dept.department.name }}</span>
|
||||
</div>
|
||||
{% if dept.response_notes_en %}
|
||||
<p class="text-sm text-slate-700 mt-1">{{ dept.response_notes_en }}</p>
|
||||
{% if dept.assigned_to %}
|
||||
<p class="text-[10px] text-slate mt-0.5">
|
||||
<i data-lucide="user-check" class="w-3 h-3 inline"></i> {{ dept.assigned_to.get_full_name }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if dept.response_notes_ar %}
|
||||
<p class="text-sm text-slate-700 mt-1" dir="rtl">{{ dept.response_notes_ar }}</p>
|
||||
{% if dept.response_notes %}
|
||||
<p class="text-[10px] text-slate/70 mt-1 italic max-w-xs truncate">{{ dept.response_notes|truncatechars:60 }}</p>
|
||||
{% endif %}
|
||||
{% if dept.acceptance_notes %}
|
||||
<p class="text-xs text-slate mt-1 italic">{% trans "Review notes:" %} {{ dept.acceptance_notes }}</p>
|
||||
</td>
|
||||
<td class="py-2.5 px-2">
|
||||
<span class="px-1.5 py-0.5 bg-light text-navy rounded text-[10px] font-semibold">{{ dept.get_role_display }}</span>
|
||||
</td>
|
||||
<td class="py-2.5 px-2">
|
||||
{% if not dept.response_submitted %}
|
||||
<span class="text-[10px] text-slate/50 italic">{% trans "No response" %}</span>
|
||||
{% elif dept.manager_review_status == 'rejected' %}
|
||||
<span class="inline-flex items-center gap-1 text-[10px] font-bold text-red-600"><i data-lucide="x-circle" class="w-3 h-3"></i> {% trans "Rejected" %}</span>
|
||||
{% elif dept.manager_review_status == 'pending' %}
|
||||
<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>
|
||||
{% elif dept.acceptance_status == 'acceptable' %}
|
||||
<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>
|
||||
{% elif dept.acceptance_status == 'not_acceptable' %}
|
||||
<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>
|
||||
{% elif dept.acceptance_status == 'pending' and dept.manager_review_status == 'approved' %}
|
||||
<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>
|
||||
{% else %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif dept.acceptance_status == 'not_acceptable' %}
|
||||
<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 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' %}
|
||||
<div class="rounded-lg p-3 mt-3 border bg-green-50 border-green-200">
|
||||
<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">
|
||||
</td>
|
||||
{% if can_manage_actions and complaint.is_active_status %}
|
||||
<td class="py-2.5 pl-2 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
{% if not dept.response_submitted %}
|
||||
<button type="button"
|
||||
onclick="openDeptResponseModal('complaint', '{{ dept.pk }}', '{% url 'complaints:involved_department_response' pk=dept.pk %}', '{{ complaint.reference_number }}', '{{ dept.department.name|escapejs }}')"
|
||||
class="p-1.5 text-slate hover:text-blue transition" title="{% trans 'Submit Response' %}">
|
||||
<i data-lucide="message-square" class="w-4 h-4"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{% url 'complaints:involved_department_edit' pk=dept.pk %}"
|
||||
class="p-1.5 text-slate hover:text-navy transition" title="{% trans 'Edit' %}">
|
||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||
</a>
|
||||
<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?" %}')">
|
||||
{% 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 type="submit" class="p-1.5 text-slate hover:text-red-500 transition" title="{% trans 'Remove' %}">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-lg p-3 mt-3 border bg-amber-50 border-amber-200">
|
||||
<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 %}
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if can_edit and complaint.is_active_status %}
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
{% if not dept.response_submitted %}
|
||||
<button type="button"
|
||||
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' %}">
|
||||
<i data-lucide="message-square" class="w-5 h-5"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{% url 'complaints:involved_department_edit' pk=dept.pk %}"
|
||||
class="p-2 text-slate hover:text-navy transition" title="{% trans 'Edit' %}">
|
||||
<i data-lucide="edit-2" class="w-5 h-5"></i>
|
||||
</a>
|
||||
<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?" %}')">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="p-2 text-slate hover:text-red-500 transition" title="{% trans 'Remove' %}">
|
||||
<i data-lucide="trash-2" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<i data-lucide="building-2" class="w-16 h-16 mx-auto text-slate-300 mb-4"></i>
|
||||
<p class="text-slate mb-4">{% trans "No departments involved yet" %}</p>
|
||||
{% if can_edit and complaint.is_active_status %}
|
||||
<a href="{% url 'complaints:involved_department_add' complaint_pk=complaint.pk %}"
|
||||
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">
|
||||
<div class="text-center py-8">
|
||||
<i data-lucide="building-2" class="w-10 h-10 mx-auto text-slate-300 mb-2"></i>
|
||||
<p class="text-slate text-sm mb-3">{% trans "No departments involved yet" %}</p>
|
||||
{% if can_manage_actions and complaint.is_active_status %}
|
||||
<button type="button" onclick="showAddDepartmentModal()"
|
||||
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" %}
|
||||
</a>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -1,37 +1,12 @@
|
||||
{% load i18n %}
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<h3 class="text-xl font-bold text-navy mb-6">{% trans "Staff Explanations" %}</h3>
|
||||
<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 "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 %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div></div>
|
||||
{% if can_edit 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">
|
||||
{% if can_manage_actions and complaint.is_active_status %}
|
||||
<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" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
@ -121,24 +96,24 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- 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 %}
|
||||
{% if not linked_dept or linked_dept.manager_review_status == 'approved' %}
|
||||
<div class="mt-4 pt-4 border-t border-slate-100">
|
||||
{% if exp.acceptance_status == 'pending' %}
|
||||
<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" %}
|
||||
</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" %}
|
||||
</button>
|
||||
{% 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" %}
|
||||
</button>
|
||||
{% 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" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
@ -155,25 +130,71 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% 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 -->
|
||||
{% 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="flex flex-wrap gap-2">
|
||||
{% 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" %}
|
||||
</button>
|
||||
{% 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" %}
|
||||
</button>
|
||||
{% 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" %}
|
||||
</span>
|
||||
{% 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" %}
|
||||
</button>
|
||||
</div>
|
||||
@ -193,11 +214,11 @@
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<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>
|
||||
{% if can_edit 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">
|
||||
<p class="text-slate mb-4">{% trans "Not requests sent yet" %}</p>
|
||||
{% if can_manage_actions and complaint.is_active_status %}
|
||||
<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" %}
|
||||
</a>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
{% load i18n %}
|
||||
<section id="pdfSummaryContent" class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-bold text-navy flex items-center gap-2">
|
||||
<section id="pdfSummaryContent" 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 flex items-center gap-2">
|
||||
<i data-lucide="file-text" class="w-6 h-6"></i> {% trans "PDF Report" %}
|
||||
</h3>
|
||||
</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." %}
|
||||
</p>
|
||||
|
||||
{% if complaint.status == 'resolved' or complaint.status == 'closed' %}
|
||||
<!-- State: checking -->
|
||||
<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>
|
||||
@ -19,7 +20,7 @@
|
||||
<!-- State: empty -->
|
||||
<div id="pdfSummaryEmpty" class="hidden">
|
||||
<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>
|
||||
إنشاء الملخص
|
||||
</button>
|
||||
@ -102,12 +103,12 @@
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<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>
|
||||
إنشاء PDF
|
||||
</button>
|
||||
<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>
|
||||
إعادة إنشاء الملخص
|
||||
</button>
|
||||
@ -138,12 +139,12 @@
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<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>
|
||||
تحميل PDF
|
||||
</a>
|
||||
<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>
|
||||
تعديل النص
|
||||
</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>
|
||||
</div>
|
||||
<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>
|
||||
تحديث PDF
|
||||
</button>
|
||||
@ -233,8 +234,19 @@
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{% if complaint.status == 'resolved' or complaint.status == 'closed' %}
|
||||
<script>
|
||||
(function() {
|
||||
let _pdfBlobUrl = null;
|
||||
@ -438,3 +450,4 @@
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
@ -1,71 +1,55 @@
|
||||
{% load i18n %}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<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>
|
||||
<section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
|
||||
<h3 class="text-lg font-bold text-navy mb-1">{% trans "Root Cause Analysis" %}</h3>
|
||||
<p class="text-xs text-slate mb-4">{% trans "Structured analysis to identify underlying causes and prevent recurrence." %}</p>
|
||||
|
||||
{% if linked_rcas %}
|
||||
<div class="space-y-3">
|
||||
{% for rca in linked_rcas %}
|
||||
<a href="{% url 'rca:rca_detail' pk=rca.pk %}" class="block bg-white border border-slate-200 rounded-xl p-4 hover:border-purple-300 hover:shadow-sm transition group">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<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>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase
|
||||
{% if rca.status == 'draft' %}bg-slate-100 text-slate-600
|
||||
{% elif rca.status == 'in_progress' %}bg-blue-100 text-blue-700
|
||||
{% elif rca.status == 'review' %}bg-yellow-100 text-yellow-700
|
||||
{% elif rca.status == 'approved' %}bg-green-100 text-green-700
|
||||
{% elif rca.status == 'closed' %}bg-gray-100 text-gray-600{% endif %}">
|
||||
{{ rca.get_status_display }}
|
||||
</span>
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase
|
||||
{% if rca.severity == 'low' %}bg-green-50 text-green-600
|
||||
{% elif rca.severity == 'medium' %}bg-yellow-50 text-yellow-600
|
||||
{% elif rca.severity == 'high' %}bg-orange-50 text-orange-600
|
||||
{% elif rca.severity == 'critical' %}bg-red-50 text-red-600{% endif %}">
|
||||
{{ rca.get_severity_display }}
|
||||
</span>
|
||||
{% if rca.assigned_to %}
|
||||
<span class="text-[10px] text-slate">
|
||||
<i data-lucide="user" class="w-3 h-3 inline"></i> {{ rca.assigned_to.get_full_name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0 ml-3 text-right">
|
||||
<p class="text-[10px] text-slate">{{ rca.created_at|date:"M d, Y" }}</p>
|
||||
<div class="mt-1 text-[10px] text-slate">
|
||||
<div class="space-y-3">
|
||||
{% for rca in linked_rcas %}
|
||||
<a href="{% url 'rca:rca_detail' pk=rca.pk %}" class="block bg-white border border-slate-200 rounded-xl p-4 hover:border-purple-300 hover:shadow-sm transition group">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<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>
|
||||
<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
|
||||
{% if rca.status == 'draft' %}bg-slate-100 text-slate-600
|
||||
{% elif rca.status == 'in_progress' %}bg-blue-100 text-blue-700
|
||||
{% elif rca.status == 'review' %}bg-yellow-100 text-yellow-700
|
||||
{% elif rca.status == 'approved' %}bg-green-100 text-green-700
|
||||
{% elif rca.status == 'closed' %}bg-gray-100 text-gray-600{% endif %}">
|
||||
{{ rca.get_status_display }}
|
||||
</span>
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase
|
||||
{% if rca.severity == 'low' %}bg-green-50 text-green-600
|
||||
{% elif rca.severity == 'medium' %}bg-yellow-50 text-yellow-600
|
||||
{% elif rca.severity == 'high' %}bg-orange-50 text-orange-600
|
||||
{% elif rca.severity == 'critical' %}bg-red-50 text-red-600{% endif %}">
|
||||
{{ rca.get_severity_display }}
|
||||
</span>
|
||||
{% if rca.assigned_to %}
|
||||
<span class="text-[10px] text-slate inline-flex items-center gap-0.5">
|
||||
<i data-lucide="user" class="w-3 h-3"></i> {{ rca.assigned_to.get_full_name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="text-[10px] text-slate">
|
||||
{% trans "Root Causes" %}: {{ rca.root_causes.count }} ·
|
||||
{% trans "Actions" %}: {{ rca.corrective_actions.count }}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="flex-shrink-0 ml-3 text-right">
|
||||
<p class="text-[10px] text-slate">{{ rca.created_at|date:"M d, Y" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12 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>
|
||||
<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>
|
||||
{% 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 class="text-center py-8 bg-slate-50 rounded-xl border border-dashed border-slate-200">
|
||||
<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-xs text-slate mt-1">{% trans "Use the Initiate RCA button above to start investigating." %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
{% load i18n %}
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<h3 class="text-xl font-bold text-navy mb-6">{% trans "Resolution" %}</h3>
|
||||
<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 "Resolution" %}</h3>
|
||||
|
||||
|
||||
{% 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">
|
||||
<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>
|
||||
@ -47,7 +47,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<i data-lucide="smile" class="w-5 h-5 text-navy"></i>
|
||||
<h4 class="font-bold text-navy">{% trans "Patient Satisfaction" %}</h4>
|
||||
@ -116,36 +116,15 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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 complaint.assigned_to == current_user %}
|
||||
|
||||
{% if can_manage_actions and complaint.is_active_status %}
|
||||
{% if complaint.assigned_to == current_user or can_manage_actions %}
|
||||
<form method="post" action="{% url 'complaints:complaint_change_status' pk=complaint.pk %}" id="resolutionForm">
|
||||
{% csrf_token %}
|
||||
<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 -->
|
||||
{% if explanations %}
|
||||
<div id="aiResolutionSelection" class="hidden mb-4">
|
||||
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Select AI Generated Resolution" %}</label>
|
||||
<div class="space-y-3">
|
||||
@ -157,7 +136,7 @@
|
||||
</div>
|
||||
<div id="resolutionEnText" class="text-sm text-slate bg-slate-50 rounded-lg p-3 max-h-32 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Arabic Option -->
|
||||
<div class="border border-slate-200 rounded-xl p-4 cursor-pointer hover:border-navy transition" onclick="selectResolution('ar')">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
@ -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>
|
||||
</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">
|
||||
<i data-lucide="check-circle" class="w-5 h-5"></i> {% trans "Mark as Resolved" %}
|
||||
</button>
|
||||
<!-- 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" %}
|
||||
</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>
|
||||
{% else %}
|
||||
<!-- Show message that activation is required to resolve -->
|
||||
|
||||
@ -1,102 +1,98 @@
|
||||
{% load i18n %}
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-xl font-bold text-navy">{% trans "Involved Staff" %}</h3>
|
||||
{% if can_edit and complaint.is_active_status %}
|
||||
<a href="{% url 'complaints:involved_staff_add' complaint_pk=complaint.pk %}"
|
||||
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">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add Staff" %}
|
||||
</a>
|
||||
<section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-sm font-bold text-navy uppercase tracking-wide">{% trans "Involved Staff" %}</h3>
|
||||
{% if can_manage_actions and complaint.is_active_status %}
|
||||
<button type="button" onclick="showAddStaffModal()"
|
||||
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-3.5 h-3.5"></i> {% trans "Add" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
{% if complaint.involved_staff.exists %}
|
||||
<div class="space-y-4">
|
||||
{% for staff_inv in complaint.involved_staff.all %}
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-5 hover:shadow-md transition">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-10 h-10 bg-light rounded-full flex items-center justify-center">
|
||||
<i data-lucide="user" class="w-5 h-5 text-navy"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-navy">{{ staff_inv.staff.get_localized_name }}</h4>
|
||||
<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 %}
|
||||
<div class="flex items-center gap-2 text-sm text-slate mb-2">
|
||||
<i data-lucide="building-2" class="w-4 h-4 text-slate"></i>
|
||||
<span>{{ staff_inv.staff.department.name }}</span>
|
||||
</div>
|
||||
<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 %}
|
||||
|
||||
{% if staff_inv.notes %}
|
||||
<div class="bg-slate-50 rounded-lg p-3 mt-2">
|
||||
<p class="text-sm text-slate">{{ staff_inv.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if staff_inv.explanation_received %}
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mt-3">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
{% for staff_inv in complaint.involved_staff.all %}
|
||||
<tr class="hover:bg-slate-50/50 transition">
|
||||
<td class="py-2.5 pr-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-7 h-7 bg-light rounded-full flex items-center justify-center shrink-0">
|
||||
<i data-lucide="user" class="w-3.5 h-3.5 text-navy"></i>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-navy text-xs truncate">{{ staff_inv.staff.get_localized_name }}</p>
|
||||
{% if staff_inv.staff.department %}
|
||||
<p class="text-[10px] text-slate truncate">{{ staff_inv.staff.department.name }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if staff_inv.explanation %}
|
||||
<p class="text-sm text-slate-700 mt-1">{{ staff_inv.explanation }}</p>
|
||||
{% if staff_inv.notes %}
|
||||
<p class="text-[10px] text-slate/70 mt-1 italic max-w-xs truncate">{{ staff_inv.notes|truncatechars:60 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif staff_inv.explanation_requested %}
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="clock" class="w-4 h-4 text-yellow-500"></i>
|
||||
<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>
|
||||
</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 %}
|
||||
<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>
|
||||
{% elif staff_inv.explanation_requested %}
|
||||
<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>
|
||||
{% else %}
|
||||
<span class="text-[10px] text-slate/50 italic">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if can_manage_actions and complaint.is_active_status %}
|
||||
<td class="py-2.5 pl-2 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
{% if not staff_inv.explanation_received %}
|
||||
<form method="post" action="{% url 'complaints:involved_staff_explanation' pk=staff_inv.pk %}" class="inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="p-1.5 text-slate hover:text-blue transition" title="{% trans 'Submit Response' %}">
|
||||
<i data-lucide="message-square" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{% url 'complaints:involved_staff_edit' pk=staff_inv.pk %}"
|
||||
class="p-1.5 text-slate hover:text-navy transition" title="{% trans 'Edit' %}">
|
||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
||||
</a>
|
||||
<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?" %}')">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="p-1.5 text-slate hover:text-red-500 transition" title="{% trans 'Remove' %}">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if can_edit and complaint.is_active_status %}
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
{% if not staff_inv.explanation_received %}
|
||||
<form method="post" action="{% url 'complaints:involved_staff_explanation' pk=staff_inv.pk %}" class="inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="p-2 text-slate hover:text-blue transition" title="{% trans 'Submit Response' %}">
|
||||
<i data-lucide="message-square" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{% url 'complaints:involved_staff_edit' pk=staff_inv.pk %}"
|
||||
class="p-2 text-slate hover:text-navy transition" title="{% trans 'Edit' %}">
|
||||
<i data-lucide="edit-2" class="w-5 h-5"></i>
|
||||
</a>
|
||||
<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?" %}')">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="p-2 text-slate hover:text-red-500 transition" title="{% trans 'Remove' %}">
|
||||
<i data-lucide="trash-2" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<i data-lucide="users" class="w-16 h-16 mx-auto text-slate-300 mb-4"></i>
|
||||
<p class="text-slate mb-4">{% trans "No staff members involved yet" %}</p>
|
||||
{% if can_edit and complaint.is_active_status %}
|
||||
<a href="{% url 'complaints:involved_staff_add' complaint_pk=complaint.pk %}"
|
||||
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">
|
||||
<div class="text-center py-8">
|
||||
<i data-lucide="users" class="w-10 h-10 mx-auto text-slate-300 mb-2"></i>
|
||||
<p class="text-slate text-sm mb-3">{% trans "No staff members involved yet" %}</p>
|
||||
{% if can_manage_actions and complaint.is_active_status %}
|
||||
<button type="button" onclick="showAddStaffModal()"
|
||||
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" %}
|
||||
</a>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{% load i18n %}
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<h3 class="text-xl font-bold text-navy mb-6">{% trans "Timeline" %}</h3>
|
||||
<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 "Timeline" %}</h3>
|
||||
|
||||
{% if not stage_timeline.stages %}
|
||||
<div class="text-center py-12">
|
||||
|
||||
@ -96,43 +96,43 @@ header.glass-card {
|
||||
{% endfor %}
|
||||
</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 -->
|
||||
<div class="rounded-3xl shadow-2xl overflow-hidden mb-8 text-center animate-fade-in">
|
||||
<div class="bg-white w-full py-8 px-6 flex items-center justify-center">
|
||||
<img src="{% static 'img/hh-logo.png' %}" alt="Al Hammadi Hospital" class="max-h-16 w-auto object-contain">
|
||||
<div class="rounded-2xl shadow-lg overflow-hidden mb-6 text-center animate-fade-in">
|
||||
<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-12 md:max-h-16 w-auto object-contain">
|
||||
</div>
|
||||
<div class="bg-white p-8">
|
||||
<h1 class="text-2xl font-bold text-navy mb-3">{% trans "Track Your Complaint" %}</h1>
|
||||
<p class="text-slate text-base max-w-xl mx-auto">
|
||||
<div class="bg-white p-4 md:p-8">
|
||||
<h1 class="text-xl md:text-2xl font-bold text-navy mb-2">{% trans "Track Your Complaint" %}</h1>
|
||||
<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." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
{% csrf_token %}
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<i data-lucide="hash" class="w-5 h-5 text-slate/40 group-focus-within:text-blue transition-colors"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
<input
|
||||
type="text"
|
||||
name="reference_number"
|
||||
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' %}"
|
||||
value="{{ reference_number }}"
|
||||
required
|
||||
>
|
||||
</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>
|
||||
{% trans "Track Status" %}
|
||||
</button>
|
||||
</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>
|
||||
{% trans "Found in your confirmation email" %}
|
||||
</p>
|
||||
@ -152,74 +152,58 @@ header.glass-card {
|
||||
|
||||
{% if complaint %}
|
||||
<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="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div class="bg-white rounded-2xl shadow-lg p-4 md:p-6 mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<span class="text-xs font-bold text-slate/40 uppercase tracking-widest block mb-1">{% trans "Case Reference" %}</span>
|
||||
<h2 class="text-3xl font-black text-navy">{{ complaint.reference_number }}</h2>
|
||||
<span class="text-[10px] font-bold text-slate/40 uppercase tracking-wider block">{% trans "Case Reference" %}</span>
|
||||
<h2 class="text-xl md:text-2xl font-black text-navy">{{ complaint.reference_number }}</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-right hidden md:block">
|
||||
<span class="text-xs font-bold text-slate/40 uppercase tracking-widest block mb-1">{% trans "Current Status" %}</span>
|
||||
<p class="font-bold text-navy">{{ public_status.label }}</p>
|
||||
</div>
|
||||
<div class="px-6 py-3 rounded-2xl text-sm font-black uppercase tracking-wider shadow-sm border-b-4
|
||||
{% if public_status.css == 'amber' %}bg-amber-50 text-amber-700 border-amber-200
|
||||
{% 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 %}">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider
|
||||
{% if public_status.css == 'amber' %}bg-amber-50 text-amber-700
|
||||
{% elif public_status.css == 'blue' %}bg-blue-50 text-blue-700
|
||||
{% elif public_status.css == 'emerald' %}bg-emerald-50 text-emerald-700
|
||||
{% elif public_status.css == 'rose' %}bg-rose-50 text-rose-700
|
||||
{% else %}bg-slate-50 text-slate-700{% endif %}">
|
||||
{{ public_status.label }}
|
||||
</div>
|
||||
{% 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">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
|
||||
<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-3.5 h-3.5"></i>
|
||||
{% trans "Escalated" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 h-2 w-full bg-slate-100 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-navy transition-all duration-1000"
|
||||
|
||||
<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"
|
||||
style="width: {{ public_status.progress }}%">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
|
||||
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm transition-hover hover:shadow-md">
|
||||
<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="bg-white rounded-2xl shadow-lg p-5 md:p-8">
|
||||
<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">
|
||||
<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>
|
||||
{% trans "Resolution Journey" %}
|
||||
</h3>
|
||||
@ -227,26 +211,27 @@ header.glass-card {
|
||||
{% if public_updates %}
|
||||
<div class="space-y-1">
|
||||
{% 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="w-12 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
|
||||
{% elif update.update_type == 'resolution' %}bg-emerald-100 text-emerald-600
|
||||
<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 == 'resolution' %}bg-emerald-100 text-emerald-600
|
||||
{% 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 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">
|
||||
<h4 class="font-black text-navy text-lg">
|
||||
{% if update.update_type == 'status_change' %}{% trans "Status Updated" %}
|
||||
{% elif update.update_type == 'resolution' %}{% trans "Final Resolution" %}
|
||||
{% else %}{% trans "Update Received" %}{% endif %}
|
||||
<h4 class="font-black text-navy text-base md:text-lg">
|
||||
{% if update.update_type == 'resolution' %}{% trans "Final Resolution" %}
|
||||
{% else %}{% trans "Department Response" %}{% endif %}
|
||||
</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>
|
||||
{% 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 %}
|
||||
<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 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -18,38 +18,15 @@
|
||||
<p id="drmSubject" class="text-sm text-slate-700"></p>
|
||||
</div>
|
||||
|
||||
<div id="drmSingleField">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-2">
|
||||
{% trans "Your Response" %} <span class="text-red-500">*</span>
|
||||
</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"
|
||||
placeholder="{% trans "Enter your department's response..." %}"></textarea>
|
||||
</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 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('drmSubject').textContent = subject || '';
|
||||
|
||||
const singleField = document.getElementById('drmSingleField');
|
||||
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('drmResponseNotes').value = existingEn || existingAr || '';
|
||||
document.getElementById('drmError').classList.add('hidden');
|
||||
|
||||
document.getElementById('deptResponseModal').classList.remove('hidden');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
@ -107,23 +69,18 @@ function submitDeptResponse() {
|
||||
const submitBtn = document.getElementById('drmSubmitBtn');
|
||||
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 = {};
|
||||
if (_drmConfig.type === 'complaint') {
|
||||
body.response_notes_en = document.getElementById('drmResponseEn').value.trim();
|
||||
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;
|
||||
}
|
||||
body.response_notes = value;
|
||||
} else {
|
||||
body.response_en = document.getElementById('drmResponseEn').value.trim();
|
||||
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;
|
||||
}
|
||||
body.response_en = value;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
|
||||
@ -11,7 +11,7 @@ Required context variables:
|
||||
{% 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 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="flex items-center justify-between">
|
||||
<h3 class="text-xl font-bold text-navy flex items-center gap-2">
|
||||
@ -86,6 +86,23 @@ Required context variables:
|
||||
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 'Add context or instructions...' %}"></textarea>
|
||||
</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 -->
|
||||
<div id="sendError" class="hidden text-sm text-red-600 bg-red-50 border border-red-200 p-3 rounded-lg"></div>
|
||||
@ -106,15 +123,25 @@ Required context variables:
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showSendModal(itemId, itemType) {
|
||||
function showSendModal(itemId, itemType, preselectDeptId) {
|
||||
document.getElementById('sendToForm').reset();
|
||||
document.getElementById('sendItemId').value = itemId;
|
||||
document.getElementById('sendItemType').value = itemType;
|
||||
document.getElementById('sendToModal').classList.remove('hidden');
|
||||
document.getElementById('sendError').classList.add('hidden');
|
||||
document.getElementById('sendSuccess').classList.add('hidden');
|
||||
document.getElementById('sendToForm').reset();
|
||||
document.getElementById('contactPersonSection').classList.add('hidden');
|
||||
switchRecipientType('person');
|
||||
|
||||
if (preselectDeptId) {
|
||||
switchRecipientType('department');
|
||||
var deptSelect = document.getElementById('departmentSelect');
|
||||
if (deptSelect) {
|
||||
deptSelect.value = preselectDeptId;
|
||||
loadDepartmentContacts(preselectDeptId);
|
||||
}
|
||||
} else {
|
||||
switchRecipientType('person');
|
||||
}
|
||||
}
|
||||
|
||||
function closeSendModal() {
|
||||
@ -127,8 +154,11 @@ function switchRecipientType(type) {
|
||||
const contactPersonSection = document.getElementById('contactPersonSection');
|
||||
const personLabel = document.getElementById('recipientLabelPerson');
|
||||
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 (personRadio) personRadio.checked = true;
|
||||
personSection.classList.remove('hidden');
|
||||
departmentSection.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.remove('border-navy', 'bg-navy/5', 'text-navy', 'font-semibold');
|
||||
} else {
|
||||
if (deptRadio) deptRadio.checked = true;
|
||||
personSection.classList.add('hidden');
|
||||
departmentSection.classList.remove('hidden');
|
||||
deptLabel.classList.add('border-navy', 'bg-navy/5', 'text-navy', 'font-semibold');
|
||||
|
||||
@ -127,15 +127,15 @@ header.glass-card {
|
||||
{% endfor %}
|
||||
</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 -->
|
||||
<div class="rounded-3xl shadow-2xl overflow-hidden mb-8 text-center animate-fade-in">
|
||||
<div class="bg-white w-full py-8 px-6 flex items-center justify-center">
|
||||
<img src="{% static 'img/hh-logo.png' %}" alt="Al Hammadi Hospital" class="max-h-16 w-auto object-contain">
|
||||
<div class="rounded-2xl shadow-lg overflow-hidden mb-6 text-center animate-fade-in">
|
||||
<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-12 md:max-h-16 w-auto object-contain">
|
||||
</div>
|
||||
<div class="bg-white p-8">
|
||||
<h1 class="text-2xl font-bold text-navy mb-3">{% trans "Track Your Submission" %}</h1>
|
||||
<p class="text-slate text-base max-w-xl mx-auto">
|
||||
<div class="bg-white p-4 md:p-8">
|
||||
<h1 class="text-xl md:text-2xl font-bold text-navy mb-2">{% trans "Track Your Submission" %}</h1>
|
||||
<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." %}
|
||||
</p>
|
||||
</div>
|
||||
@ -221,32 +221,30 @@ header.glass-card {
|
||||
|
||||
<!-- Results -->
|
||||
<div id="resultsBox" class="hidden animate-slide-up">
|
||||
<!-- Status Header -->
|
||||
<div class="bg-white rounded-3xl shadow-2xl p-6 md:p-8 mb-6">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<!-- Status Header (compact) -->
|
||||
<div class="bg-white rounded-2xl shadow-lg p-4 md:p-6 mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<span class="text-xs font-bold text-slate/40 uppercase tracking-widest block mb-1" id="resultRefLabel"></span>
|
||||
<h2 class="text-3xl font-black text-navy" id="resultReference"></h2>
|
||||
<span class="text-[10px] font-bold text-slate/40 uppercase tracking-wider block" id="resultRefLabel"></span>
|
||||
<h2 class="text-xl md:text-2xl font-black text-navy" id="resultReference"></h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<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="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">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
|
||||
<div class="flex items-center gap-2">
|
||||
<div id="resultStatusBadge" class="px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider"></div>
|
||||
<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-3.5 h-3.5"></i>
|
||||
{% trans "Escalated" %}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Info Cards -->
|
||||
<div id="resultInfoCards" class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10"></div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<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 id="timelineBox" class="bg-white rounded-2xl shadow-lg p-5 md:p-8">
|
||||
<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">
|
||||
<i data-lucide="list-checks" class="w-5 h-5"></i>
|
||||
</div>
|
||||
@ -306,9 +304,9 @@ header.glass-card {
|
||||
|
||||
<!-- Back Link -->
|
||||
<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">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 inline mr-1"></i>
|
||||
{% trans "Back to Submit Feedback" %}
|
||||
<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"></i>
|
||||
{% trans "Submit New Feedback" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -365,7 +363,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
btn.innerHTML = originalBtn;
|
||||
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);
|
||||
} else {
|
||||
document.getElementById('errorText').textContent = data.error || "{% trans 'No submission found with this reference number.' %}";
|
||||
@ -414,14 +417,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
var cardsHtml = '';
|
||||
(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 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 += '<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-5 h-5 ' + iconColor + ' mb-3"></i>' +
|
||||
'<span class="block text-xs font-bold text-slate/50 uppercase">' + card.label + '</span>' +
|
||||
'<p class="font-bold text-navy truncate">' + card.value + '</p>' +
|
||||
alertDot +
|
||||
'</div>';
|
||||
var iconColor = card.alert ? 'text-rose-500' : 'text-slate/60';
|
||||
cardsHtml += '<span class="inline-flex items-center gap-1.5 ' + (card.alert ? 'text-rose-500 font-bold' : '') + '">' +
|
||||
'<i data-lucide="' + card.icon + '" class="w-3.5 h-3.5 ' + iconColor + '"></i>' +
|
||||
card.value +
|
||||
'</span>';
|
||||
});
|
||||
document.getElementById('resultInfoCards').innerHTML = cardsHtml;
|
||||
|
||||
@ -433,20 +433,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
noTimelineEl.classList.add('hidden');
|
||||
var tlHtml = '';
|
||||
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');
|
||||
tlHtml += '<div class="timeline-item flex gap-6 pb-10 relative">' +
|
||||
var iconBg = item.type === 'resolution' ? 'bg-emerald-100 text-emerald-600' : 'bg-blue-50 text-blue-600';
|
||||
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="w-12 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>' +
|
||||
'<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-5 h-5 md:w-6 md:h-6"></i>' +
|
||||
'</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">' +
|
||||
'<h4 class="font-black text-navy text-lg">' + item.title + '</h4>' +
|
||||
'<time class="text-sm font-medium text-slate/40">' + item.created_at + '</time>' +
|
||||
'<h4 class="font-black text-navy text-base md:text-lg">' + item.title + '</h4>' +
|
||||
'<time class="text-xs md:text-sm font-medium text-slate/40">' + item.created_at + '</time>' +
|
||||
'</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) {
|
||||
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>';
|
||||
});
|
||||
@ -460,31 +463,21 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
var responseBox = document.getElementById('responseBox');
|
||||
var responseContent = document.getElementById('responseContent');
|
||||
var timelineBox = document.getElementById('timelineBox');
|
||||
var resp = data.response || {};
|
||||
if (resp.has_response && (resp.en || resp.ar)) {
|
||||
var rHtml = '<div class="space-y-6">';
|
||||
if (resp.en) {
|
||||
rHtml += '<div class="bg-emerald-50/50 rounded-2xl p-6 border border-emerald-100">' +
|
||||
'<div class="flex items-center gap-2 mb-3">' +
|
||||
'<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>';
|
||||
}
|
||||
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>';
|
||||
var responseText = resp.ar || resp.en;
|
||||
var isRtl = !!resp.ar;
|
||||
var rHtml = '<div class="bg-emerald-50/50 rounded-2xl p-6 border border-emerald-100"' + (isRtl ? ' dir="rtl"' : '') + '>' +
|
||||
'<div class="text-slate-700 leading-relaxed whitespace-pre-line"' + (isRtl ? ' style="text-align: right;"' : '') + '>' + escapeHtml(responseText) + '</div>' +
|
||||
'</div>';
|
||||
responseContent.innerHTML = rHtml;
|
||||
responseBox.classList.remove('hidden');
|
||||
timelineBox.classList.add('hidden');
|
||||
} else {
|
||||
responseContent.innerHTML = '';
|
||||
responseBox.classList.add('hidden');
|
||||
timelineBox.classList.remove('hidden');
|
||||
}
|
||||
|
||||
var satSection = document.getElementById('satisfactionSection');
|
||||
|
||||
@ -262,7 +262,7 @@
|
||||
{% else %}bg-green-100 text-green-700{% endif %}">
|
||||
{{ action.priority }}
|
||||
</span>
|
||||
{% if can_edit %}
|
||||
{% if can_admin %}
|
||||
<form method="post" action="{% url 'feedback:feedback_create_action' feedback.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action_title" value="{{ action.action_en }}">
|
||||
@ -326,6 +326,22 @@
|
||||
|
||||
<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">
|
||||
<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 }}"
|
||||
@ -339,6 +355,7 @@
|
||||
{% trans "Create QI Project" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@ -211,6 +211,18 @@
|
||||
|
||||
{% 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 ===== -->
|
||||
|
||||
<!-- Patients -->
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<h2 class="text-2xl font-bold text-gray-800">
|
||||
{% block page_title %}
|
||||
{% if user.first_name %}
|
||||
{% trans "Good morning" %}, {{ user.first_name }}! ☀️
|
||||
{% trans "Welcome" %}, {{ user.first_name }}
|
||||
{% else %}
|
||||
{% trans "Dashboard" %}
|
||||
{% endif %}
|
||||
@ -24,12 +24,12 @@
|
||||
<!-- Right Side Actions -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 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>
|
||||
<input type="text"
|
||||
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">
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
|
||||
<!-- Notifications -->
|
||||
<div class="relative">
|
||||
|
||||
@ -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-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('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>
|
||||
{% endif %}
|
||||
<button class="py-4 text-sm tab-inactive" onclick="switchTab('notes')" id="tab-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 %}
|
||||
@ -83,7 +84,7 @@
|
||||
<main class="grid grid-cols-12 gap-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">
|
||||
<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>
|
||||
@ -202,46 +203,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</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-department" class="tab-panel hidden">
|
||||
{% if observation.assigned_department %}
|
||||
{% if observation.sent_to_department and observation.assigned_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
|
||||
{% if observation.department_responded_at %}border-blue-200{% elif observation.dept_response_is_overdue %}border-red-200{% else %}border-amber-200{% endif %}">
|
||||
@ -415,8 +377,63 @@
|
||||
</div>
|
||||
</section>
|
||||
{% 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 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">
|
||||
<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">
|
||||
@ -457,10 +474,11 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<h3 class="font-bold text-navy mb-4 text-sm">{% trans "Quick Actions" %}</h3>
|
||||
<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 %}
|
||||
<form method="post" action="{% url 'observations:observation_activate' observation.id %}" class="contents">
|
||||
{% csrf_token %}
|
||||
@ -485,13 +503,19 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
<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>
|
||||
<span class="text-[10px] font-bold uppercase">{% if observation.assigned_to %}{% trans "Reassign" %}{% else %}{% trans "Assign" %}{% endif %}</span>
|
||||
</button>
|
||||
{% 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">
|
||||
<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>
|
||||
@ -519,16 +543,16 @@
|
||||
<span class="text-[10px] font-bold text-red-600 uppercase">{% trans "Escalate" %}</span>
|
||||
</button>
|
||||
{% 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">
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if observation.status == 'resolved' or observation.status == 'closed' %}
|
||||
{% if can_triage %}
|
||||
<form method="post" action="{% url 'observations:observation_reopen' observation.id %}" class="col-span-2 contents">
|
||||
@ -554,6 +578,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if can_convert %}
|
||||
<section id="obsAssignForm" class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hidden">
|
||||
@ -676,6 +701,45 @@
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if observation.status == 'resolved' or observation.status == 'closed' %}
|
||||
<section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
|
||||
<h3 class="font-bold text-navy mb-3 text-sm flex items-center gap-2">
|
||||
<i data-lucide="smile" class="w-4 h-4"></i> {% trans "Satisfaction" %}
|
||||
</h3>
|
||||
{% if observation.satisfaction %}
|
||||
<div class="mb-3">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold
|
||||
{% if observation.satisfaction == 'satisfied' %}bg-green-100 text-green-800 border border-green-300
|
||||
{% elif observation.satisfaction == 'neutral' %}bg-yellow-100 text-yellow-800 border border-yellow-300
|
||||
{% elif observation.satisfaction == 'dissatisfied' %}bg-red-100 text-red-800 border border-red-300
|
||||
{% else %}bg-slate-100 text-slate-600 border border-slate-300{% endif %}">
|
||||
{{ observation.get_satisfaction_display }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'observations:observation_update_satisfaction' observation.pk %}">
|
||||
{% csrf_token %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="submit" name="satisfaction" value="satisfied"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
|
||||
{% if observation.satisfaction == 'satisfied' %}border-green-500 bg-green-50 text-green-700{% else %}border-slate-200 text-slate-600 hover:border-green-400{% endif %}">
|
||||
<i data-lucide="thumbs-up" class="w-3.5 h-3.5"></i> {% trans "Satisfied" %}
|
||||
</button>
|
||||
<button type="submit" name="satisfaction" value="neutral"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
|
||||
{% if observation.satisfaction == 'neutral' %}border-yellow-500 bg-yellow-50 text-yellow-700{% else %}border-slate-200 text-slate-600 hover:border-yellow-400{% endif %}">
|
||||
<i data-lucide="minus-circle" class="w-3.5 h-3.5"></i> {% trans "Neutral" %}
|
||||
</button>
|
||||
<button type="submit" name="satisfaction" value="dissatisfied"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
|
||||
{% if observation.satisfaction == 'dissatisfied' %}border-red-500 bg-red-50 text-red-700{% else %}border-slate-200 text-slate-600 hover:border-red-400{% endif %}">
|
||||
<i data-lucide="thumbs-down" class="w-3.5 h-3.5"></i> {% trans "Dissatisfied" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -714,8 +778,147 @@ function switchObsRecipientType(type) {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
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>
|
||||
|
||||
<!-- 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 -->
|
||||
<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">
|
||||
|
||||
@ -135,7 +135,7 @@
|
||||
</a>
|
||||
</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="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 bg-red-50 rounded-lg flex items-center justify-center">
|
||||
@ -224,6 +224,153 @@
|
||||
</div>
|
||||
{% 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 -->
|
||||
{% 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">
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
{% 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">
|
||||
<i data-lucide="message-square" class="w-5 h-5 text-blue"></i>
|
||||
{% trans "Notes" %}
|
||||
</h3>
|
||||
|
||||
{% 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 %}
|
||||
<input type="hidden" name="content_type_id" value="{{ content_type_id }}">
|
||||
<input type="hidden" name="object_id" value="{{ object_id }}">
|
||||
|
||||
87
templates/projects/my_tasks.html
Normal file
87
templates/projects/my_tasks.html
Normal 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 %}
|
||||
@ -2,7 +2,7 @@
|
||||
<tr id="task-{{ task.pk }}">
|
||||
<!-- Toggle -->
|
||||
<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"
|
||||
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 %}"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user