update on the px-actions
This commit is contained in:
parent
7f19b3283c
commit
5dc61c8e30
41
Makefile
Normal file
41
Makefile
Normal file
@ -0,0 +1,41 @@
|
||||
# PX360 deployment helpers for the `target` tool.
|
||||
#
|
||||
# First time (per environment):
|
||||
# make init-dev # creates deploy/env.dev from the template
|
||||
# $EDITOR deploy/env.dev # fill in SECRET_KEY, API keys, DB password, etc.
|
||||
#
|
||||
# Deploy (builds image, ships the whole .env, runs compose up):
|
||||
# make deploy-dev
|
||||
# make deploy-prod
|
||||
#
|
||||
# Each environment is a single gitignored file (deploy/env.dev / deploy/env.prod)
|
||||
# holding ALL config + secrets. `target deploy` ships it to the server as .env.
|
||||
|
||||
.PHONY: deploy-dev deploy-prod init-dev init-prod check-dev check-prod
|
||||
|
||||
# --- One-time: create the gitignored env file from the committed template ---
|
||||
init-dev:
|
||||
cp -n deploy/env.dev.example deploy/env.dev 2>/dev/null || true
|
||||
@echo "Created deploy/env.dev — edit it and fill in the secrets, then: make deploy-dev"
|
||||
|
||||
init-prod:
|
||||
cp -n deploy/env.prod.example deploy/env.prod 2>/dev/null || true
|
||||
@echo "Created deploy/env.prod — edit it and fill in the secrets, then: make deploy-prod"
|
||||
|
||||
# --- Guards ---
|
||||
check-dev:
|
||||
@if [ ! -f deploy/env.dev ]; then \
|
||||
echo "ERROR: deploy/env.dev not found. Run: make init-dev"; exit 1; \
|
||||
fi
|
||||
|
||||
check-prod:
|
||||
@if [ ! -f deploy/env.prod ]; then \
|
||||
echo "ERROR: deploy/env.prod not found. Run: make init-prod"; exit 1; \
|
||||
fi
|
||||
|
||||
# --- Deploy (builds image, ships .env, runs compose up) ---
|
||||
deploy-dev: check-dev
|
||||
target deploy --config deploy/target.dev.yaml
|
||||
|
||||
deploy-prod: check-prod
|
||||
target deploy --config deploy/target.prod.yaml
|
||||
@ -22,7 +22,7 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **kwargs):
|
||||
# Full SHCT Data Structure: (EN, AR)
|
||||
shct_data = {
|
||||
('CLINICAL', 'سريري'): {
|
||||
('Clinical Care', 'سريري'): {
|
||||
('Quality', 'الجودة'): {
|
||||
('Examination', 'الفحص'): [
|
||||
('Examination not performed', 'لم يتم إجراء الفحص'),
|
||||
@ -87,7 +87,7 @@ class Command(BaseCommand):
|
||||
]
|
||||
}
|
||||
},
|
||||
('MANAGEMENT', 'إداري'): {
|
||||
('Management', 'إداري'): {
|
||||
('Institutional Issues', 'القضايا المؤسسية'): {
|
||||
('Administrative Policies', 'السياسات الإدارية'): [
|
||||
('Paperwork delays', 'تأخير في المعاملات الورقية'),
|
||||
@ -129,7 +129,7 @@ class Command(BaseCommand):
|
||||
],
|
||||
}
|
||||
},
|
||||
('RELATIONSHIPS', 'علاقات'): {
|
||||
('Relationships', 'علاقات'): {
|
||||
('Communication', 'التواصل'): {
|
||||
('Patient-staff communication', 'التواصل بين المريض والموظفين'): [
|
||||
('Miscommunication with Patient', 'سوء فهم مع المريض'),
|
||||
@ -171,12 +171,16 @@ class Command(BaseCommand):
|
||||
total_created = 0
|
||||
|
||||
for (dom_en, dom_ar), categories in shct_data.items():
|
||||
# Level 1: Domain
|
||||
# Level 1: Domain — look up by name_en + level only so existing
|
||||
# records (which may have different Arabic name/domain_type) are
|
||||
# found instead of being duplicated.
|
||||
domain, created = ComplaintCategory.objects.get_or_create(
|
||||
name_en=dom_en,
|
||||
name_ar=dom_ar,
|
||||
level=ComplaintCategory.LevelChoices.DOMAIN,
|
||||
domain_type=dom_en.upper()
|
||||
defaults={
|
||||
"name_ar": dom_ar,
|
||||
"domain_type": dom_en.upper(),
|
||||
},
|
||||
)
|
||||
if created:
|
||||
domains_created += 1
|
||||
|
||||
26
apps/complaints/migrations/0030_complaint_taxonomy_review.py
Normal file
26
apps/complaints/migrations/0030_complaint_taxonomy_review.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-01 13:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0029_investigationstatus_direct_reply'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='taxonomy_reviewed_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='taxonomy_reviewed_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_taxonomy_complaints', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,21 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-01 20:45
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0030_complaint_taxonomy_review'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='satisfaction_set_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='set_satisfaction_complaints', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-01 22:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0031_complaint_satisfaction_set_by'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='ai_response_suggestions',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='ai_response_suggestions_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-01 23:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0032_complaint_ai_response_suggestions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='complaint',
|
||||
name='satisfaction_change_count',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,21 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-04 19:55
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0033_complaint_satisfaction_change_count'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='inquiry',
|
||||
name='satisfaction',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='inquiry',
|
||||
name='satisfaction_set_at',
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,57 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("complaints", "0034_remove_inquiry_observation_satisfaction"),
|
||||
("organizations", "0014_remove_department_manager_1st"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="complaintinvolveddepartment",
|
||||
name="routing_status",
|
||||
field=models.CharField(
|
||||
choices=[("sent", "Sent"), ("accepted", "Accepted"), ("rejected", "Rejected")],
|
||||
default="sent",
|
||||
help_text="Whether the department has accepted or rejected the routing of this complaint",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="complaintinvolveddepartment",
|
||||
name="rejected_at",
|
||||
field=models.DateTimeField(blank=True, help_text="When the champion rejected the routing", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="complaintinvolveddepartment",
|
||||
name="rejected_by_staff",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Staff member (champion/manager) who rejected the routing",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="rejected_dept_involvements",
|
||||
to="organizations.staff",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="complaintinvolveddepartment",
|
||||
name="rejection_reason",
|
||||
field=models.TextField(blank=True, help_text="Reason the department rejected the routing"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="complaintinvolveddepartment",
|
||||
name="suggested_department",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Department the champion suggested as the correct one",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="suggested_for_involvements",
|
||||
to="organizations.department",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -589,12 +589,45 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
)
|
||||
satisfaction_set_at = models.DateTimeField(null=True, blank=True, help_text="When satisfaction was last set")
|
||||
|
||||
# Who set the satisfaction (PX-team only; patient locks via satisfaction_locked_by_patient)
|
||||
satisfaction_set_by = models.ForeignKey(
|
||||
"accounts.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="set_satisfaction_complaints",
|
||||
)
|
||||
|
||||
# Patient lock — once the patient submits satisfaction via the public tracking
|
||||
# page, the value becomes authoritative and can no longer be edited by anyone
|
||||
# (neither patient nor PX-team).
|
||||
satisfaction_locked_by_patient = models.BooleanField(default=False, db_index=True)
|
||||
satisfaction_locked_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Taxonomy review gate — PX-team must review/confirm the AI-generated
|
||||
# classification before the complaint can be sent to the department.
|
||||
# Once set, stays set permanently (taxonomy remains editable afterwards,
|
||||
# but the gate is passed once).
|
||||
taxonomy_reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||
taxonomy_reviewed_by = models.ForeignKey(
|
||||
"accounts.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="reviewed_taxonomy_complaints",
|
||||
)
|
||||
|
||||
# AI response suggestions — persisted bilingual output from the
|
||||
# "Response Suggestions" button in the Resolution tab.
|
||||
ai_response_suggestions = models.JSONField(default=dict, blank=True)
|
||||
ai_response_suggestions_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# PX-team satisfaction change budget — only "satisfied", "neutral", and
|
||||
# "dissatisfied" consume this counter. "no_response" does NOT count.
|
||||
# Patient override is separate (satisfaction_locked_by_patient).
|
||||
satisfaction_change_count = models.IntegerField(default=0)
|
||||
MAX_SATISFACTION_CHANGES = 3
|
||||
|
||||
# External references
|
||||
moh_reference = models.CharField(max_length=100, blank=True, help_text="Ministry of Health reference number")
|
||||
moh_reference_date = models.DateField(null=True, blank=True, help_text="MOH reference date")
|
||||
@ -862,6 +895,22 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
ComplaintStatus.PENDING_EXTERNAL,
|
||||
]
|
||||
|
||||
@property
|
||||
def satisfaction_window_expired(self):
|
||||
"""True if 5+ days have passed since resolution/closure.
|
||||
|
||||
Mirrors the tracking-page expiry in core/views.py.
|
||||
When expired, satisfaction can no longer be set or changed by anyone.
|
||||
"""
|
||||
if not self.resolved_at and not self.closed_at:
|
||||
return False
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
expiry_base = self.resolved_at or self.closed_at
|
||||
return timezone.now() > expiry_base + timedelta(days=5)
|
||||
|
||||
@property
|
||||
def sla_time_remaining(self):
|
||||
if not self.due_at:
|
||||
@ -1834,13 +1883,6 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="responded_inquiries"
|
||||
)
|
||||
|
||||
# Satisfaction
|
||||
satisfaction = models.CharField(
|
||||
max_length=20, blank=True, default="",
|
||||
choices=[("satisfied", "Satisfied"), ("neutral", "Neutral"), ("dissatisfied", "Dissatisfied"), ("no_response", "No Response")],
|
||||
)
|
||||
satisfaction_set_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Metadata (stores AI analysis, form data, etc.)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
@ -2799,6 +2841,37 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel):
|
||||
)
|
||||
manager_reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Routing acceptance / rejection (champion can reject a wrongly-routed complaint)
|
||||
class RoutingStatus(models.TextChoices):
|
||||
SENT = "sent", _("Sent")
|
||||
ACCEPTED = "accepted", _("Accepted")
|
||||
REJECTED = "rejected", _("Rejected")
|
||||
|
||||
routing_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=RoutingStatus.choices,
|
||||
default=RoutingStatus.SENT,
|
||||
help_text="Whether the department has accepted or rejected the routing of this complaint",
|
||||
)
|
||||
rejected_at = models.DateTimeField(null=True, blank=True, help_text="When the champion rejected the routing")
|
||||
rejected_by_staff = models.ForeignKey(
|
||||
"organizations.Staff",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="rejected_dept_involvements",
|
||||
help_text="Staff member (champion/manager) who rejected the routing",
|
||||
)
|
||||
rejection_reason = models.TextField(blank=True, help_text="Reason the department rejected the routing")
|
||||
suggested_department = models.ForeignKey(
|
||||
"organizations.Department",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="suggested_for_involvements",
|
||||
help_text="Department the champion suggested as the correct one",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-is_primary", "-created_at"]
|
||||
verbose_name = "Complaint Involved Department"
|
||||
@ -2839,6 +2912,17 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel):
|
||||
return timedelta(0)
|
||||
return remaining
|
||||
|
||||
@property
|
||||
def can_reject_routing(self):
|
||||
"""Whether the champion can still reject this routing as 'wrong department'."""
|
||||
if self.response_submitted:
|
||||
return False
|
||||
if self.routing_status != self.RoutingStatus.SENT:
|
||||
return False
|
||||
if self.complaint_id and self.complaint.status in ("closed", "cancelled"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class ComplaintInvolvedStaff(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
|
||||
@ -7,7 +7,13 @@ from django.utils import timezone
|
||||
from apps.core.services import AuditService
|
||||
from apps.notifications.services import NotificationService, get_email_header_html
|
||||
from apps.organizations.models import Department
|
||||
from apps.complaints.models import Complaint, ComplaintExplanation, ComplaintStatus, ComplaintUpdate
|
||||
from apps.complaints.models import (
|
||||
Complaint,
|
||||
ComplaintExplanation,
|
||||
ComplaintInvolvedDepartment,
|
||||
ComplaintStatus,
|
||||
ComplaintUpdate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -927,7 +933,19 @@ This is an automated message from PX360 Complaint Management System."""
|
||||
inv_dept.sent = True
|
||||
inv_dept.sent_at = inv_dept.sent_at or now
|
||||
inv_dept.forwarded_at = inv_dept.forwarded_at or now
|
||||
inv_dept.save()
|
||||
inv_dept.save(update_fields=["sent", "sent_at", "forwarded_at"])
|
||||
# If re-sending to a department that previously rejected the routing,
|
||||
# reset the rejection so the champion can act again.
|
||||
if not created and inv_dept.routing_status == ComplaintInvolvedDepartment.RoutingStatus.REJECTED:
|
||||
inv_dept.routing_status = ComplaintInvolvedDepartment.RoutingStatus.SENT
|
||||
inv_dept.rejected_at = None
|
||||
inv_dept.rejected_by_staff = None
|
||||
inv_dept.rejection_reason = ""
|
||||
inv_dept.suggested_department = None
|
||||
inv_dept.save(update_fields=[
|
||||
"routing_status", "rejected_at", "rejected_by_staff",
|
||||
"rejection_reason", "suggested_department",
|
||||
])
|
||||
|
||||
metadata = {
|
||||
"champion_count": champion_count,
|
||||
@ -1045,3 +1063,139 @@ This is an automated message from PX360 Complaint Management System."""
|
||||
content_object=complaint,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
class RoutingRejectionError(Exception):
|
||||
"""Raised when a routing rejection is not allowed."""
|
||||
|
||||
|
||||
def reject_department_routing(involved_dept, *, staff=None, user=None, reason="", suggested_department=None):
|
||||
"""
|
||||
Mark a ComplaintInvolvedDepartment as rejected (wrong department).
|
||||
|
||||
Shared between the no-login token view and the logged-in champion view.
|
||||
- Marks routing_status=REJECTED, sets rejected_at / rejected_by_staff /
|
||||
rejection_reason / suggested_department.
|
||||
- If the rejected department is the complaint's primary, clears
|
||||
complaint.department / complaint.section so PX can reassign.
|
||||
- Creates a ComplaintUpdate timeline entry + an AuditEvent.
|
||||
- Notifies the complaint handler (complaint.assigned_to) and PX admins by email.
|
||||
|
||||
Returns the updated involved_dept.
|
||||
Raises RoutingRejectionError if the routing cannot be rejected.
|
||||
"""
|
||||
if not involved_dept.can_reject_routing:
|
||||
raise RoutingRejectionError("This routing can no longer be rejected.")
|
||||
|
||||
complaint = involved_dept.complaint
|
||||
department = involved_dept.department
|
||||
now = timezone.now()
|
||||
|
||||
involved_dept.routing_status = ComplaintInvolvedDepartment.RoutingStatus.REJECTED
|
||||
involved_dept.rejected_at = now
|
||||
involved_dept.rejected_by_staff = staff
|
||||
involved_dept.rejection_reason = reason
|
||||
involved_dept.suggested_department = suggested_department
|
||||
involved_dept.save(update_fields=[
|
||||
"routing_status", "rejected_at", "rejected_by_staff",
|
||||
"rejection_reason", "suggested_department",
|
||||
])
|
||||
|
||||
was_primary = involved_dept.is_primary or complaint.department_id == department.pk
|
||||
if was_primary and complaint.department_id == department.pk:
|
||||
complaint.department = None
|
||||
complaint.section = None
|
||||
complaint.save(update_fields=["department", "section"])
|
||||
|
||||
actor_label = staff.get_full_name() if staff else (user.get_full_name() if user else "Champion")
|
||||
|
||||
suggested_label = ""
|
||||
if suggested_department is not None:
|
||||
suggested_label = f" Suggested department: {suggested_department.get_localized_name()}."
|
||||
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type="note",
|
||||
message=(
|
||||
f"{department.get_localized_name()} rejected the routing "
|
||||
f"(wrong department). Reason: {reason or 'No reason provided.'}.{suggested_label}"
|
||||
),
|
||||
created_by=user,
|
||||
metadata={
|
||||
"department_id": str(department.id),
|
||||
"staff_id": str(staff.id) if staff else None,
|
||||
"reason": reason,
|
||||
"suggested_department_id": str(suggested_department.id) if suggested_department else None,
|
||||
"was_primary": was_primary,
|
||||
},
|
||||
)
|
||||
|
||||
AuditService.log_event(
|
||||
event_type="dept_routing_rejected",
|
||||
description=(
|
||||
f"{actor_label} rejected routing of complaint {complaint.reference_number} "
|
||||
f"to {department.get_localized_name()} (wrong department)."
|
||||
),
|
||||
user=user,
|
||||
content_object=complaint,
|
||||
metadata={
|
||||
"department_id": str(department.id),
|
||||
"reason": reason,
|
||||
"suggested_department_id": str(suggested_department.id) if suggested_department else None,
|
||||
"was_primary": was_primary,
|
||||
},
|
||||
)
|
||||
|
||||
_notify_routing_rejected(
|
||||
complaint=complaint,
|
||||
department=department,
|
||||
reason=reason,
|
||||
suggested_label=suggested_label,
|
||||
actor_label=actor_label,
|
||||
)
|
||||
|
||||
return involved_dept
|
||||
|
||||
|
||||
def _notify_routing_rejected(*, complaint, department, reason, suggested_label, actor_label):
|
||||
"""Email + in-app notification to the complaint handler and PX admins."""
|
||||
subject = f"Department Rejected Routing - {complaint.reference_number}"
|
||||
plain = (
|
||||
f"{department.get_localized_name()} has rejected the routing of complaint "
|
||||
f"{complaint.reference_number} as it was sent to the wrong department.\n\n"
|
||||
f"Rejected by: {actor_label}\n"
|
||||
f"Reason: {reason or 'No reason provided.'}{suggested_label}\n\n"
|
||||
f"Please review and re-route to the correct department.\n"
|
||||
)
|
||||
html = f"""
|
||||
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
||||
{get_email_header_html()}
|
||||
<div style="padding: 20px;">
|
||||
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Department Rejected Routing</h2>
|
||||
<p style="margin: 0 0 8px 0;"><strong>{department.get_localized_name()}</strong> has rejected the routing of
|
||||
complaint <strong>#{complaint.reference_number}</strong> as it was sent to the wrong department.</p>
|
||||
<p style="margin: 0 0 4px 0;"><strong>Rejected by:</strong> {actor_label}</p>
|
||||
<p style="margin: 0 0 4px 0;"><strong>Reason:</strong> {reason or 'No reason provided.'}{suggested_label}</p>
|
||||
<p style="margin: 12px 0 0 0; color: #6b7280; font-size: 13px;">Please review and re-route to the correct department.</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
recipients = []
|
||||
if complaint.assigned_to and complaint.assigned_to.email:
|
||||
recipients.append(complaint.assigned_to)
|
||||
|
||||
for recipient in recipients:
|
||||
try:
|
||||
NotificationService.send_email(
|
||||
email=recipient.email,
|
||||
subject=subject,
|
||||
message=plain,
|
||||
html_message=html,
|
||||
related_object=complaint,
|
||||
user=recipient,
|
||||
notification_type="dept_routing_rejected",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send routing-rejected notification for complaint %s", complaint.id)
|
||||
|
||||
|
||||
260
apps/complaints/tests_routing_rejection.py
Normal file
260
apps/complaints/tests_routing_rejection.py
Normal file
@ -0,0 +1,260 @@
|
||||
"""
|
||||
Tests for the champion "reject wrong-department routing" feature.
|
||||
|
||||
Covers:
|
||||
- Token-based (no-login) rejection via complaint_explanation_form
|
||||
- Login-based rejection via involved_department_reject_routing
|
||||
- Primary department rejection clears complaint.department
|
||||
- Guards: can't reject twice, can't reject after response submitted
|
||||
- Re-send to a rejected department resets the rejection
|
||||
"""
|
||||
import secrets
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.complaints.models import (
|
||||
Complaint,
|
||||
ComplaintExplanation,
|
||||
ComplaintInvolvedDepartment,
|
||||
ComplaintUpdate,
|
||||
)
|
||||
from apps.complaints.services.complaint_service import (
|
||||
RoutingRejectionError,
|
||||
reject_department_routing,
|
||||
)
|
||||
from apps.organizations.models import Department, Hospital, Staff
|
||||
|
||||
|
||||
class RoutingRejectionBase(TestCase):
|
||||
"""Shared fixtures: hospital, two departments, PX admin, champion, complaint."""
|
||||
|
||||
@classmethod
|
||||
def _make_staff(cls, first_name, last_name, emp_id, hospital, department):
|
||||
return Staff.objects.create(
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
staff_type="admin",
|
||||
job_title="Champion",
|
||||
employee_id=emp_id,
|
||||
hospital=hospital,
|
||||
department=department,
|
||||
status="active",
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.hospital = Hospital.objects.create(name="Test Hosp", code="TH001", status="active")
|
||||
self.dept_a = Department.objects.create(
|
||||
hospital=self.hospital, name="Dept A", name_en="Dept A", code="DA", status="active",
|
||||
)
|
||||
self.dept_b = Department.objects.create(
|
||||
hospital=self.hospital, name="Dept B", name_en="Dept B", code="DB", status="active",
|
||||
)
|
||||
|
||||
# PX admin
|
||||
self.px_admin = User.objects.create_user(
|
||||
email="px@test.com", password="pass123", hospital=self.hospital,
|
||||
)
|
||||
px_group, _ = Group.objects.get_or_create(name="PX Admin")
|
||||
self.px_admin.groups.add(px_group)
|
||||
|
||||
# Champion of dept_a
|
||||
self.champ_user = User.objects.create_user(
|
||||
email="champ@test.com", password="pass123",
|
||||
hospital=self.hospital, department=self.dept_a,
|
||||
)
|
||||
self.champ_staff = self._make_staff(
|
||||
"Champ", "One", "EMP-CHAMP-1", self.hospital, self.dept_a,
|
||||
)
|
||||
self.champ_staff.user = self.champ_user
|
||||
self.champ_staff.save()
|
||||
self.dept_a.champion = self.champ_staff
|
||||
self.dept_a.save(update_fields=["champion"])
|
||||
|
||||
# Complaint routed to dept_a (primary, sent)
|
||||
self.complaint = Complaint.objects.create(
|
||||
hospital=self.hospital,
|
||||
department=self.dept_a,
|
||||
title="Wrong routing test",
|
||||
description="Sent to the wrong department.",
|
||||
created_by=self.px_admin,
|
||||
status="in_progress",
|
||||
)
|
||||
self.involved = ComplaintInvolvedDepartment.objects.create(
|
||||
complaint=self.complaint,
|
||||
department=self.dept_a,
|
||||
role="primary",
|
||||
is_primary=True,
|
||||
sent=True,
|
||||
)
|
||||
|
||||
|
||||
class ModelHelperTests(RoutingRejectionBase):
|
||||
def test_can_reject_routing_initially_true(self):
|
||||
self.assertTrue(self.involved.can_reject_routing)
|
||||
|
||||
def test_cannot_reject_after_response_submitted(self):
|
||||
self.involved.response_submitted = True
|
||||
self.involved.save()
|
||||
self.assertFalse(self.involved.can_reject_routing)
|
||||
|
||||
def test_cannot_reject_twice(self):
|
||||
reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong dept")
|
||||
self.involved.refresh_from_db()
|
||||
self.assertFalse(self.involved.can_reject_routing)
|
||||
with self.assertRaises(RoutingRejectionError):
|
||||
reject_department_routing(self.involved, staff=self.champ_staff, reason="again")
|
||||
|
||||
|
||||
class ServiceRejectTests(RoutingRejectionBase):
|
||||
def test_service_reject_marks_fields(self):
|
||||
reject_department_routing(
|
||||
self.involved,
|
||||
staff=self.champ_staff,
|
||||
reason="not ours",
|
||||
suggested_department=self.dept_b,
|
||||
)
|
||||
self.involved.refresh_from_db()
|
||||
self.assertEqual(self.involved.routing_status, "rejected")
|
||||
self.assertIsNotNone(self.involved.rejected_at)
|
||||
self.assertEqual(self.involved.rejected_by_staff_id, self.champ_staff.id)
|
||||
self.assertEqual(self.involved.rejection_reason, "not ours")
|
||||
self.assertEqual(self.involved.suggested_department_id, self.dept_b.id)
|
||||
|
||||
def test_service_reject_primary_clears_complaint_department(self):
|
||||
reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong")
|
||||
self.complaint.refresh_from_db()
|
||||
self.assertIsNone(self.complaint.department_id)
|
||||
self.assertIsNone(self.complaint.section_id)
|
||||
|
||||
def test_service_reject_secondary_keeps_other_department(self):
|
||||
# Make dept_b a secondary involved department
|
||||
inv_b = ComplaintInvolvedDepartment.objects.create(
|
||||
complaint=self.complaint, department=self.dept_b, role="secondary", sent=True,
|
||||
)
|
||||
reject_department_routing(inv_b, staff=self.champ_staff, reason="wrong")
|
||||
# Primary dept_a must remain untouched
|
||||
self.complaint.refresh_from_db()
|
||||
self.assertEqual(self.complaint.department_id, self.dept_a.id)
|
||||
|
||||
def test_service_reject_creates_update_and_audit(self):
|
||||
reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong dept")
|
||||
self.assertTrue(
|
||||
ComplaintUpdate.objects.filter(
|
||||
complaint=self.complaint, update_type="note",
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class TokenViewRejectTests(RoutingRejectionBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.explanation = ComplaintExplanation.objects.create(
|
||||
complaint=self.complaint,
|
||||
staff=self.champ_staff,
|
||||
token=secrets.token_urlsafe(32),
|
||||
explanation="",
|
||||
is_used=False,
|
||||
)
|
||||
self.url = reverse(
|
||||
"complaints:complaint_explanation_form",
|
||||
kwargs={"complaint_id": self.complaint.id, "token": self.explanation.token},
|
||||
)
|
||||
|
||||
def test_token_reject_success(self):
|
||||
resp = self.client.post(self.url, {
|
||||
"action": "reject_routing",
|
||||
"rejection_reason": "Belongs to another dept",
|
||||
})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTemplateUsed(resp, "complaints/explanation_routing_rejected.html")
|
||||
|
||||
self.involved.refresh_from_db()
|
||||
self.assertEqual(self.involved.routing_status, "rejected")
|
||||
self.assertEqual(self.involved.rejection_reason, "Belongs to another dept")
|
||||
|
||||
# Token consumed
|
||||
self.explanation.refresh_from_db()
|
||||
self.assertTrue(self.explanation.is_used)
|
||||
|
||||
def test_token_reject_requires_reason(self):
|
||||
resp = self.client.post(self.url, {"action": "reject_routing", "rejection_reason": ""})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTemplateUsed(resp, "complaints/explanation_form.html")
|
||||
self.involved.refresh_from_db()
|
||||
self.assertEqual(self.involved.routing_status, "sent")
|
||||
|
||||
def test_token_reject_already_used_token_blocked(self):
|
||||
self.explanation.is_used = True
|
||||
self.explanation.save()
|
||||
resp = self.client.post(self.url, {
|
||||
"action": "reject_routing", "rejection_reason": "x",
|
||||
})
|
||||
# Already-submitted template is rendered when token is used
|
||||
self.assertTemplateUsed(resp, "complaints/explanation_already_submitted.html")
|
||||
|
||||
|
||||
class LoginViewRejectTests(RoutingRejectionBase):
|
||||
def test_login_reject_by_champion(self):
|
||||
self.client.login(username="champ@test.com", password="pass123")
|
||||
url = reverse(
|
||||
"complaints:involved_department_reject_routing", kwargs={"pk": self.involved.pk},
|
||||
)
|
||||
resp = self.client.post(url, {"rejection_reason": "Wrong department"})
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
self.involved.refresh_from_db()
|
||||
self.assertEqual(self.involved.routing_status, "rejected")
|
||||
|
||||
def test_login_reject_requires_permission(self):
|
||||
# A random user with no relation to the department
|
||||
User.objects.create_user(
|
||||
email="other@test.com", password="pass123", hospital=self.hospital,
|
||||
)
|
||||
self.client.login(username="other@test.com", password="pass123")
|
||||
url = reverse(
|
||||
"complaints:involved_department_reject_routing", kwargs={"pk": self.involved.pk},
|
||||
)
|
||||
resp = self.client.post(url, {"rejection_reason": "Wrong department"})
|
||||
self.assertEqual(resp.status_code, 302) # redirected, but not rejected
|
||||
self.involved.refresh_from_db()
|
||||
self.assertEqual(self.involved.routing_status, "sent")
|
||||
|
||||
def test_login_reject_requires_reason(self):
|
||||
self.client.login(username="champ@test.com", password="pass123")
|
||||
url = reverse(
|
||||
"complaints:involved_department_reject_routing", kwargs={"pk": self.involved.pk},
|
||||
)
|
||||
resp = self.client.post(url, {"rejection_reason": ""})
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
self.involved.refresh_from_db()
|
||||
self.assertEqual(self.involved.routing_status, "sent")
|
||||
|
||||
|
||||
class ResendResetTests(RoutingRejectionBase):
|
||||
def test_resend_to_rejected_department_resets_routing(self):
|
||||
reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong")
|
||||
self.involved.refresh_from_db()
|
||||
self.assertEqual(self.involved.routing_status, "rejected")
|
||||
|
||||
# Simulate re-send via the service path
|
||||
from django.utils import timezone
|
||||
|
||||
inv, created = ComplaintInvolvedDepartment.objects.get_or_create(
|
||||
complaint=self.complaint, department=self.dept_a,
|
||||
defaults={"role": "primary", "sent": True, "sent_at": timezone.now()},
|
||||
)
|
||||
self.assertFalse(created)
|
||||
inv.routing_status = ComplaintInvolvedDepartment.RoutingStatus.SENT
|
||||
inv.rejected_at = None
|
||||
inv.rejected_by_staff = None
|
||||
inv.rejection_reason = ""
|
||||
inv.suggested_department = None
|
||||
inv.sent = True
|
||||
inv.sent_at = timezone.now()
|
||||
inv.save()
|
||||
|
||||
inv.refresh_from_db()
|
||||
self.assertEqual(inv.routing_status, "sent")
|
||||
self.assertEqual(inv.rejection_reason, "")
|
||||
@ -639,7 +639,13 @@ def complaint_detail(request, pk):
|
||||
|
||||
hospital_departments = []
|
||||
if complaint.hospital:
|
||||
hospital_departments = Department.objects.filter(hospital=complaint.hospital, status="active").order_by("name")
|
||||
from django.db.models import Q
|
||||
|
||||
hospital_departments = Department.objects.filter(
|
||||
hospital=complaint.hospital, status="active",
|
||||
).filter(
|
||||
Q(champion__isnull=False) | Q(manager__isnull=False)
|
||||
).order_by("name")
|
||||
|
||||
if complaint.is_active_status and not complaint.is_overdue:
|
||||
complaint.check_overdue()
|
||||
@ -651,25 +657,53 @@ def complaint_detail(request, pk):
|
||||
escalation_targets = []
|
||||
default_escalation_target = None
|
||||
|
||||
# Build escalation list from Manager + Deputy Manager of ALL involved departments
|
||||
involved_depts = list(
|
||||
complaint.involved_departments.select_related("department", "department__manager")
|
||||
.exclude(department__isnull=True)
|
||||
)
|
||||
dept_label_map = {}
|
||||
departments_to_check = []
|
||||
if complaint.department:
|
||||
dept = Department.objects.filter(pk=complaint.department_id).first()
|
||||
if dept:
|
||||
for holder in dept.get_role_holders():
|
||||
staff = holder["staff"]
|
||||
has_user = staff.user and staff.user.is_active
|
||||
has_email = bool(staff.email)
|
||||
if has_user or has_email:
|
||||
escalation_targets.append({
|
||||
"staff": staff,
|
||||
"has_user": has_user,
|
||||
"user_id": str(staff.user.id) if has_user else "",
|
||||
"is_manager": False,
|
||||
"is_line_manager": False,
|
||||
"role_label": holder["role_label"],
|
||||
"group": "department_roles",
|
||||
})
|
||||
if escalation_targets:
|
||||
default_escalation_target = str(escalation_targets[0]["staff"].id)
|
||||
departments_to_check.append(complaint.department)
|
||||
dept_label_map[complaint.department.id] = complaint.department.get_localized_name()
|
||||
for idept in involved_depts:
|
||||
if idept.department and idept.department.id not in dept_label_map:
|
||||
departments_to_check.append(idept.department)
|
||||
dept_label_map[idept.department.id] = idept.department.get_localized_name()
|
||||
|
||||
seen_staff_ids = set()
|
||||
for dept in departments_to_check:
|
||||
dept_label = dept_label_map.get(dept.id, dept.name)
|
||||
|
||||
# Deputy Manager (Staff FK)
|
||||
if dept.deputy_manager and dept.deputy_manager.id not in seen_staff_ids:
|
||||
has_user = bool(dept.deputy_manager.user and dept.deputy_manager.user.is_active)
|
||||
if has_user or dept.deputy_manager.email:
|
||||
seen_staff_ids.add(dept.deputy_manager.id)
|
||||
escalation_targets.append({
|
||||
"staff": dept.deputy_manager,
|
||||
"has_user": has_user,
|
||||
"user_id": str(dept.deputy_manager.user.id) if has_user else "",
|
||||
"role_label": _("Deputy Manager"),
|
||||
"group": dept_label,
|
||||
})
|
||||
|
||||
# Primary Manager (User FK — bridge via staff_profile)
|
||||
if dept.manager:
|
||||
manager_staff = getattr(dept.manager, "staff_profile", None)
|
||||
if manager_staff and manager_staff.id not in seen_staff_ids:
|
||||
seen_staff_ids.add(manager_staff.id)
|
||||
escalation_targets.append({
|
||||
"staff": manager_staff,
|
||||
"has_user": True,
|
||||
"user_id": str(dept.manager.id),
|
||||
"role_label": _("Manager"),
|
||||
"group": dept_label,
|
||||
})
|
||||
|
||||
if escalation_targets:
|
||||
default_escalation_target = str(escalation_targets[0]["staff"].id)
|
||||
|
||||
adverse_actions = complaint.adverse_actions.all()
|
||||
|
||||
@ -711,6 +745,7 @@ def complaint_detail(request, pk):
|
||||
"is_active_status": complaint.is_active_status,
|
||||
"workflow_steps": {
|
||||
"activated": complaint.activated_at is not None,
|
||||
"taxonomy_reviewed": complaint.taxonomy_reviewed_at is not None,
|
||||
"sent_to_department": complaint.sent_to_department or complaint.forwarded_to_dept_at is not None,
|
||||
"department_responded": (
|
||||
complaint.explanations.filter(is_used=True).exists()
|
||||
@ -719,6 +754,30 @@ def complaint_detail(request, pk):
|
||||
"resolved": complaint.status in ("resolved", "closed"),
|
||||
"cancelled": complaint.status == "cancelled",
|
||||
},
|
||||
"dept_response_progress": {
|
||||
"responded": complaint.involved_departments.filter(sent=True, response_submitted=True).count(),
|
||||
"total": complaint.involved_departments.filter(sent=True).count(),
|
||||
"pending": list(
|
||||
complaint.involved_departments.filter(
|
||||
sent=True, response_submitted=False
|
||||
).select_related("department").values_list("department__name", flat=True)
|
||||
),
|
||||
"responded_list": [
|
||||
{
|
||||
"name": d.department.get_localized_name() if d.department else "—",
|
||||
"at": d.response_submitted_at,
|
||||
}
|
||||
for d in complaint.involved_departments.filter(
|
||||
sent=True, response_submitted=True
|
||||
).select_related("department")
|
||||
],
|
||||
},
|
||||
"satisfaction_history": ComplaintUpdate.objects.filter(
|
||||
complaint=complaint, message__icontains="satisfaction"
|
||||
).order_by("-created_at")[:5],
|
||||
"satisfaction_changes_remaining": max(
|
||||
0, Complaint.MAX_SATISFACTION_CHANGES - complaint.satisfaction_change_count
|
||||
),
|
||||
"can_collect_feedback": bool(
|
||||
complaint.department
|
||||
and complaint.is_active_status
|
||||
@ -1145,7 +1204,17 @@ def complaint_send_to(request, pk):
|
||||
involved_dept.forwarded_at = now
|
||||
involved_dept.sent = True
|
||||
involved_dept.sent_at = now
|
||||
involved_dept.save()
|
||||
# Reset any prior rejection so the champion can act again
|
||||
involved_dept.routing_status = "sent"
|
||||
involved_dept.rejected_at = None
|
||||
involved_dept.rejected_by_staff = None
|
||||
involved_dept.rejection_reason = ""
|
||||
involved_dept.suggested_department = None
|
||||
involved_dept.save(update_fields=[
|
||||
"forwarded_at", "sent", "sent_at",
|
||||
"routing_status", "rejected_at", "rejected_by_staff",
|
||||
"rejection_reason", "suggested_department",
|
||||
])
|
||||
|
||||
complaint.sent_to_department = True
|
||||
complaint.sent_to_department_at = complaint.sent_to_department_at or now
|
||||
@ -1355,20 +1424,78 @@ def update_satisfaction(request, pk):
|
||||
)
|
||||
return redirect("complaints:complaint_detail", pk=pk)
|
||||
|
||||
if complaint.satisfaction_window_expired:
|
||||
messages.error(
|
||||
request,
|
||||
_("Satisfaction can no longer be set. The 5-day window after resolution has expired."),
|
||||
)
|
||||
return redirect("complaints:complaint_detail", pk=pk)
|
||||
|
||||
satisfaction = request.POST.get("satisfaction", "")
|
||||
valid_choices = ["satisfied", "neutral", "dissatisfied", "no_response"]
|
||||
if satisfaction and satisfaction not in valid_choices:
|
||||
messages.error(request, _("Invalid satisfaction value."))
|
||||
return redirect("complaints:complaint_detail", pk=pk)
|
||||
|
||||
old_satisfaction = complaint.satisfaction
|
||||
|
||||
# Only real satisfaction ratings (satisfied/neutral/dissatisfied) consume
|
||||
# the change budget. "no_response" does NOT count.
|
||||
counts_as_change = satisfaction in ("satisfied", "neutral", "dissatisfied")
|
||||
is_new_rating = satisfaction != old_satisfaction
|
||||
|
||||
if counts_as_change and is_new_rating and complaint.satisfaction_change_count >= Complaint.MAX_SATISFACTION_CHANGES:
|
||||
messages.error(
|
||||
request,
|
||||
_("Satisfaction has been changed {} times and can no longer be modified by PX-team. "
|
||||
"Only the patient can override it via the tracking page.").format(Complaint.MAX_SATISFACTION_CHANGES),
|
||||
)
|
||||
return redirect("complaints:complaint_detail", pk=pk)
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
complaint.satisfaction = satisfaction
|
||||
if satisfaction:
|
||||
complaint.satisfaction_set_at = timezone.now()
|
||||
complaint.satisfaction_set_by = request.user
|
||||
else:
|
||||
complaint.satisfaction_set_at = None
|
||||
complaint.save(update_fields=["satisfaction", "satisfaction_set_at", "updated_at"])
|
||||
complaint.satisfaction_set_by = None
|
||||
|
||||
# Increment counter only for real ratings that represent a change
|
||||
if counts_as_change and is_new_rating:
|
||||
complaint.satisfaction_change_count += 1
|
||||
|
||||
complaint.save(update_fields=[
|
||||
"satisfaction", "satisfaction_set_at", "satisfaction_set_by",
|
||||
"satisfaction_change_count", "updated_at",
|
||||
])
|
||||
|
||||
# Log with transition (from → to)
|
||||
SATISFACTION_LABELS = {
|
||||
"satisfied": _("Satisfied"), "neutral": _("Neutral"),
|
||||
"dissatisfied": _("Dissatisfied"), "no_response": _("No Response"),
|
||||
}
|
||||
if is_new_rating and satisfaction:
|
||||
new_label = SATISFACTION_LABELS.get(satisfaction, satisfaction)
|
||||
if old_satisfaction:
|
||||
old_label = str(SATISFACTION_LABELS.get(old_satisfaction, old_satisfaction))
|
||||
log_msg = f"Satisfaction changed from '{old_label}' to '{new_label}' by {request.user.get_full_name()}"
|
||||
else:
|
||||
log_msg = f"Satisfaction set to '{new_label}' by {request.user.get_full_name()}"
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type="note",
|
||||
message=log_msg,
|
||||
created_by=request.user,
|
||||
)
|
||||
elif not satisfaction and old_satisfaction:
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type="note",
|
||||
message=f"Satisfaction cleared by {request.user.get_full_name()}",
|
||||
created_by=request.user,
|
||||
)
|
||||
|
||||
if satisfaction:
|
||||
messages.success(request, _("Satisfaction updated to: {}").format(complaint.get_satisfaction_display()))
|
||||
@ -1907,11 +2034,10 @@ def complaint_escalate(request, pk):
|
||||
|
||||
escalate_to_name = escalate_to_staff.get_full_name()
|
||||
|
||||
# Mark as escalated and assign to selected user
|
||||
# Mark as escalated — do NOT reassign. The complaint stays with the
|
||||
# PX-team user who activated it. The escalated person is notified via email.
|
||||
complaint.escalated_at = timezone.now()
|
||||
if escalate_to_user:
|
||||
complaint.assigned_to = escalate_to_user
|
||||
complaint.save(update_fields=["escalated_at", "assigned_to"])
|
||||
complaint.save(update_fields=["escalated_at"])
|
||||
|
||||
# Create update with escalation details
|
||||
escalation_message = f"Complaint escalated. Reason: {reason}"
|
||||
@ -1951,7 +2077,7 @@ def complaint_escalate(request, pk):
|
||||
email_subject = request.POST.get("email_subject", f"Complaint Escalated - {complaint.reference_number}")
|
||||
email_body = request.POST.get("email_body", "")
|
||||
|
||||
department_url = f"https://{request.get_host()}/organizations/departments/{complaint.department.pk}/" if complaint.department else ""
|
||||
complaint_url = f"https://{request.get_host()}/organizations/departments/{complaint.department.pk}/complaints/{complaint.pk}/" if complaint.department else ""
|
||||
html_message = f"""
|
||||
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
||||
{get_email_header_html()}
|
||||
@ -1966,7 +2092,7 @@ def complaint_escalate(request, pk):
|
||||
<strong>Status:</strong> {complaint.get_status_display()}</p>
|
||||
<p>Please review and take appropriate action.</p>
|
||||
<p style="margin-top: 16px;">
|
||||
<a href="{department_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">View Department</a>
|
||||
<a href="{complaint_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">View Complaint</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -2011,6 +2137,31 @@ def complaint_activate(request, pk):
|
||||
return redirect("complaints:complaint_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def confirm_taxonomy(request, pk):
|
||||
"""Confirm that the taxonomy classification has been reviewed.
|
||||
|
||||
Sets taxonomy_reviewed_at/by so the workflow stepper advances past
|
||||
the 'Review Taxonomy' step, unblocking 'Send to Department'.
|
||||
The taxonomy itself remains editable afterwards via the Edit pencil.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
|
||||
complaint = get_object_or_404(Complaint, pk=pk)
|
||||
|
||||
if not can_manage_complaint(request.user, complaint):
|
||||
messages.error(request, _("You don't have permission to confirm taxonomy for this complaint."))
|
||||
return redirect("complaints:complaint_detail", pk=pk)
|
||||
|
||||
complaint.taxonomy_reviewed_at = timezone.now()
|
||||
complaint.taxonomy_reviewed_by = request.user
|
||||
complaint.save(update_fields=["taxonomy_reviewed_at", "taxonomy_reviewed_by", "updated_at"])
|
||||
|
||||
messages.success(request, _("Taxonomy confirmed. You can now send to department."))
|
||||
return redirect("complaints:complaint_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
def complaint_export_csv(request):
|
||||
"""Export complaints to CSV"""
|
||||
@ -2580,7 +2731,13 @@ def inquiry_detail(request, pk):
|
||||
# Get departments for the inquiry's hospital
|
||||
hospital_departments = []
|
||||
if inquiry.hospital:
|
||||
hospital_departments = Department.objects.filter(hospital=inquiry.hospital, status="active").order_by("name")
|
||||
from django.db.models import Q
|
||||
|
||||
hospital_departments = Department.objects.filter(
|
||||
hospital=inquiry.hospital, status="active",
|
||||
).filter(
|
||||
Q(champion__isnull=False) | Q(manager__isnull=False)
|
||||
).order_by("name")
|
||||
|
||||
# Status choices for the form
|
||||
status_choices = [
|
||||
@ -2714,6 +2871,19 @@ def inquiry_detail(request, pk):
|
||||
context["notes"] = inquiry.notes.select_related("created_by").all()
|
||||
context["notes_count"] = context["notes"].count()
|
||||
|
||||
# Workflow stepper
|
||||
_iq_steps = [
|
||||
{"label": "Created", "icon": "plus-circle", "done": True},
|
||||
{"label": "Assigned", "icon": "user-plus", "done": inquiry.assigned_at is not None},
|
||||
{"label": "Sent to Dept", "icon": "send", "done": inquiry.sent_to_department_at is not None},
|
||||
{"label": "Dept Response", "icon": "message-square", "done": inquiry.department_responded_at is not None},
|
||||
{"label": "Resolved", "icon": "check-circle-2", "done": inquiry.status in ("resolved", "closed")},
|
||||
]
|
||||
_iq_next = next((i for i, s in enumerate(_iq_steps) if not s["done"]), len(_iq_steps))
|
||||
if _iq_next < len(_iq_steps):
|
||||
_iq_steps[_iq_next]["current"] = True
|
||||
context["workflow_steps"] = _iq_steps
|
||||
|
||||
return render(request, "complaints/inquiry_detail.html", context)
|
||||
|
||||
|
||||
@ -3392,20 +3562,6 @@ def inquiry_respond(request, pk):
|
||||
return redirect("inquiries:inquiry_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def inquiry_update_satisfaction(request, pk):
|
||||
"""Update inquiry satisfaction."""
|
||||
inquiry = get_object_or_404(Inquiry, pk=pk)
|
||||
satisfaction = request.POST.get("satisfaction", "").strip()
|
||||
if satisfaction in ("satisfied", "neutral", "dissatisfied", "no_response"):
|
||||
inquiry.satisfaction = satisfaction
|
||||
inquiry.satisfaction_set_at = timezone.now()
|
||||
inquiry.save(update_fields=["satisfaction", "satisfaction_set_at"])
|
||||
messages.success(request, _("Satisfaction updated."))
|
||||
return redirect("inquiries:inquiry_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def inquiry_transfer_to_department(request, pk):
|
||||
@ -4502,6 +4658,23 @@ def public_complaint_submit(request):
|
||||
)
|
||||
|
||||
|
||||
def public_complaint_pdf(request, reference_number):
|
||||
"""Serve the latest PDF report for a complaint (no auth — scanned from QR code).
|
||||
|
||||
Finds the most recent ComplaintPdfSummary for the complaint and serves the
|
||||
saved PDF file inline so the browser displays it directly.
|
||||
"""
|
||||
from django.http import Http404, HttpResponse
|
||||
|
||||
complaint = get_object_or_404(Complaint, reference_number__iexact=reference_number)
|
||||
summary = complaint.pdf_summaries.order_by("-created_at").first()
|
||||
if not summary or not summary.file:
|
||||
raise Http404("No PDF report available for this complaint.")
|
||||
response = HttpResponse(summary.file, content_type="application/pdf")
|
||||
response["Content-Disposition"] = f'inline; filename="{complaint.reference_number}.pdf"'
|
||||
return response
|
||||
|
||||
|
||||
def public_complaint_track(request):
|
||||
"""
|
||||
Public complaint tracking page.
|
||||
@ -6114,6 +6287,73 @@ def involved_department_review_response(request, pk):
|
||||
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def involved_department_reject_routing(request, pk):
|
||||
"""
|
||||
Champion / department manager rejects the routing of a complaint to their
|
||||
department as 'wrong department'. Only the involved department's routing is
|
||||
affected; if it was the primary department, complaint.department is cleared.
|
||||
"""
|
||||
from .models import ComplaintInvolvedDepartment
|
||||
from apps.complaints.services.complaint_service import (
|
||||
reject_department_routing,
|
||||
RoutingRejectionError,
|
||||
)
|
||||
from apps.organizations.models import Department
|
||||
|
||||
involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk)
|
||||
complaint = involved_dept.complaint
|
||||
department = involved_dept.department
|
||||
user = request.user
|
||||
|
||||
# Permission: champion or manager of this department, the assigned handler,
|
||||
# or anyone with complaint management rights.
|
||||
is_dept_champion = user.is_champion() and user.department == department
|
||||
is_dept_manager = user.is_department_manager() and user.department == department
|
||||
can_act = (
|
||||
is_dept_champion
|
||||
or is_dept_manager
|
||||
or involved_dept.assigned_to == user
|
||||
or can_manage_complaint(user, complaint)
|
||||
)
|
||||
if not can_act:
|
||||
messages.error(request, _("You don't have permission to reject this routing."))
|
||||
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
||||
|
||||
reason = request.POST.get("rejection_reason", "").strip()
|
||||
suggested_id = request.POST.get("suggested_department_id", "").strip()
|
||||
suggested = None
|
||||
if suggested_id:
|
||||
suggested = Department.objects.filter(id=suggested_id, hospital=complaint.hospital).first()
|
||||
|
||||
if not reason:
|
||||
messages.error(request, _("Please provide a reason for rejecting this routing."))
|
||||
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
||||
|
||||
staff = getattr(user, "staff_profile", None)
|
||||
try:
|
||||
reject_department_routing(
|
||||
involved_dept,
|
||||
staff=staff,
|
||||
user=user,
|
||||
reason=reason,
|
||||
suggested_department=suggested,
|
||||
)
|
||||
except RoutingRejectionError as exc:
|
||||
messages.error(
|
||||
request,
|
||||
str(exc) if str(exc) else _("This routing can no longer be rejected."),
|
||||
)
|
||||
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
_(f"Routing to {department.get_localized_name()} rejected. The PX team has been notified."),
|
||||
)
|
||||
return redirect("organizations:department_detail", pk=department.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def involved_staff_add(request, complaint_pk):
|
||||
|
||||
@ -15,6 +15,7 @@ from .views import (
|
||||
staff_investigation_form,
|
||||
champion_review_answers,
|
||||
generate_complaint_pdf,
|
||||
generate_complaint_pdf_v2,
|
||||
inquiry_pdf,
|
||||
api_locations,
|
||||
api_sections,
|
||||
@ -60,6 +61,7 @@ urlpatterns = [
|
||||
path("<uuid:pk>/add-note/", ui_views.complaint_add_note, name="complaint_add_note"),
|
||||
path("<uuid:pk>/escalate/", ui_views.complaint_escalate, name="complaint_escalate"),
|
||||
path("<uuid:pk>/activate/", ui_views.complaint_activate, name="complaint_activate"),
|
||||
path("<uuid:pk>/confirm-taxonomy/", ui_views.confirm_taxonomy, name="confirm_taxonomy"),
|
||||
# Export Views
|
||||
path("export/csv/", ui_views.complaint_export_csv, name="complaint_export_csv"),
|
||||
path("export/excel/", ui_views.complaint_export_excel, name="complaint_export_excel"),
|
||||
@ -125,6 +127,7 @@ urlpatterns = [
|
||||
# Public Complaint Form (No Authentication Required)
|
||||
path("public/submit/", ui_views.public_complaint_submit, name="public_complaint_submit"),
|
||||
path("public/track/", ui_views.public_complaint_track, name="public_complaint_track"),
|
||||
path("public/pdf/<str:reference_number>/", ui_views.public_complaint_pdf, name="public_complaint_pdf"),
|
||||
path("public/success/<str:reference>/", ui_views.public_complaint_success, name="public_complaint_success"),
|
||||
path("public/api/lookup-patient/", ui_views.api_lookup_patient, name="api_lookup_patient"),
|
||||
path("public/api/load-departments/", ui_views.api_load_departments, name="api_load_departments"),
|
||||
@ -166,6 +169,7 @@ urlpatterns = [
|
||||
),
|
||||
# PDF Export
|
||||
path("<uuid:pk>/pdf/", generate_complaint_pdf, name="complaint_pdf"),
|
||||
path("<uuid:pk>/pdf-v2/", generate_complaint_pdf_v2, name="complaint_pdf_v2"),
|
||||
path("inquiries/<uuid:pk>/pdf/", inquiry_pdf, name="inquiry_pdf"),
|
||||
# Involved Departments Management
|
||||
path("<uuid:complaint_pk>/departments/add/", ui_views.involved_department_add, name="involved_department_add"),
|
||||
@ -178,6 +182,7 @@ urlpatterns = [
|
||||
path("departments/<uuid:pk>/remove/", ui_views.involved_department_remove, name="involved_department_remove"),
|
||||
path("departments/<uuid:pk>/response/", ui_views.involved_department_response, name="involved_department_response"),
|
||||
path("departments/<uuid:pk>/review-response/", ui_views.involved_department_review_response, name="involved_department_review_response"),
|
||||
path("departments/<uuid:pk>/reject-routing/", ui_views.involved_department_reject_routing, name="involved_department_reject_routing"),
|
||||
# Unified Send To (Person or Department) - AJAX
|
||||
path("<uuid:pk>/send-to/", ui_views.complaint_send_to, name="complaint_send_to"),
|
||||
# Collect Feedback (champion/manager compose questions for staff)
|
||||
|
||||
@ -17,7 +17,6 @@ urlpatterns = [
|
||||
path("<uuid:pk>/reopen/", ui_views.inquiry_reopen, name="inquiry_reopen"),
|
||||
path("<uuid:pk>/add-note/", ui_views.inquiry_add_note, name="inquiry_add_note"),
|
||||
path("<uuid:pk>/respond/", ui_views.inquiry_respond, name="inquiry_respond"),
|
||||
path("<uuid:pk>/update-satisfaction/", ui_views.inquiry_update_satisfaction, name="inquiry_update_satisfaction"),
|
||||
path("<uuid:pk>/transfer-to-department/", ui_views.inquiry_transfer_to_department, name="inquiry_transfer_to_department"),
|
||||
path("<uuid:pk>/department-response/", ui_views.inquiry_department_response, name="inquiry_department_response"),
|
||||
path("<uuid:pk>/review-dept-response/", ui_views.inquiry_review_dept_response, name="inquiry_review_dept_response"),
|
||||
|
||||
@ -1945,6 +1945,7 @@ The matter has been addressed through appropriate channels. Appropriate measures
|
||||
|
||||
try:
|
||||
from apps.core.ai_service import AIService
|
||||
from django.utils import timezone
|
||||
import json
|
||||
|
||||
explanations_text = ""
|
||||
@ -1952,7 +1953,7 @@ The matter has been addressed through appropriate channels. Appropriate measures
|
||||
name = exp.staff.get_full_name() if exp.staff else "Staff"
|
||||
explanations_text += f"\n- {name}: {exp.explanation[:500]}"
|
||||
|
||||
prompt = f"""You are a patient experience resolution advisor. Based on this complaint, provide actionable suggestions for resolving it.
|
||||
prompt = f"""You are a patient experience resolution advisor. Based on this complaint, provide actionable suggestions for responding to the patient.
|
||||
|
||||
Complaint: {complaint.description[:1500]}
|
||||
Severity: {complaint.get_severity_display()}
|
||||
@ -1961,10 +1962,20 @@ Priority: {complaint.get_priority_display()}
|
||||
Status: {complaint.get_status_display()}
|
||||
Staff explanations received: {explanations_text or 'None yet'}
|
||||
|
||||
Generate a JSON response with:
|
||||
- "suggestions": array of 3-5 actionable suggestions, each with "title" and "description"
|
||||
- "recommended_actions": array of immediate next steps
|
||||
- "communication_tip": a tip for communicating with the patient"""
|
||||
IMPORTANT: ALL text fields must be provided in BOTH English and Arabic.
|
||||
|
||||
Generate a JSON response with this exact structure:
|
||||
{{
|
||||
"suggestions": [
|
||||
{{"title_en": "Short title", "title_ar": "عنوان قصير", "description_en": "Description", "description_ar": "الوصف"}}
|
||||
],
|
||||
"recommended_actions_en": ["Action 1", "Action 2"],
|
||||
"recommended_actions_ar": ["الإجراء 1", "الإجراء 2"],
|
||||
"communication_tip_en": "Tip text",
|
||||
"communication_tip_ar": "نصيحة"
|
||||
}}
|
||||
|
||||
Provide 3-5 suggestions. Keep them concise and focused on how to respond to the patient."""
|
||||
|
||||
result = AIService.chat_completion(
|
||||
prompt=prompt,
|
||||
@ -1972,7 +1983,12 @@ Generate a JSON response with:
|
||||
)
|
||||
parsed = json.loads(result)
|
||||
|
||||
return Response({"success": True, "suggestions": parsed})
|
||||
# Persist to complaint
|
||||
complaint.ai_response_suggestions = parsed
|
||||
complaint.ai_response_suggestions_at = timezone.now()
|
||||
complaint.save(update_fields=["ai_response_suggestions", "ai_response_suggestions_at", "updated_at"])
|
||||
|
||||
return Response({"success": True})
|
||||
except Exception as e:
|
||||
logger.error(f"AI helper suggestion failed: {e}")
|
||||
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
@ -2221,12 +2237,54 @@ Generate JSON with exactly these fields:
|
||||
|
||||
import io
|
||||
from PIL import Image as PILImage
|
||||
|
||||
def _img_to_data_uri(path):
|
||||
try:
|
||||
img = PILImage.open(path)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG", optimize=True)
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
_logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png")
|
||||
_logo_img.thumbnail((600, 600), PILImage.LANCZOS)
|
||||
_logo_buf = io.BytesIO()
|
||||
_logo_img.save(_logo_buf, format="PNG", optimize=True)
|
||||
logo_path = "data:image/png;base64," + base64.b64encode(_logo_buf.getvalue()).decode()
|
||||
|
||||
# Per-hospital letterhead + stamp (with static fallback)
|
||||
letterhead_path = None
|
||||
if complaint.hospital and complaint.hospital.letterhead:
|
||||
letterhead_path = _img_to_data_uri(complaint.hospital.letterhead.path)
|
||||
if not letterhead_path:
|
||||
letterhead_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "artboard" / "Artboard 1@3x.png")
|
||||
|
||||
stamp_path = None
|
||||
if complaint.hospital and complaint.hospital.stamp:
|
||||
stamp_path = _img_to_data_uri(complaint.hospital.stamp.path)
|
||||
if not stamp_path:
|
||||
stamp_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "stamps" / "stamp.png")
|
||||
|
||||
# QR code encoding the public tracking URL for authenticity verification
|
||||
qr_code_path = None
|
||||
try:
|
||||
import qrcode
|
||||
from django.urls import reverse
|
||||
|
||||
pdf_url = request.build_absolute_uri(
|
||||
reverse("complaints:public_complaint_pdf", kwargs={"reference_number": complaint.reference_number})
|
||||
)
|
||||
qr = qrcode.QRCode(version=1, box_size=4, border=1)
|
||||
qr.add_data(pdf_url)
|
||||
qr.make(fit=True)
|
||||
qr_img = qr.make_image(fill_color="black", back_color="white")
|
||||
qr_buf = io.BytesIO()
|
||||
qr_img.save(qr_buf, format="PNG")
|
||||
qr_code_path = "data:image/png;base64," + base64.b64encode(qr_buf.getvalue()).decode()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
html_string = render_to_string(
|
||||
"complaints/complaint_summary_pdf.html",
|
||||
{
|
||||
@ -2235,6 +2293,9 @@ Generate JSON with exactly these fields:
|
||||
"content_summary": content_summary,
|
||||
"dept_response_summary": dept_response_summary,
|
||||
"logo_path": logo_path,
|
||||
"letterhead_path": letterhead_path,
|
||||
"stamp_path": stamp_path,
|
||||
"qr_code_path": qr_code_path,
|
||||
"hospital_name_en": complaint.hospital.get_display_name() if complaint.hospital else "",
|
||||
"hospital_address": complaint.hospital.address if complaint.hospital else "",
|
||||
"hospital_phone": complaint.hospital.phone if complaint.hospital else "",
|
||||
@ -3816,6 +3877,18 @@ def complaint_review_pdf(request, complaint_id, token):
|
||||
return response
|
||||
|
||||
|
||||
def _hospital_departments(complaint):
|
||||
"""Active departments in the complaint's hospital (for the suggest-dept dropdown)."""
|
||||
from apps.organizations.models import Department
|
||||
from django.db.models import Q
|
||||
|
||||
if not complaint.hospital:
|
||||
return Department.objects.none()
|
||||
return Department.objects.filter(
|
||||
hospital=complaint.hospital, status="active",
|
||||
).filter(Q(champion__isnull=False) | Q(manager__isnull=False)).order_by("name")
|
||||
|
||||
|
||||
def complaint_explanation_form(request, complaint_id, token):
|
||||
"""
|
||||
Public-facing form for staff to submit explanation.
|
||||
@ -3889,6 +3962,7 @@ def complaint_explanation_form(request, complaint_id, token):
|
||||
"token": explanation.token,
|
||||
})
|
||||
),
|
||||
"hospital_departments": _hospital_departments(complaint),
|
||||
}
|
||||
|
||||
# --- Step 1: Send verification code ---
|
||||
@ -3975,6 +4049,19 @@ def complaint_explanation_form(request, complaint_id, token):
|
||||
"otp_email": "email" in sent_channels,
|
||||
})
|
||||
|
||||
# --- Cancel OTP: user clicked "Edit response" — re-enable the form ---
|
||||
if action == "cancel_otp":
|
||||
investigation = ChampionInvestigation.objects.filter(
|
||||
explanation=explanation,
|
||||
status=InvestigationStatus.DIRECT_REPLY_IN_PROGRESS,
|
||||
).first()
|
||||
if investigation:
|
||||
investigation.otp_code = ""
|
||||
investigation.otp_sent_at = None
|
||||
investigation.save(update_fields=["otp_code", "otp_sent_at"])
|
||||
# Re-render with otp_sent=False; base_ctx preserves all form values
|
||||
return render(request, "complaints/explanation_form.html", {**base_ctx})
|
||||
|
||||
# --- Step 2: Verify code + submit ---
|
||||
if action == "verify_submit":
|
||||
entered_code = request.POST.get("otp_code", "").strip()
|
||||
@ -4047,11 +4134,14 @@ def complaint_explanation_form(request, complaint_id, token):
|
||||
file_size=uploaded_file.size,
|
||||
)
|
||||
|
||||
# Mark ALL other explanations for this complaint as used (first responder wins)
|
||||
ComplaintExplanation.objects.filter(
|
||||
complaint=complaint,
|
||||
is_used=False,
|
||||
).update(is_used=True)
|
||||
# First-responder-wins WITHIN the same department only.
|
||||
# Other departments' explanations stay valid (parallel collection).
|
||||
if explanation.staff and explanation.staff.department_id:
|
||||
ComplaintExplanation.objects.filter(
|
||||
complaint=complaint,
|
||||
is_used=False,
|
||||
staff__department_id=explanation.staff.department_id,
|
||||
).exclude(pk=explanation.pk).update(is_used=True)
|
||||
|
||||
# Update the linked ComplaintInvolvedDepartment
|
||||
from apps.complaints.models import ComplaintInvolvedDepartment
|
||||
@ -4134,6 +4224,69 @@ def complaint_explanation_form(request, complaint_id, token):
|
||||
{"complaint": complaint, "explanation": explanation, "attachment_count": len(files)},
|
||||
)
|
||||
|
||||
# --- Reject routing (wrong department) ---
|
||||
if action == "reject_routing":
|
||||
from apps.complaints.services.complaint_service import (
|
||||
reject_department_routing,
|
||||
RoutingRejectionError,
|
||||
)
|
||||
from apps.complaints.models import ComplaintInvolvedDepartment
|
||||
from apps.organizations.models import Department
|
||||
|
||||
reason = request.POST.get("rejection_reason", "").strip()
|
||||
suggested_id = request.POST.get("suggested_department_id", "").strip()
|
||||
suggested = None
|
||||
if suggested_id:
|
||||
suggested = Department.objects.filter(id=suggested_id, hospital=complaint.hospital).first()
|
||||
|
||||
if not reason:
|
||||
return render(request, "complaints/explanation_form.html", {
|
||||
**base_ctx,
|
||||
"show_reject_form": True,
|
||||
"error": _("Please provide a reason for rejecting this routing."),
|
||||
})
|
||||
|
||||
# Resolve the involved department this champion belongs to
|
||||
involved_dept = None
|
||||
if explanation.staff and explanation.staff.department_id:
|
||||
involved_dept = ComplaintInvolvedDepartment.objects.filter(
|
||||
complaint=complaint, department_id=explanation.staff.department_id,
|
||||
).first()
|
||||
if not involved_dept:
|
||||
involved_dept = ComplaintInvolvedDepartment.objects.filter(
|
||||
complaint=complaint, sent=True,
|
||||
).first()
|
||||
|
||||
if not involved_dept:
|
||||
return render(request, "complaints/explanation_form.html", {
|
||||
**base_ctx, "show_reject_form": True,
|
||||
"error": _("Could not find the department routing to reject."),
|
||||
})
|
||||
|
||||
try:
|
||||
reject_department_routing(
|
||||
involved_dept,
|
||||
staff=explanation.staff,
|
||||
reason=reason,
|
||||
suggested_department=suggested,
|
||||
)
|
||||
except RoutingRejectionError as exc:
|
||||
return render(request, "complaints/explanation_form.html", {
|
||||
**base_ctx, "show_reject_form": True,
|
||||
"error": str(exc) if str(exc) else _("This routing can no longer be rejected."),
|
||||
})
|
||||
|
||||
# Consume the token so it can't be reused
|
||||
explanation.is_used = True
|
||||
explanation.responded_at = timezone.now()
|
||||
explanation.save(update_fields=["is_used", "responded_at"])
|
||||
|
||||
return render(
|
||||
request,
|
||||
"complaints/explanation_routing_rejected.html",
|
||||
{"complaint": complaint, "explanation": explanation, "involved_dept": involved_dept},
|
||||
)
|
||||
|
||||
# Unknown / missing action — show form with error
|
||||
return render(request, "complaints/explanation_form.html", {
|
||||
**base_ctx,
|
||||
@ -4178,6 +4331,7 @@ def complaint_explanation_form(request, complaint_id, token):
|
||||
"accused_staff": accused_staff,
|
||||
"existing_investigation": existing_investigation,
|
||||
"otp_sent": otp_sent_on_get,
|
||||
"hospital_departments": _hospital_departments(complaint),
|
||||
"investigate_url": request.build_absolute_uri(
|
||||
reverse("complaints:champion_start_investigation", kwargs={
|
||||
"complaint_id": complaint.id,
|
||||
@ -4498,6 +4652,8 @@ def staff_search_for_investigation(request, complaint_id, token):
|
||||
| Q(name__icontains=q)
|
||||
| Q(name_ar__icontains=q)
|
||||
| Q(employee_id__icontains=q)
|
||||
| Q(civil_id__icontains=q)
|
||||
| Q(license_number__icontains=q)
|
||||
)
|
||||
.select_related("department")[:20]
|
||||
)
|
||||
@ -4507,6 +4663,7 @@ def staff_search_for_investigation(request, complaint_id, token):
|
||||
"id": str(s.id),
|
||||
"name": s.get_full_name(),
|
||||
"department_name": s.department.get_localized_name() if s.department else "",
|
||||
"employee_id": s.employee_id or "",
|
||||
"already_added": s.id in already_linked,
|
||||
}
|
||||
for s in qs
|
||||
@ -4810,6 +4967,15 @@ def champion_review_answers(request, complaint_id, token):
|
||||
"otp_email": "email" in sent_channels,
|
||||
})
|
||||
|
||||
# --- Cancel OTP: user clicked "Edit response" — re-enable the form ---
|
||||
if action == "cancel_otp":
|
||||
investigation.otp_code = ""
|
||||
investigation.otp_sent_at = None
|
||||
investigation.save(update_fields=["otp_code", "otp_sent_at"])
|
||||
return render(request, "complaints/investigation_review.html", {
|
||||
**base_ctx, "final_reply": final_reply, "consent_checked": True,
|
||||
})
|
||||
|
||||
# --- Step 2: Verify code + submit ---
|
||||
if action == "verify_submit":
|
||||
entered_code = request.POST.get("otp_code", "").strip()
|
||||
@ -4874,10 +5040,14 @@ def champion_review_answers(request, complaint_id, token):
|
||||
file_size=f.size,
|
||||
)
|
||||
|
||||
ComplaintExplanation.objects.filter(
|
||||
complaint=complaint,
|
||||
is_used=False,
|
||||
).update(is_used=True)
|
||||
# First-responder-wins WITHIN the same department only.
|
||||
# Other departments' explanations stay valid (parallel collection).
|
||||
if explanation.staff and explanation.staff.department_id:
|
||||
ComplaintExplanation.objects.filter(
|
||||
complaint=complaint,
|
||||
is_used=False,
|
||||
staff__department_id=explanation.staff.department_id,
|
||||
).exclude(pk=explanation.pk).update(is_used=True)
|
||||
|
||||
involved_dept = investigation.involved_department or explanation.linked_involved_department
|
||||
if involved_dept:
|
||||
@ -5069,6 +5239,112 @@ def generate_complaint_pdf(request, pk):
|
||||
return HttpResponse(f"Error generating PDF: {str(e)}", status=500)
|
||||
|
||||
|
||||
def generate_complaint_pdf_v2(request, pk):
|
||||
"""Generate complaint PDF using the Artboard 1 PNG letterhead (v2)."""
|
||||
complaint = get_object_or_404(Complaint, id=pk)
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponse("Unauthorized", status=401)
|
||||
|
||||
if not (
|
||||
request.user.is_px_admin()
|
||||
or (request.user.is_hospital_admin() and request.user.hospital == complaint.hospital)
|
||||
or (request.user.is_department_manager() and request.user.department == complaint.department)
|
||||
or (request.user.hospital == complaint.hospital)
|
||||
):
|
||||
return HttpResponse("Forbidden", status=403)
|
||||
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
import io
|
||||
import base64
|
||||
from PIL import Image as PILImage
|
||||
|
||||
logo_path = None
|
||||
try:
|
||||
logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png")
|
||||
logo_img.thumbnail((600, 600), PILImage.LANCZOS)
|
||||
_buf = io.BytesIO()
|
||||
logo_img.save(_buf, format="PNG", optimize=True)
|
||||
logo_path = "data:image/png;base64," + base64.b64encode(_buf.getvalue()).decode()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _img_to_data_uri(path):
|
||||
"""Open an image file and return it as a base64 data URI."""
|
||||
try:
|
||||
img = PILImage.open(path)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG", optimize=True)
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Letterhead: hospital-specific upload → static fallback
|
||||
letterhead_path = None
|
||||
if complaint.hospital and complaint.hospital.letterhead:
|
||||
letterhead_path = _img_to_data_uri(complaint.hospital.letterhead.path)
|
||||
if not letterhead_path:
|
||||
letterhead_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "artboard" / "Artboard 1@3x.png")
|
||||
|
||||
# Stamp: hospital-specific upload → static fallback
|
||||
stamp_path = None
|
||||
if complaint.hospital and complaint.hospital.stamp:
|
||||
stamp_path = _img_to_data_uri(complaint.hospital.stamp.path)
|
||||
if not stamp_path:
|
||||
stamp_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "stamps" / "stamp.png")
|
||||
|
||||
explanations = complaint.explanations.all().select_related("staff", "accepted_by").prefetch_related("attachments")
|
||||
timeline = complaint.updates.all().select_related("created_by")[:20]
|
||||
|
||||
from apps.px_action_center.models import PXAction
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
complaint_ct = ContentType.objects.get_for_model(Complaint)
|
||||
px_actions = PXAction.objects.filter(content_type=complaint_ct, object_id=complaint.id).order_by("-created_at")[:5]
|
||||
|
||||
html_string = render_to_string(
|
||||
"complaints/complaint_pdf_v2.html",
|
||||
{
|
||||
"complaint": complaint,
|
||||
"explanations": explanations,
|
||||
"timeline": timeline,
|
||||
"px_actions": px_actions,
|
||||
"generated_at": timezone.now(),
|
||||
"logo_path": logo_path,
|
||||
"letterhead_path": letterhead_path,
|
||||
"stamp_path": stamp_path,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
pdf_file = HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf()
|
||||
|
||||
response = HttpResponse(pdf_file, content_type="application/pdf")
|
||||
response["X-Frame-Options"] = "SAMEORIGIN"
|
||||
|
||||
view_mode = request.GET.get("view", "download")
|
||||
if view_mode == "inline":
|
||||
response["Content-Disposition"] = "inline"
|
||||
else:
|
||||
from datetime import datetime
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"complaint_{complaint.reference_number}_{timestamp}.pdf"
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
|
||||
return response
|
||||
except ImportError:
|
||||
return HttpResponse("WeasyPrint is not installed.", status=500)
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).error(f"Error generating v2 PDF for complaint {pk}: {e}")
|
||||
return HttpResponse(f"Error generating PDF: {str(e)}", status=500)
|
||||
|
||||
|
||||
def inquiry_explanation_form(request, inquiry_id, token):
|
||||
"""
|
||||
Public-facing form for staff to submit response to an inquiry.
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test-only helper: seed a QI project in E2E-HOSP with PDCA+FOCUS phases,
|
||||
team members from 2 different departments, and tasks assigned to them.
|
||||
two linked departments (whose champion + manager form the cross-dept team),
|
||||
and tasks assigned to those team members.
|
||||
|
||||
Usage:
|
||||
manage.py seed_e2e_project
|
||||
@ -24,7 +25,7 @@ class Command(BaseCommand):
|
||||
|
||||
e2e = Hospital.objects.get(code="E2E-HOSP")
|
||||
depts = list(Department.objects.filter(hospital=e2e))
|
||||
dept_a = depts[0] # Contact Center (champion's dept)
|
||||
dept_a = depts[0] # Contact Center (staff_a's dept)
|
||||
dept_b = depts[1] if len(depts) > 1 else depts[0] # a different dept
|
||||
|
||||
# Ensure Staff profiles exist for e2e-staff (dept_a) and e2e-nurse (dept_b)
|
||||
@ -49,16 +50,24 @@ class Command(BaseCommand):
|
||||
if staff_b.department_id != dept_b.id:
|
||||
staff_b.department = dept_b; staff_b.save(update_fields=["department"])
|
||||
|
||||
# Make staff_a the champion of dept_a and staff_b the champion of dept_b,
|
||||
# so they become derived team members of the project (ACL requires it).
|
||||
if dept_a.champion_id != staff_a.id:
|
||||
dept_a.champion = staff_a; dept_a.save(update_fields=["champion"])
|
||||
if dept_b.champion_id != staff_b.id:
|
||||
dept_b.champion = staff_b; dept_b.save(update_fields=["champion"])
|
||||
|
||||
n = QIProject.objects.count()
|
||||
project = QIProject.objects.create(
|
||||
hospital=e2e,
|
||||
department=dept_a,
|
||||
name=f"E2E QI Project #{n}",
|
||||
description=f"E2E cross-department QI project #{n}. Automated - please ignore.",
|
||||
status="pending",
|
||||
focus_enabled=True,
|
||||
)
|
||||
project.team_members.add(staff_a, staff_b)
|
||||
# Link both departments; the m2m_changed signal derives the team
|
||||
# (champions staff_a + staff_b) automatically.
|
||||
project.departments.set([dept_a, dept_b])
|
||||
|
||||
# Create PDCA phases
|
||||
plan_phase = None
|
||||
|
||||
@ -1,41 +1,43 @@
|
||||
"""
|
||||
Unified reference-number generator.
|
||||
|
||||
Format: {PREFIX}-{YYYYMM}-{HOSPITAL_TOKEN}-{SEQ:04d}
|
||||
e.g. CMP-202606-HHN-0001
|
||||
Format: {PREFIX}-{YYYYMM}-{SEQ:04d}
|
||||
e.g. CMP-202607-0001
|
||||
|
||||
PREFIX module prefix (CMP/INQ/OBS/APR/SGT)
|
||||
YYYYMM creation month
|
||||
HOSPITAL_TOKEN hospital.code sanitized to uppercase alphanumerics
|
||||
(HH-N -> HHN, E2E-HOSP -> E2EHOSP); "GEN" when no hospital
|
||||
SEQ 4-digit monthly sequence per (prefix, hospital, month)
|
||||
SEQ 4-digit monthly sequence (global across all hospitals)
|
||||
|
||||
The sequence is allocated atomically via ReferenceSequence so it is safe under
|
||||
concurrent submissions. Legacy references keep their old format (no migration
|
||||
of historical data); only new records use this generator.
|
||||
concurrent submissions. Legacy references keep their old format
|
||||
({PREFIX}-{YYYYMM}-{HOSPITAL_TOKEN}-{SEQ}) — only new records use this generator.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
_TOKEN_RE = re.compile(r"[^A-Z0-9]")
|
||||
|
||||
|
||||
def sanitize_hospital_token(hospital) -> str:
|
||||
"""Derive a clean, format-safe token from a hospital instance."""
|
||||
"""Derive a clean, format-safe token from a hospital instance.
|
||||
|
||||
Kept for backward compatibility but no longer used in the output format.
|
||||
"""
|
||||
code = getattr(hospital, "code", None) if hospital else None
|
||||
if not code:
|
||||
return "GEN"
|
||||
token = _TOKEN_RE.sub("", str(code).upper())
|
||||
token = re.sub(r"[^A-Z0-9]", "", str(code).upper())
|
||||
return token or "GEN"
|
||||
|
||||
|
||||
def generate_reference(prefix: str, hospital) -> str:
|
||||
"""Generate a unified reference number for the given module prefix."""
|
||||
"""Generate a unified reference number for the given module prefix.
|
||||
|
||||
Format: CMP-202607-0001 (no hospital token in the output).
|
||||
All hospitals share a single global sequence per (prefix, month).
|
||||
"""
|
||||
from apps.core.models import ReferenceSequence
|
||||
|
||||
prefix = (prefix or "").upper()
|
||||
token = sanitize_hospital_token(hospital)
|
||||
year_month = datetime.now().strftime("%Y%m")
|
||||
number = ReferenceSequence.next_number(prefix, token, year_month)
|
||||
return f"{prefix}-{year_month}-{token}-{number:04d}"
|
||||
number = ReferenceSequence.next_number(prefix, "GLOBAL", year_month)
|
||||
return f"{prefix}-{year_month}-{number:04d}"
|
||||
|
||||
@ -585,7 +585,6 @@ def _track_inquiry(reference):
|
||||
"en": inquiry.response_en or inquiry.response or "",
|
||||
"ar": inquiry.response_ar or "",
|
||||
},
|
||||
"satisfaction": inquiry.satisfaction or "",
|
||||
})
|
||||
|
||||
|
||||
@ -630,7 +629,6 @@ def _track_observation(reference):
|
||||
"en": observation.response_en or observation.response or "",
|
||||
"ar": observation.response_ar or "",
|
||||
},
|
||||
"satisfaction": observation.satisfaction or "",
|
||||
})
|
||||
|
||||
|
||||
@ -784,14 +782,9 @@ def public_set_satisfaction(request):
|
||||
if upper.startswith("CMP-"):
|
||||
from apps.complaints.models import Complaint
|
||||
obj = Complaint.objects.get(reference_number__iexact=reference)
|
||||
elif upper.startswith("INQ-"):
|
||||
from apps.complaints.models import Inquiry
|
||||
obj = Inquiry.objects.get(reference_number__iexact=reference)
|
||||
elif upper.startswith("OBS-"):
|
||||
from apps.observations.models import Observation
|
||||
obj = Observation.objects.get(tracking_code__iexact=reference)
|
||||
else:
|
||||
return JsonResponse({"success": False, "error": "Unrecognized reference format."}, status=400)
|
||||
# Satisfaction is only collected for complaints.
|
||||
return JsonResponse({"success": False, "error": "Satisfaction is only available for complaints."}, status=400)
|
||||
except Exception:
|
||||
return JsonResponse({"success": False, "error": "Not found."}, status=404)
|
||||
|
||||
|
||||
@ -274,6 +274,19 @@ def feedback_detail(request, pk):
|
||||
"notes_count": generic_notes.count(),
|
||||
}
|
||||
|
||||
# Workflow stepper
|
||||
_fb_steps = [
|
||||
{"label": "Submitted", "icon": "plus-circle", "done": True},
|
||||
{"label": "Assigned", "icon": "user-plus", "done": feedback.assigned_to is not None},
|
||||
{"label": "Reviewed", "icon": "eye", "done": feedback.status in ("reviewed", "acknowledged", "closed")},
|
||||
{"label": "Acknowledged", "icon": "thumbs-up", "done": feedback.status in ("acknowledged", "closed")},
|
||||
{"label": "Closed", "icon": "check-circle-2", "done": feedback.status == "closed"},
|
||||
]
|
||||
_fb_next = next((i for i, s in enumerate(_fb_steps) if not s["done"]), len(_fb_steps))
|
||||
if _fb_next < len(_fb_steps):
|
||||
_fb_steps[_fb_next]["current"] = True
|
||||
context["workflow_steps"] = _fb_steps
|
||||
|
||||
return render(request, "feedback/feedback_detail.html", context)
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-04 19:55
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('observations', '0018_observation_satisfaction_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='observation',
|
||||
name='satisfaction',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='observation',
|
||||
name='satisfaction_set_at',
|
||||
),
|
||||
]
|
||||
@ -598,13 +598,6 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="responded_observations"
|
||||
)
|
||||
|
||||
# Satisfaction
|
||||
satisfaction = models.CharField(
|
||||
max_length=20, blank=True, default="",
|
||||
choices=[("satisfied", "Satisfied"), ("neutral", "Neutral"), ("dissatisfied", "Dissatisfied"), ("no_response", "No Response")],
|
||||
)
|
||||
satisfaction_set_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Closure
|
||||
closed_at = models.DateTimeField(null=True, blank=True)
|
||||
closed_by = models.ForeignKey(
|
||||
|
||||
@ -57,7 +57,6 @@ urlpatterns = [
|
||||
path("<uuid:pk>/note/", views.observation_add_note, name="observation_add_note"),
|
||||
# Respond (patient-facing response)
|
||||
path("<uuid:pk>/respond/", views.observation_respond, name="observation_respond"),
|
||||
path("<uuid:pk>/update-satisfaction/", views.observation_update_satisfaction, name="observation_update_satisfaction"),
|
||||
path("<uuid:pk>/generate-ai-response/", views.observation_generate_ai_response, name="observation_generate_ai_response"),
|
||||
# Send to Department
|
||||
path("<uuid:pk>/send-to-department/", views.observation_send_to_department, name="observation_send_to_department"),
|
||||
|
||||
@ -702,6 +702,19 @@ def observation_detail(request, pk):
|
||||
context["escalation_email_subject"] = escalation_email_subject
|
||||
context["escalation_email_body"] = escalation_email_body
|
||||
|
||||
# Workflow stepper
|
||||
_obs_steps = [
|
||||
{"label": "Created", "icon": "plus-circle", "done": True},
|
||||
{"label": "Assigned", "icon": "user-plus", "done": observation.assigned_at is not None},
|
||||
{"label": "Sent to Dept", "icon": "send", "done": observation.forwarded_to_dept_at is not None},
|
||||
{"label": "Dept Response", "icon": "message-square", "done": bool(observation.department_response_en or observation.department_response_ar)},
|
||||
{"label": "Resolved", "icon": "check-circle-2", "done": observation.status in ("resolved", "closed")},
|
||||
]
|
||||
_obs_next = next((i for i, s in enumerate(_obs_steps) if not s["done"]), len(_obs_steps))
|
||||
if _obs_next < len(_obs_steps):
|
||||
_obs_steps[_obs_next]["current"] = True
|
||||
context["workflow_steps"] = _obs_steps
|
||||
|
||||
return render(request, "observations/observation_detail.html", context)
|
||||
|
||||
|
||||
@ -1089,20 +1102,6 @@ Generate comprehensive, professional responses in both languages. Use Modern Sta
|
||||
return JsonResponse({"success": False, "error": f"Failed to generate response: {str(e)}"}, status=500)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def observation_update_satisfaction(request, pk):
|
||||
"""Update observation satisfaction."""
|
||||
observation = get_object_or_404(Observation, pk=pk)
|
||||
satisfaction = request.POST.get("satisfaction", "").strip()
|
||||
if satisfaction in ("satisfied", "neutral", "dissatisfied", "no_response"):
|
||||
observation.satisfaction = satisfaction
|
||||
observation.satisfaction_set_at = timezone.now()
|
||||
observation.save(update_fields=["satisfaction", "satisfaction_set_at"])
|
||||
messages.success(request, _("Satisfaction updated."))
|
||||
return redirect("observations:observation_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def observation_convert_to_action(request, pk):
|
||||
|
||||
@ -54,6 +54,7 @@ class HospitalAdmin(admin.ModelAdmin):
|
||||
("Contact Information", {"fields": ("address", "city", "phone", "email")}),
|
||||
("Executive Leadership", {"fields": ("ceo", "medical_director", "coo", "cfo")}),
|
||||
("Details", {"fields": ("license_number", "capacity", "status")}),
|
||||
("Branding", {"fields": ("letterhead", "stamp")}),
|
||||
("Metadata", {"fields": ("created_at", "updated_at")}),
|
||||
)
|
||||
autocomplete_fields = ["organization", "ceo", "medical_director", "coo", "cfo"]
|
||||
|
||||
23
apps/organizations/migrations/0015_hospital_branding.py
Normal file
23
apps/organizations/migrations/0015_hospital_branding.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-01 17:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('organizations', '0014_remove_department_manager_1st'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='hospital',
|
||||
name='letterhead',
|
||||
field=models.ImageField(blank=True, help_text='A4 transparent PNG with hospital header/footer artwork for PDF letterhead', null=True, upload_to='hospital_branding/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='hospital',
|
||||
name='stamp',
|
||||
field=models.ImageField(blank=True, help_text='Transparent stamp PNG placed at bottom-right of PDF pages', null=True, upload_to='hospital_branding/'),
|
||||
),
|
||||
]
|
||||
@ -84,6 +84,20 @@ class Hospital(UUIDModel, TimeStampedModel):
|
||||
# Status
|
||||
status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE, db_index=True)
|
||||
|
||||
# Branding — per-hospital PDF letterhead and stamp images
|
||||
letterhead = models.ImageField(
|
||||
upload_to="hospital_branding/",
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="A4 transparent PNG with hospital header/footer artwork for PDF letterhead",
|
||||
)
|
||||
stamp = models.ImageField(
|
||||
upload_to="hospital_branding/",
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Transparent stamp PNG placed at bottom-right of PDF pages",
|
||||
)
|
||||
|
||||
# Executive leadership
|
||||
ceo = models.ForeignKey(
|
||||
"accounts.User",
|
||||
|
||||
@ -2248,6 +2248,17 @@ def department_detail(request, pk):
|
||||
status__in=["open", "in_progress"],
|
||||
involved_departments__department=department,
|
||||
).distinct().select_related("department", "assigned_to")[:5],
|
||||
"pending_routing_involvements": ComplaintInvolvedDepartment.objects.filter(
|
||||
department=department,
|
||||
sent=True,
|
||||
response_submitted=False,
|
||||
routing_status="sent",
|
||||
).select_related("complaint", "complaint__department", "department").order_by("-sent_at")[:10],
|
||||
"hospital_departments": (
|
||||
Department.objects.filter(hospital=department.hospital, status="active")
|
||||
.filter(Q(champion__isnull=False) | Q(manager__isnull=False))
|
||||
.order_by("name")
|
||||
),
|
||||
}
|
||||
return render(request, "organizations/department_detail.html", context)
|
||||
|
||||
@ -2256,6 +2267,8 @@ def _check_department_access(user, department):
|
||||
return True
|
||||
if user.is_hospital_admin() and user.hospital == department.hospital:
|
||||
return True
|
||||
if department.manager_id == user.id:
|
||||
return True
|
||||
if user.is_champion() and user.department == department:
|
||||
return True
|
||||
if user.is_department_manager() and user.department == department:
|
||||
|
||||
@ -18,21 +18,21 @@ class QIProjectTaskInline(admin.TabularInline):
|
||||
class QIProjectAdmin(admin.ModelAdmin):
|
||||
"""QI Project admin"""
|
||||
list_display = [
|
||||
'name', 'hospital', 'department', 'project_lead',
|
||||
'name', 'hospital', 'get_departments', 'project_lead',
|
||||
'status', 'start_date', 'target_completion_date'
|
||||
]
|
||||
list_filter = ['status', 'hospital', 'department', 'start_date']
|
||||
list_filter = ['status', 'hospital', 'departments', 'start_date']
|
||||
search_fields = ['name', 'name_ar', 'description']
|
||||
ordering = ['-created_at']
|
||||
inlines = [QIProjectTaskInline]
|
||||
filter_horizontal = ['team_members', 'related_actions']
|
||||
|
||||
filter_horizontal = ['team_members', 'related_actions', 'departments']
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('name', 'name_ar', 'description')
|
||||
}),
|
||||
('Organization', {
|
||||
'fields': ('hospital', 'department')
|
||||
'fields': ('hospital', 'departments')
|
||||
}),
|
||||
('Team', {
|
||||
'fields': ('project_lead', 'team_members')
|
||||
@ -53,12 +53,19 @@ class QIProjectAdmin(admin.ModelAdmin):
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('hospital', 'department', 'project_lead')
|
||||
return qs.select_related('hospital', 'project_lead').prefetch_related('departments')
|
||||
|
||||
@admin.display(description='Departments')
|
||||
def get_departments(self, obj):
|
||||
depts = list(obj.departments.all())
|
||||
if not depts:
|
||||
return "—"
|
||||
return ", ".join(d.name for d in depts)
|
||||
|
||||
|
||||
@admin.register(QIProjectTask)
|
||||
|
||||
@ -61,7 +61,7 @@ def export_project_excel(project):
|
||||
info_data = [
|
||||
("Description", project.description),
|
||||
("Hospital", project.hospital.name if project.hospital else ""),
|
||||
("Department", project.department.name if project.department else ""),
|
||||
("Department", ", ".join(d.name for d in project.departments.all()) if project.departments.exists() else ""),
|
||||
("Project Lead", project.project_lead.get_full_name() if project.project_lead else ""),
|
||||
("Created By", project.created_by.get_full_name() if project.created_by else ""),
|
||||
("Status", project.get_status_display()),
|
||||
|
||||
@ -8,11 +8,11 @@ from django import forms
|
||||
from django.forms import inlineformset_factory
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.core.form_mixins import HospitalFieldMixin
|
||||
from apps.accounts.models import User
|
||||
from apps.organizations.models import Department, Hospital
|
||||
from apps.core.form_mixins import HospitalFieldMixin
|
||||
from apps.organizations.models import Department
|
||||
|
||||
from .models import QIProject, QIProjectTask, PDCAPhase, FOCUSPhase
|
||||
from .models import FOCUSPhase, PDCAPhase, QIProject, QIProjectTask
|
||||
|
||||
|
||||
class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
|
||||
@ -31,9 +31,8 @@ class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"name_ar",
|
||||
"description",
|
||||
"hospital",
|
||||
"department",
|
||||
"departments",
|
||||
"project_lead",
|
||||
"team_members",
|
||||
"status",
|
||||
"start_date",
|
||||
"target_completion_date",
|
||||
@ -65,18 +64,14 @@ class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||
}
|
||||
),
|
||||
"department": forms.Select(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||
}
|
||||
),
|
||||
"project_lead": forms.Select(
|
||||
"departments": forms.SelectMultiple(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white",
|
||||
"data-tomselect": "",
|
||||
"size": "6",
|
||||
}
|
||||
),
|
||||
"team_members": forms.SelectMultiple(
|
||||
"project_lead": forms.Select(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white",
|
||||
"data-tomselect": "",
|
||||
@ -127,21 +122,25 @@ class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
|
||||
hospital_id = self.user.hospital.id
|
||||
|
||||
if hospital_id:
|
||||
self.fields["department"].queryset = Department.objects.filter(
|
||||
self.fields["departments"].queryset = Department.objects.filter(
|
||||
hospital_id=hospital_id, status="active"
|
||||
).order_by("name")
|
||||
|
||||
# Filter staff choices based on hospital
|
||||
# Filter project lead based on hospital
|
||||
from apps.organizations.models import Staff
|
||||
staff_qs = Staff.objects.filter(
|
||||
hospital_id=hospital_id, status="active"
|
||||
).order_by("first_name", "last_name")
|
||||
self.fields["project_lead"].queryset = staff_qs
|
||||
self.fields["team_members"].queryset = staff_qs
|
||||
else:
|
||||
self.fields["department"].queryset = Department.objects.none()
|
||||
self.fields["project_lead"].queryset = Staff.objects.none() if False else []
|
||||
self.fields["team_members"].queryset = []
|
||||
self.fields["departments"].queryset = Department.objects.none()
|
||||
self.fields["project_lead"].queryset = Department.objects.none()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
instance = super().save(*args, **kwargs)
|
||||
# Team membership is derived from the selected departments' champion + manager.
|
||||
instance.sync_team_members_from_departments()
|
||||
return instance
|
||||
|
||||
|
||||
class QIProjectTaskForm(forms.ModelForm):
|
||||
@ -244,11 +243,12 @@ class QIProjectTaskForm(forms.ModelForm):
|
||||
self.fields["focus_phase"].queryset = FOCUSPhase.objects.none()
|
||||
self.fields["focus_phase"].widget = forms.HiddenInput()
|
||||
|
||||
# Filter assigned_to choices based on project hospital
|
||||
if self.project and self.project.hospital:
|
||||
# Filter assigned_to choices based on project team members
|
||||
# (derived from each department's champion + manager, plus the project lead).
|
||||
if self.project and self.project.pk:
|
||||
from apps.organizations.models import Staff
|
||||
self.fields["assigned_to"].queryset = Staff.objects.filter(
|
||||
hospital=self.project.hospital, status="active"
|
||||
pk__in=self.project.get_team_members_staff_ids()
|
||||
).order_by("first_name", "last_name")
|
||||
else:
|
||||
self.fields["assigned_to"].queryset = User.objects.none()
|
||||
@ -269,7 +269,7 @@ class QIProjectTemplateForm(HospitalFieldMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = QIProject
|
||||
fields = ["name", "name_ar", "description", "hospital", "department", "target_completion_date"]
|
||||
fields = ["name", "name_ar", "description", "hospital", "departments", "target_completion_date"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(
|
||||
attrs={
|
||||
@ -296,9 +296,11 @@ class QIProjectTemplateForm(HospitalFieldMixin, forms.ModelForm):
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||
}
|
||||
),
|
||||
"department": forms.Select(
|
||||
"departments": forms.SelectMultiple(
|
||||
attrs={
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
|
||||
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white",
|
||||
"data-tomselect": "",
|
||||
"size": "6",
|
||||
}
|
||||
),
|
||||
"target_completion_date": forms.DateInput(
|
||||
@ -332,11 +334,11 @@ class QIProjectTemplateForm(HospitalFieldMixin, forms.ModelForm):
|
||||
hospital_id = self.user.hospital.id
|
||||
|
||||
if hospital_id:
|
||||
self.fields["department"].queryset = Department.objects.filter(
|
||||
self.fields["departments"].queryset = Department.objects.filter(
|
||||
hospital_id=hospital_id, status="active"
|
||||
).order_by("name")
|
||||
else:
|
||||
self.fields["department"].queryset = Department.objects.none()
|
||||
self.fields["departments"].queryset = Department.objects.none()
|
||||
|
||||
|
||||
class ConvertToProjectForm(forms.Form):
|
||||
|
||||
81
apps/projects/migrations/0004_qi_multi_department.py
Normal file
81
apps/projects/migrations/0004_qi_multi_department.py
Normal file
@ -0,0 +1,81 @@
|
||||
# Generated for QI multi-department support.
|
||||
#
|
||||
# Order matters:
|
||||
# 1. Add the new `departments` M2M (blank).
|
||||
# 2. Copy each existing `department_id` into the M2M, then rebuild the derived
|
||||
# `team_members` set from each department's champion + manager (and the
|
||||
# project lead). The sync logic is inlined here (not imported from the
|
||||
# model) so the migration stays stable as the model method evolves.
|
||||
# 3. Remove the old single `department` FK.
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def copy_department_and_sync_team(apps, schema_editor):
|
||||
QIProject = apps.get_model("projects", "QIProject")
|
||||
Staff = apps.get_model("organizations", "Staff")
|
||||
|
||||
missing_manager_profile = 0
|
||||
for project in QIProject.objects.exclude(department__isnull=True):
|
||||
old_dept = project.department
|
||||
# M2M add via the through manager (project has just been saved with the new field).
|
||||
if old_dept and old_dept not in project.departments.all():
|
||||
project.departments.add(old_dept)
|
||||
|
||||
# Rebuild derived team_members: champion (Staff) + manager.staff_profile (Staff),
|
||||
# plus the project lead. De-duped; nulls skipped.
|
||||
pks = set()
|
||||
for dept in project.departments.all():
|
||||
champion_id = getattr(dept, "champion_id", None)
|
||||
if champion_id:
|
||||
pks.add(champion_id)
|
||||
manager_user_id = getattr(dept, "manager_id", None)
|
||||
if manager_user_id:
|
||||
manager_user = dept.manager
|
||||
if manager_user is not None and hasattr(manager_user, "staff_profile"):
|
||||
sp = manager_user.staff_profile
|
||||
if sp is not None and sp.id:
|
||||
pks.add(sp.id)
|
||||
else:
|
||||
missing_manager_profile += 1
|
||||
if project.project_lead_id:
|
||||
pks.add(project.project_lead_id)
|
||||
|
||||
if pks:
|
||||
project.team_members.set(Staff.objects.filter(pk__in=pks))
|
||||
|
||||
if missing_manager_profile:
|
||||
# Surface how many manager links could not be added (no staff_profile).
|
||||
print(f" [qi_multi_department] {missing_manager_profile} department manager(s) "
|
||||
f"had no staff_profile and were excluded from the derived team.")
|
||||
|
||||
|
||||
def remove_team_backreference(apps, schema_editor):
|
||||
# Reverse is a no-op: we cannot reconstruct the single FK from the M2M losslessly.
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("organizations", "0015_hospital_branding"),
|
||||
("projects", "0003_alter_qiproject_project_lead_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="qiproject",
|
||||
name="departments",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Departments involved in this QI project. Team membership is derived from each department's champion and manager.",
|
||||
related_name="qi_projects",
|
||||
to="organizations.department",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(copy_department_and_sync_team, remove_team_backreference),
|
||||
migrations.RemoveField(
|
||||
model_name="qiproject",
|
||||
name="department",
|
||||
),
|
||||
]
|
||||
@ -56,8 +56,11 @@ class QIProject(UUIDModel, TimeStampedModel):
|
||||
blank=True,
|
||||
help_text="Null for global templates available to all hospitals",
|
||||
)
|
||||
department = models.ForeignKey(
|
||||
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="qi_projects"
|
||||
departments = models.ManyToManyField(
|
||||
"organizations.Department",
|
||||
blank=True,
|
||||
related_name="qi_projects",
|
||||
help_text="Departments involved in this QI project. Team membership is derived from each department's champion and manager.",
|
||||
)
|
||||
|
||||
# Project lead
|
||||
@ -112,6 +115,44 @@ class QIProject(UUIDModel, TimeStampedModel):
|
||||
return f"[Template] {self.name}"
|
||||
return f"{self.name} ({self.status})"
|
||||
|
||||
def get_team_members_staff_ids(self):
|
||||
"""Return the set of Staff PKs derived from each linked department's
|
||||
champion (a Staff) and manager (a User -> staff_profile). De-duped; nulls skipped."""
|
||||
pks = set()
|
||||
for dept in self.departments.all():
|
||||
if dept.champion_id:
|
||||
pks.add(dept.champion_id)
|
||||
# `Department.manager` is a FK to User; resolve to its Staff profile if present.
|
||||
manager_user_id = getattr(dept, "manager_id", None)
|
||||
if manager_user_id:
|
||||
manager_user = dept.manager
|
||||
if manager_user is not None and hasattr(manager_user, "staff_profile"):
|
||||
sp = manager_user.staff_profile
|
||||
if sp is not None and sp.id:
|
||||
pks.add(sp.id)
|
||||
# Ensure project lead is always considered part of the team
|
||||
if self.project_lead_id:
|
||||
pks.add(self.project_lead_id)
|
||||
return pks
|
||||
|
||||
def sync_team_members_from_departments(self):
|
||||
"""Rebuild the `team_members` M2M from the champion + manager of each
|
||||
linked department (plus the project lead). Idempotent."""
|
||||
if not self.pk:
|
||||
return
|
||||
from apps.organizations.models import Staff
|
||||
|
||||
self.team_members.set(Staff.objects.filter(pk__in=self.get_team_members_staff_ids()))
|
||||
|
||||
def is_team_member(self, user):
|
||||
"""True if the given user's staff_profile is a derived team member or the project lead."""
|
||||
staff_profile = getattr(user, "staff_profile", None)
|
||||
if not staff_profile:
|
||||
return False
|
||||
if self.project_lead_id and self.project_lead_id == staff_profile.id:
|
||||
return True
|
||||
return self.team_members.filter(pk=staff_profile.id).exists()
|
||||
|
||||
|
||||
class QIProjectTask(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
"""
|
||||
Signals for QI Projects — sends a notification when a task is assigned.
|
||||
Signals for QI Projects.
|
||||
|
||||
- Sends a notification when a task is assigned.
|
||||
- Keeps `team_members` in sync with the champion + manager of each linked
|
||||
department whenever the `departments` M2M changes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.db.models.signals import post_save
|
||||
from django.db.models.signals import m2m_changed, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from apps.projects.models import QIProject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -36,3 +42,20 @@ def notify_task_assignment(sender, instance, created, **kwargs):
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send QI task notification: {e}")
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=QIProject.departments.through)
|
||||
def sync_team_on_departments_change(sender, instance, action, **kwargs):
|
||||
"""Rebuild the derived `team_members` set whenever the project's departments change.
|
||||
|
||||
Fires on `post_add` / `post_remove` / `post_clear` so that programmatic,
|
||||
admin, and form-driven edits all stay consistent. The sync is idempotent.
|
||||
"""
|
||||
if action not in ("post_add", "post_remove", "post_clear"):
|
||||
return
|
||||
if not instance.pk:
|
||||
return
|
||||
try:
|
||||
instance.sync_team_members_from_departments()
|
||||
except Exception as e: # defensive: never break an M2M write on sync failure
|
||||
logger.warning(f"Failed to sync QI team members for project {instance.pk}: {e}")
|
||||
|
||||
157
apps/projects/tests.py
Normal file
157
apps/projects/tests.py
Normal file
@ -0,0 +1,157 @@
|
||||
"""
|
||||
Tests for the QI Projects app.
|
||||
|
||||
Covers the multi-department + derived-team behaviour:
|
||||
- Team membership is derived from each department's champion + manager (+ lead).
|
||||
- Sync fires on M2M changes to `departments`.
|
||||
- ACL: only admins / project lead / team members can view & manage.
|
||||
- Task `assigned_to` is limited to derived team members.
|
||||
"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.organizations.models import Department, Hospital, Staff
|
||||
from apps.projects.models import QIProject, QIProjectTask
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def _make_staff(hospital, department=None, first="Staff", email=None):
|
||||
return Staff.objects.create(
|
||||
hospital=hospital,
|
||||
department=department,
|
||||
first_name=first,
|
||||
last_name="Test",
|
||||
status="active",
|
||||
staff_type="other",
|
||||
job_title="T",
|
||||
employee_id=f"EMP-{first}-{Hospital.objects.count()}",
|
||||
user=User.objects.create_user(
|
||||
username=email or f"{first.lower()}@test.local",
|
||||
email=email or f"{first.lower()}@test.local",
|
||||
password="pw",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DerivedTeamTests(TestCase):
|
||||
"""sync_team_members_from_departments + m2m_changed signal."""
|
||||
|
||||
def setUp(self):
|
||||
self.hospital = Hospital.objects.create(name="H1", code="H1", status="active")
|
||||
self.dept_a = Department.objects.create(hospital=self.hospital, name="Dept A", code="DA", status="active")
|
||||
self.dept_b = Department.objects.create(hospital=self.hospital, name="Dept B", code="DB", status="active")
|
||||
self.champ_a = _make_staff(self.hospital, self.dept_a, "ChampA")
|
||||
self.champ_b = _make_staff(self.hospital, self.dept_b, "ChampB")
|
||||
self.dept_a.champion = self.champ_a
|
||||
self.dept_a.save()
|
||||
self.dept_b.champion = self.champ_b
|
||||
self.dept_b.save()
|
||||
self.lead = _make_staff(self.hospital, self.dept_a, "Lead")
|
||||
|
||||
def _new_project(self, **kw):
|
||||
defaults = dict(
|
||||
name="P", description="d", hospital=self.hospital, status="pending", project_lead=self.lead
|
||||
)
|
||||
defaults.update(kw)
|
||||
return QIProject.objects.create(**defaults)
|
||||
|
||||
def test_team_derived_from_department_champions(self):
|
||||
p = self._new_project()
|
||||
p.departments.set([self.dept_a, self.dept_b])
|
||||
# champ_a, champ_b (champions) + lead (project_lead) → 3 members
|
||||
self.assertEqual(set(p.team_members.values_list("pk", flat=True)),
|
||||
{self.champ_a.pk, self.champ_b.pk, self.lead.pk})
|
||||
|
||||
def test_manager_without_staff_profile_is_skipped(self):
|
||||
# Manager is a User with no staff_profile → excluded
|
||||
mgr_user = User.objects.create_user(username="mgr", email="mgr@x", password="p")
|
||||
self.dept_a.manager = mgr_user
|
||||
self.dept_a.save()
|
||||
p = self._new_project()
|
||||
p.departments.set([self.dept_a])
|
||||
# Only champ_a + lead; mgr_user has no staff_profile so not added
|
||||
self.assertEqual(set(p.team_members.values_list("pk", flat=True)),
|
||||
{self.champ_a.pk, self.lead.pk})
|
||||
|
||||
def test_manager_with_staff_profile_is_included(self):
|
||||
mgr_staff = _make_staff(self.hospital, self.dept_a, "Mgr")
|
||||
mgr_user = mgr_staff.user
|
||||
self.dept_a.manager = mgr_user
|
||||
self.dept_a.save()
|
||||
p = self._new_project()
|
||||
p.departments.set([self.dept_a])
|
||||
self.assertIn(mgr_staff.pk, set(p.team_members.values_list("pk", flat=True)))
|
||||
|
||||
def test_removing_department_rebuilds_team(self):
|
||||
p = self._new_project()
|
||||
p.departments.set([self.dept_a, self.dept_b])
|
||||
self.assertIn(self.champ_b.pk, set(p.team_members.values_list("pk", flat=True)))
|
||||
p.departments.remove(self.dept_b)
|
||||
# champ_b no longer in team; champ_a + lead remain
|
||||
self.assertEqual(set(p.team_members.values_list("pk", flat=True)),
|
||||
{self.champ_a.pk, self.lead.pk})
|
||||
|
||||
def test_is_team_member_helper(self):
|
||||
p = self._new_project()
|
||||
p.departments.set([self.dept_a])
|
||||
self.assertTrue(p.is_team_member(self.champ_a.user))
|
||||
self.assertTrue(p.is_team_member(self.lead.user))
|
||||
self.assertFalse(p.is_team_member(self.champ_b.user))
|
||||
|
||||
|
||||
class ACLTests(TestCase):
|
||||
"""_check_project_permission + _get_can_edit via the views."""
|
||||
|
||||
def setUp(self):
|
||||
self.hospital = Hospital.objects.create(name="H1", code="H1", status="active")
|
||||
self.hospital2 = Hospital.objects.create(name="H2", code="H2", status="active")
|
||||
self.dept = Department.objects.create(hospital=self.hospital, name="D", code="D", status="active")
|
||||
self.champ = _make_staff(self.hospital, self.dept, "Champ")
|
||||
self.outsider = _make_staff(self.hospital, self.dept, "Outsider")
|
||||
self.dept.champion = self.champ
|
||||
self.dept.save()
|
||||
self.lead = _make_staff(self.hospital, self.dept, "Lead")
|
||||
self.project = QIProject.objects.create(
|
||||
name="P", description="d", hospital=self.hospital, status="pending", project_lead=self.lead
|
||||
)
|
||||
self.project.departments.set([self.dept]) # derives team = {champ, lead}
|
||||
|
||||
def test_team_member_can_view_detail(self):
|
||||
# ACL helper level (view-level rendering is blocked by a pre-existing
|
||||
# staticfiles-manifest issue in the test env, unrelated to this feature).
|
||||
from apps.projects.ui_views import _check_project_permission, _get_can_edit
|
||||
|
||||
self.assertTrue(_check_project_permission(self.project, self.champ.user))
|
||||
self.assertFalse(_get_can_edit(self.champ.user, self.project)) # not lead/admin
|
||||
|
||||
def test_non_team_same_hospital_user_is_denied(self):
|
||||
from apps.projects.ui_views import _check_project_permission
|
||||
|
||||
# Outsider is same-hospital but NOT a team member → denied
|
||||
self.assertFalse(_check_project_permission(self.project, self.outsider.user))
|
||||
|
||||
def test_project_lead_can_edit(self):
|
||||
from apps.projects.ui_views import _get_can_edit
|
||||
|
||||
# Lead is not an admin but is the project lead → can edit
|
||||
self.assertTrue(_get_can_edit(self.lead.user, self.project))
|
||||
|
||||
def test_task_assigned_to_is_limited_to_team_members(self):
|
||||
from apps.projects.forms import QIProjectTaskForm
|
||||
|
||||
form = QIProjectTaskForm(project=self.project)
|
||||
qs_ids = set(form.fields["assigned_to"].queryset.values_list("pk", flat=True))
|
||||
self.assertEqual(qs_ids, {self.champ.pk, self.lead.pk})
|
||||
self.assertNotIn(self.outsider.pk, qs_ids)
|
||||
|
||||
def test_task_toggle_requires_post(self):
|
||||
task = QIProjectTask.objects.create(project=self.project, title="t", assigned_to=self.champ, status="pending")
|
||||
self.client.force_login(self.champ.user)
|
||||
url = reverse("projects:task_toggle_status",
|
||||
kwargs={"project_pk": self.project.pk, "task_pk": task.pk})
|
||||
# GET must be rejected (405) — CSRF-safe toggle
|
||||
resp = self.client.get(url)
|
||||
self.assertEqual(resp.status_code, 405)
|
||||
@ -12,6 +12,7 @@ from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from apps.core.decorators import block_source_user
|
||||
from apps.core.models import StatusChoices
|
||||
@ -258,8 +259,8 @@ def project_list(request):
|
||||
# Exclude templates from the list
|
||||
queryset = (
|
||||
QIProject.objects.filter(is_template=False)
|
||||
.select_related("hospital", "department", "project_lead", "project_lead__department")
|
||||
.prefetch_related("team_members", "related_actions")
|
||||
.select_related("hospital", "project_lead", "project_lead__department")
|
||||
.prefetch_related("departments", "team_members", "related_actions")
|
||||
)
|
||||
|
||||
# Apply RBAC filters
|
||||
@ -271,8 +272,24 @@ def project_list(request):
|
||||
# PX Admins see all, but filter by selected hospital if set
|
||||
if selected_hospital:
|
||||
queryset = queryset.filter(hospital=selected_hospital)
|
||||
elif user.is_hospital_admin():
|
||||
# Hospital admins see projects in their hospital, scoped by team membership
|
||||
if user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
staff_profile = getattr(user, "staff_profile", None)
|
||||
if staff_profile:
|
||||
queryset = queryset.filter(
|
||||
Q(team_members=staff_profile) | Q(project_lead=staff_profile)
|
||||
).distinct()
|
||||
elif user.hospital:
|
||||
queryset = queryset.filter(hospital=user.hospital)
|
||||
# Other users: same hospital AND must be a team member or project lead
|
||||
staff_profile = getattr(user, "staff_profile", None)
|
||||
if staff_profile:
|
||||
queryset = queryset.filter(hospital=user.hospital).filter(
|
||||
Q(team_members=staff_profile) | Q(project_lead=staff_profile)
|
||||
).distinct()
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
|
||||
# Apply filters
|
||||
status_filter = request.GET.get("status")
|
||||
@ -283,6 +300,10 @@ def project_list(request):
|
||||
if hospital_filter:
|
||||
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||
|
||||
department_filter = request.GET.get("department")
|
||||
if department_filter:
|
||||
queryset = queryset.filter(departments__id=department_filter).distinct()
|
||||
|
||||
# Search
|
||||
search_query = request.GET.get("search")
|
||||
if search_query:
|
||||
@ -332,14 +353,17 @@ def project_detail(request, pk):
|
||||
|
||||
project = get_object_or_404(
|
||||
QIProject.objects.filter(is_template=False)
|
||||
.select_related("hospital", "department", "project_lead", "project_lead__department")
|
||||
.prefetch_related("team_members", "related_actions", "tasks", "pdca_phases", "focus_phases"),
|
||||
.select_related("hospital", "project_lead", "project_lead__department")
|
||||
.prefetch_related(
|
||||
"departments", "team_members", "related_actions", "tasks",
|
||||
"pdca_phases", "focus_phases",
|
||||
),
|
||||
pk=pk,
|
||||
)
|
||||
|
||||
# Check permission
|
||||
# Check permission (admins, project lead, or team members)
|
||||
user = request.user
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
if not _check_project_permission(project, user):
|
||||
messages.error(request, _("You don't have permission to view this project."))
|
||||
return redirect("projects:project_list")
|
||||
|
||||
@ -412,7 +436,7 @@ def project_create(request, template_pk=None):
|
||||
"name": template.name,
|
||||
"name_ar": template.name_ar,
|
||||
"description": template.description,
|
||||
"department": template.department,
|
||||
"departments": template.departments.all(),
|
||||
"target_completion_date": template.target_completion_date,
|
||||
}
|
||||
if not user.is_px_admin() and user.hospital:
|
||||
@ -453,7 +477,7 @@ def project_create(request, template_pk=None):
|
||||
if user.is_px_admin():
|
||||
initial_data["hospital"] = obj.hospital_id
|
||||
if hasattr(obj, "department_id") and obj.department_id:
|
||||
initial_data["department"] = obj.department_id
|
||||
initial_data["departments"] = [obj.department_id]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@ -569,11 +593,11 @@ def project_delete(request, pk):
|
||||
project = get_object_or_404(QIProject, pk=pk, is_template=False)
|
||||
|
||||
# Check permission (only PX Admin or Hospital Admin can delete)
|
||||
if not (user.is_px_admin() or user.is_hospital_admin):
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, _("You don't have permission to delete projects."))
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
if not _check_project_permission(project, user):
|
||||
messages.error(request, _("You don't have permission to delete this project."))
|
||||
return redirect("projects:project_list")
|
||||
|
||||
@ -598,12 +622,12 @@ def project_save_as_template(request, pk):
|
||||
project = get_object_or_404(QIProject, pk=pk, is_template=False)
|
||||
|
||||
# Check permission (only PX Admin or Hospital Admin can create templates)
|
||||
if not (user.is_px_admin() or user.is_hospital_admin):
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, _("You don't have permission to create templates."))
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
# Check hospital access
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
if not _check_project_permission(project, user):
|
||||
messages.error(request, _("You don't have permission to create templates from this project."))
|
||||
return redirect("projects:project_list")
|
||||
|
||||
@ -624,10 +648,11 @@ def project_save_as_template(request, pk):
|
||||
is_template=True,
|
||||
# If global, hospital is None; otherwise use project's hospital
|
||||
hospital=None if make_global else project.hospital,
|
||||
department=project.department,
|
||||
status="pending", # Default status for templates
|
||||
created_by=user,
|
||||
)
|
||||
# Copy linked departments from project to template
|
||||
template.departments.set(project.departments.all())
|
||||
|
||||
# Copy tasks from project to template
|
||||
for task in project.tasks.all():
|
||||
@ -683,8 +708,8 @@ def task_create(request, project_pk, phase=None):
|
||||
current_phase = get_object_or_404(FOCUSPhase, phase=phase, project=project)
|
||||
phase_type = "focus"
|
||||
|
||||
# Check permission — admin only
|
||||
if not _get_can_edit(user):
|
||||
# Check permission — must have project access + edit rights
|
||||
if not _check_project_permission(project, user) or not _get_can_edit(user, project):
|
||||
messages.error(request, _("You don't have permission to add tasks to this project."))
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
@ -726,8 +751,8 @@ def task_edit(request, project_pk, task_pk, phase=None):
|
||||
current_phase = get_object_or_404(FOCUSPhase, phase=phase, project=project)
|
||||
phase_type = "focus"
|
||||
|
||||
# Check permission — admin only
|
||||
if not _get_can_edit(user):
|
||||
# Check permission — must have project access + edit rights
|
||||
if not _check_project_permission(project, user) or not _get_can_edit(user, project):
|
||||
messages.error(request, _("You don't have permission to edit tasks in this project."))
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
@ -766,8 +791,8 @@ def task_delete(request, project_pk, task_pk, phase=None):
|
||||
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
||||
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
|
||||
|
||||
# Check permission — admin only
|
||||
if not _get_can_edit(user):
|
||||
# Check permission — must have project access + edit rights
|
||||
if not _check_project_permission(project, user) or not _get_can_edit(user, project):
|
||||
messages.error(request, _("You don't have permission to delete tasks in this project."))
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
@ -790,21 +815,17 @@ def task_delete(request, project_pk, task_pk, phase=None):
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
@require_POST
|
||||
def task_toggle_status(request, project_pk, task_pk, phase=None):
|
||||
"""Quick toggle task status between pending and completed"""
|
||||
user = request.user
|
||||
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
|
||||
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
|
||||
|
||||
if not _can_manage_task(project, task, user):
|
||||
if not _check_project_permission(project, user) or not _can_manage_task(project, task, user):
|
||||
messages.error(request, _("You don't have permission to update task status."))
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
# Check hospital access
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to update tasks in this project."))
|
||||
return redirect("projects:project_detail", pk=project.pk)
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
if task.status == "completed":
|
||||
@ -831,16 +852,14 @@ def template_list(request):
|
||||
user = request.user
|
||||
|
||||
# Only admins can manage templates
|
||||
if not (user.is_px_admin() or user.is_hospital_admin):
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, _("You don't have permission to view templates."))
|
||||
return redirect("projects:project_list")
|
||||
|
||||
queryset = QIProject.objects.filter(is_template=True).select_related("hospital", "department")
|
||||
queryset = QIProject.objects.filter(is_template=True).select_related("hospital").prefetch_related("departments")
|
||||
|
||||
# Apply RBAC filters
|
||||
if not user.is_px_admin():
|
||||
from django.db.models import Q
|
||||
|
||||
queryset = queryset.filter(Q(hospital=user.hospital) | Q(hospital__isnull=True))
|
||||
|
||||
# Search
|
||||
@ -856,7 +875,7 @@ def template_list(request):
|
||||
|
||||
context = {
|
||||
"templates": queryset,
|
||||
"can_create": user.is_px_admin() or user.is_hospital_admin,
|
||||
"can_create": user.is_px_admin() or user.is_hospital_admin(),
|
||||
"can_edit": user.is_px_admin() or user.is_hospital_admin(),
|
||||
}
|
||||
|
||||
@ -870,18 +889,17 @@ def template_detail(request, pk):
|
||||
user = request.user
|
||||
|
||||
# Only admins can view templates
|
||||
if not (user.is_px_admin() or user.is_hospital_admin):
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, _("You don't have permission to view templates."))
|
||||
return redirect("projects:project_list")
|
||||
|
||||
template = get_object_or_404(
|
||||
QIProject.objects.filter(is_template=True).select_related("hospital", "department"), pk=pk
|
||||
QIProject.objects.filter(is_template=True).select_related("hospital").prefetch_related("departments"),
|
||||
pk=pk,
|
||||
)
|
||||
|
||||
# Check permission for hospital-specific templates
|
||||
if not user.is_px_admin():
|
||||
from django.db.models import Q
|
||||
|
||||
if template.hospital and template.hospital != user.hospital:
|
||||
messages.error(request, _("You don't have permission to view this template."))
|
||||
return redirect("projects:template_list")
|
||||
@ -904,7 +922,7 @@ def template_create(request):
|
||||
user = request.user
|
||||
|
||||
# Only admins can create templates
|
||||
if not (user.is_px_admin() or user.is_hospital_admin):
|
||||
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||
messages.error(request, _("You don't have permission to create templates."))
|
||||
return redirect("projects:project_list")
|
||||
|
||||
@ -1036,13 +1054,14 @@ def convert_action_to_project(request, action_pk):
|
||||
name_ar=template.name_ar,
|
||||
description=template.description,
|
||||
hospital=action.hospital,
|
||||
department=template.department,
|
||||
project_lead=form.cleaned_data["project_lead"],
|
||||
target_completion_date=form.cleaned_data["target_completion_date"],
|
||||
status="pending",
|
||||
created_by=user,
|
||||
)
|
||||
# Copy tasks from template
|
||||
# Copy linked departments + tasks from template
|
||||
project.departments.set(template.departments.all())
|
||||
project.sync_team_members_from_departments()
|
||||
for template_task in template.tasks.all():
|
||||
QIProjectTask.objects.create(
|
||||
project=project,
|
||||
@ -1062,6 +1081,8 @@ def convert_action_to_project(request, action_pk):
|
||||
status="pending",
|
||||
created_by=user,
|
||||
)
|
||||
# Sync team from project lead (no departments in blank conversion)
|
||||
project.sync_team_members_from_departments()
|
||||
|
||||
# Link to the action
|
||||
project.related_actions.add(action)
|
||||
@ -1177,16 +1198,13 @@ def pdca_phase_edit(request, pk, phase):
|
||||
from apps.accounts.models import User
|
||||
|
||||
team_members = project.team_members.all()
|
||||
if project.project_lead:
|
||||
if project.project_lead not in team_members:
|
||||
team_members = list(team_members) + [project.project_lead]
|
||||
|
||||
context = {
|
||||
"project": project,
|
||||
"pdca_phase": pdca_phase,
|
||||
"phase_key": phase,
|
||||
"phase_label": PDCAPhaseChoices(phase).label,
|
||||
"team_members": team_members.distinct(),
|
||||
"team_members": team_members,
|
||||
"status_choices": StatusChoices.choices,
|
||||
}
|
||||
|
||||
@ -1290,16 +1308,13 @@ def focus_phase_edit(request, pk, phase):
|
||||
from apps.accounts.models import User
|
||||
|
||||
team_members = project.team_members.all()
|
||||
if project.project_lead:
|
||||
if project.project_lead not in team_members:
|
||||
team_members = list(team_members) + [project.project_lead]
|
||||
|
||||
context = {
|
||||
"project": project,
|
||||
"focus_phase": focus_phase,
|
||||
"phase_key": phase,
|
||||
"phase_label": FOCUSPhaseChoices(phase).label,
|
||||
"team_members": team_members.distinct(),
|
||||
"team_members": team_members,
|
||||
"status_choices": StatusChoices.choices,
|
||||
}
|
||||
|
||||
@ -1335,23 +1350,39 @@ def project_export_excel(request, pk):
|
||||
|
||||
|
||||
def _check_project_permission(project, user):
|
||||
"""Helper to check if user can access project."""
|
||||
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
|
||||
"""Check if user can access (view) a project.
|
||||
|
||||
Granted to: PX admins, hospital admins (same hospital), the project lead,
|
||||
or any derived team member (champion/manager of a linked department).
|
||||
"""
|
||||
if user.is_px_admin():
|
||||
return True
|
||||
# Hospital admins are scoped to their own hospital
|
||||
if user.is_hospital_admin():
|
||||
return not (user.hospital and project.hospital and user.hospital != project.hospital)
|
||||
# Cross-hospital access is always denied for regular users
|
||||
if user.hospital and project.hospital and user.hospital != project.hospital:
|
||||
return False
|
||||
return True
|
||||
return project.is_team_member(user)
|
||||
|
||||
|
||||
def _get_can_edit(user):
|
||||
"""Helper to check edit permissions — admin only."""
|
||||
return user.is_px_admin() or user.is_hospital_admin()
|
||||
def _get_can_edit(user, project):
|
||||
"""Check edit (structural) permissions: admins of the same hospital, or the project lead."""
|
||||
if user.is_px_admin():
|
||||
return True
|
||||
if user.is_hospital_admin():
|
||||
return not (user.hospital and project.hospital and user.hospital != project.hospital)
|
||||
staff_profile = getattr(user, "staff_profile", None)
|
||||
return bool(staff_profile and project.project_lead_id == staff_profile.id)
|
||||
|
||||
|
||||
def _can_manage_task(project, task, user):
|
||||
"""Check if user can toggle a task status.
|
||||
|
||||
True for admins, or the task's assignee (so they can check/uncheck their own tasks).
|
||||
True for admins (same hospital), the project lead, or the task's assignee
|
||||
(so they can check/uncheck their own tasks).
|
||||
"""
|
||||
if _get_can_edit(user):
|
||||
if _get_can_edit(user, project):
|
||||
return True
|
||||
staff_profile = getattr(user, "staff_profile", None)
|
||||
if staff_profile and task.assigned_to_id == staff_profile.id:
|
||||
@ -1361,6 +1392,7 @@ def _can_manage_task(project, task, user):
|
||||
|
||||
@block_source_user
|
||||
@login_required
|
||||
@require_POST
|
||||
def htmx_task_toggle_status(request, project_pk, task_pk):
|
||||
"""Toggle task status via HTMX - returns updated task row"""
|
||||
from django.utils import timezone
|
||||
@ -1385,7 +1417,7 @@ def htmx_task_toggle_status(request, project_pk, task_pk):
|
||||
|
||||
task.save()
|
||||
|
||||
can_edit = _get_can_edit(user)
|
||||
can_edit = _get_can_edit(user, project)
|
||||
can_toggle = _can_manage_task(project, task, user)
|
||||
today = timezone.now().date()
|
||||
|
||||
@ -1409,7 +1441,7 @@ def htmx_task_delete(request, project_pk, task_pk):
|
||||
if not _check_project_permission(project, user):
|
||||
return HttpResponse(_("Permission denied"), status=403)
|
||||
|
||||
if not _get_can_edit(user):
|
||||
if not _get_can_edit(user, project):
|
||||
return HttpResponse(_("You don't have permission to delete tasks."), status=403)
|
||||
|
||||
# Determine which column to refresh
|
||||
@ -1436,7 +1468,7 @@ def htmx_task_create(request, project_pk, phase_type, phase):
|
||||
if not _check_project_permission(project, user):
|
||||
return HttpResponse(_("Permission denied"), status=403)
|
||||
|
||||
can_edit = _get_can_edit(user)
|
||||
can_edit = _get_can_edit(user, project)
|
||||
if not can_edit:
|
||||
return HttpResponse(_("You don't have permission to add tasks."), status=403)
|
||||
|
||||
@ -1544,7 +1576,7 @@ def htmx_task_edit_form(request, project_pk, task_pk):
|
||||
if not _check_project_permission(project, user):
|
||||
return HttpResponse(_("Permission denied"), status=403)
|
||||
|
||||
can_edit = _get_can_edit(user)
|
||||
can_edit = _get_can_edit(user, project)
|
||||
if not can_edit:
|
||||
return HttpResponse(_("You don't have permission to edit tasks."), status=403)
|
||||
|
||||
@ -1622,7 +1654,7 @@ def htmx_pdca_column(request, project_pk, phase):
|
||||
if not _check_project_permission(project, user):
|
||||
return HttpResponse(_("Permission denied"), status=403)
|
||||
|
||||
can_edit = _get_can_edit(user)
|
||||
can_edit = _get_can_edit(user, project)
|
||||
today = timezone.now().date()
|
||||
|
||||
phase_obj, _ = PDCAPhase.objects.get_or_create(
|
||||
@ -1666,7 +1698,7 @@ def htmx_focus_column(request, project_pk, phase):
|
||||
if not _check_project_permission(project, user):
|
||||
return HttpResponse(_("Permission denied"), status=403)
|
||||
|
||||
can_edit = _get_can_edit(user)
|
||||
can_edit = _get_can_edit(user, project)
|
||||
today = timezone.now().date()
|
||||
|
||||
phase_obj, _ = FOCUSPhase.objects.get_or_create(
|
||||
@ -1708,7 +1740,7 @@ def htmx_phase_edit_form(request, project_pk, phase_type, phase):
|
||||
if not _check_project_permission(project, user):
|
||||
return HttpResponse(_("Permission denied"), status=403)
|
||||
|
||||
can_edit = _get_can_edit(user)
|
||||
can_edit = _get_can_edit(user, project)
|
||||
if not can_edit:
|
||||
return HttpResponse(_("You don't have permission to edit phases."), status=403)
|
||||
|
||||
@ -1770,9 +1802,6 @@ def htmx_phase_edit_form(request, project_pk, phase_type, phase):
|
||||
|
||||
# GET - return form
|
||||
team_members = project.team_members.all()
|
||||
if project.project_lead:
|
||||
if project.project_lead not in team_members:
|
||||
team_members = list(team_members) + [project.project_lead]
|
||||
|
||||
return render(
|
||||
request,
|
||||
@ -1783,7 +1812,7 @@ def htmx_phase_edit_form(request, project_pk, phase_type, phase):
|
||||
"phase_key": phase,
|
||||
"phase_label": phase_label,
|
||||
"phase_type": phase_type,
|
||||
"team_members": team_members.distinct(),
|
||||
"team_members": team_members,
|
||||
"status_choices": StatusChoices.choices,
|
||||
},
|
||||
)
|
||||
|
||||
@ -244,6 +244,37 @@ def action_detail(request, pk):
|
||||
return render(request, "actions/action_detail.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def action_upload_attachment(request, pk):
|
||||
"""Upload an attachment or evidence file to a PX Action."""
|
||||
action = get_object_or_404(PXAction, pk=pk)
|
||||
|
||||
user = request.user
|
||||
if not (user.is_px_admin() or user.is_hospital_admin() or action.assigned_to == user):
|
||||
messages.error(request, "You don't have permission to upload attachments.")
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
uploaded = request.FILES.get("file")
|
||||
if not uploaded:
|
||||
messages.error(request, "Please select a file to upload.")
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
PXActionAttachment.objects.create(
|
||||
action=action,
|
||||
file=uploaded,
|
||||
filename=uploaded.name,
|
||||
file_type=uploaded.content_type,
|
||||
file_size=uploaded.size,
|
||||
uploaded_by=user,
|
||||
description=request.POST.get("description", "").strip(),
|
||||
is_evidence=request.POST.get("is_evidence") == "on",
|
||||
)
|
||||
|
||||
messages.success(request, "File uploaded successfully.")
|
||||
return redirect("actions:action_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def action_assign(request, pk):
|
||||
@ -474,7 +505,18 @@ def action_create(request):
|
||||
return redirect("actions:action_list")
|
||||
|
||||
if request.method == "POST":
|
||||
form = ManualActionForm(request.POST, request=request)
|
||||
post = request.POST.copy()
|
||||
# Inject required fields the template doesn't render as visible inputs
|
||||
if not post.get("source_type"):
|
||||
post["source_type"] = (
|
||||
ActionSource.COMPLAINT if post.get("complaint_id") else ActionSource.MANUAL
|
||||
)
|
||||
if not post.get("hospital"):
|
||||
if user.hospital:
|
||||
post["hospital"] = str(user.hospital.id)
|
||||
elif hasattr(request, "tenant_hospital") and request.tenant_hospital:
|
||||
post["hospital"] = str(request.tenant_hospital.id)
|
||||
form = ManualActionForm(post, request=request)
|
||||
if form.is_valid():
|
||||
action = form.save(commit=False)
|
||||
action.created_by = user
|
||||
@ -486,6 +528,17 @@ def action_create(request):
|
||||
if action.assigned_to:
|
||||
action.assigned_at = timezone.now()
|
||||
|
||||
# Link to complaint if provided (from complaint detail → "Create Action" button)
|
||||
complaint_id = request.POST.get("complaint_id", "").strip()
|
||||
if complaint_id:
|
||||
from apps.complaints.models import Complaint
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
complaint = Complaint.objects.filter(pk=complaint_id).first()
|
||||
if complaint:
|
||||
action.source_type = ActionSource.COMPLAINT
|
||||
action.content_type = ContentType.objects.get_for_model(Complaint)
|
||||
action.object_id = complaint.id
|
||||
|
||||
action.save()
|
||||
|
||||
# Create log
|
||||
@ -526,10 +579,19 @@ def action_create(request):
|
||||
|
||||
form = ManualActionForm(request=request)
|
||||
|
||||
# Complaint linkage (from complaint detail → "Create Action" button)
|
||||
linked_complaint_id = request.GET.get("complaint_id", "").strip()
|
||||
linked_complaint = None
|
||||
if linked_complaint_id:
|
||||
from apps.complaints.models import Complaint
|
||||
linked_complaint = Complaint.objects.filter(pk=linked_complaint_id).first()
|
||||
|
||||
context = {
|
||||
"form": form,
|
||||
"source_choices": ActionSource.choices,
|
||||
"status_choices": ActionStatus.choices,
|
||||
"linked_complaint_id": linked_complaint_id,
|
||||
"linked_complaint": linked_complaint,
|
||||
}
|
||||
|
||||
return render(request, "actions/action_create.html", context)
|
||||
|
||||
@ -23,6 +23,7 @@ urlpatterns = [
|
||||
),
|
||||
path("<uuid:pk>/", ui_views.action_detail, name="action_detail"),
|
||||
path("<uuid:pk>/assign/", ui_views.action_assign, name="action_assign"),
|
||||
path("<uuid:pk>/upload-attachment/", ui_views.action_upload_attachment, name="action_upload_attachment"),
|
||||
path("<uuid:pk>/change-status/", ui_views.action_change_status, name="action_change_status"),
|
||||
path("<uuid:pk>/add-note/", ui_views.action_add_note, name="action_add_note"),
|
||||
path("<uuid:pk>/escalate/", ui_views.action_escalate, name="action_escalate"),
|
||||
|
||||
@ -189,7 +189,7 @@ LOCALE_PATHS = [
|
||||
# AI-powered translation configuration
|
||||
DJANGO_AI_PO = {
|
||||
"MODEL": "openrouter/deepseek/deepseek-v4-flash",
|
||||
"API_KEY": env("OPENROUTER_API_KEY"),
|
||||
"API_KEY": env("OPENROUTER_API_KEY", default=""),
|
||||
"TEMPERATURE": 0.2,
|
||||
"BATCH_SIZE": 5,
|
||||
"WORKERS": 2,
|
||||
@ -370,9 +370,7 @@ SLA_DEFAULTS = {
|
||||
COMPLAINT_LINK_EXPIRY_DAYS = env.int("COMPLAINT_LINK_EXPIRY_DAYS", default=7)
|
||||
|
||||
# AI Configuration (OpenRouter)
|
||||
OPENROUTER_API_KEY = env(
|
||||
"OPENROUTER_API_KEY", default="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"
|
||||
)
|
||||
OPENROUTER_API_KEY = env("OPENROUTER_API_KEY", default="")
|
||||
AI_MODEL = env("AI_MODEL", default="z-ai/glm-4.5-air:free")
|
||||
AI_TEMPERATURE = env.float("AI_TEMPERATURE", default=0.3)
|
||||
AI_MAX_TOKENS = env.int("AI_MAX_TOKENS", default=500)
|
||||
@ -445,20 +443,10 @@ EMAIL_LOGO_URL = env("EMAIL_LOGO_URL", default=f"{SITE_URL}{STATIC_URL}img/HH_P_
|
||||
|
||||
# HIS Integration Settings
|
||||
HIS_API_URL = env("HIS_API_URL", default="https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps")
|
||||
HIS_API_USERNAME = env("HIS_API_USERNAME", default="AlhhSUNZHippo")
|
||||
HIS_API_USERNAME = env("HIS_API_USERNAME", default="")
|
||||
HIS_API_PASSWORD = env("HIS_API_PASSWORD", default="")
|
||||
HIS_RATINGS_API_URL = env("HIS_RATINGS_API_URL", default="")
|
||||
|
||||
# Password contains special characters (*#$@PAlhh^2106) - set directly to avoid parsing issues
|
||||
# Check .env first, fall back to hardcoded default
|
||||
try:
|
||||
_env_password = env("HIS_API_PASSWORD")
|
||||
if _env_password and _env_password != "*":
|
||||
HIS_API_PASSWORD = _env_password
|
||||
else:
|
||||
HIS_API_PASSWORD = "*#$@PAlhh^2106"
|
||||
except Exception:
|
||||
HIS_API_PASSWORD = "*#$@PAlhh^2106"
|
||||
|
||||
# Security Settings
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
@ -497,20 +485,14 @@ TENANT_ISOLATION_LEVEL = "strict"
|
||||
|
||||
|
||||
# Social Media API Configuration
|
||||
YOUTUBE_API_KEY = env("YOUTUBE_API_KEY", default="AIzaSyAem20etP6GkRNMmCyI1pRJF7v8U_xDyMM")
|
||||
YOUTUBE_CHANNEL_ID = env("YOUTUBE_CHANNEL_ID", default="UCKoEfCXsm4_cQMtqJTvZUVQ")
|
||||
YOUTUBE_API_KEY = env("YOUTUBE_API_KEY", default="")
|
||||
YOUTUBE_CHANNEL_ID = env("YOUTUBE_CHANNEL_ID", default="")
|
||||
|
||||
FACEBOOK_PAGE_ID = env("FACEBOOK_PAGE_ID", default="938104059393026")
|
||||
FACEBOOK_ACCESS_TOKEN = env(
|
||||
"FACEBOOK_ACCESS_TOKEN",
|
||||
default="EAATrDf0UAS8BQWSKbljCUDMbluZBbxZCSWLJkZBGIviBtK8IQ7FDHfGQZBHHm7lsgLhZBL2trT3ZBGPtsWRjntFWQovhkhx726ZBexRZCKitEMhxAiZBmls7uX946432k963Myl6aYBzJzwLhSyygZAFOGP7iIIZANVf6GtLlvAnWn0NXRwZAYR0CNNUwCEEsZAAc",
|
||||
)
|
||||
FACEBOOK_PAGE_ID = env("FACEBOOK_PAGE_ID", default="")
|
||||
FACEBOOK_ACCESS_TOKEN = env("FACEBOOK_ACCESS_TOKEN", default="")
|
||||
|
||||
INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID", default="17841431861985364")
|
||||
INSTAGRAM_ACCESS_TOKEN = env(
|
||||
"INSTAGRAM_ACCESS_TOKEN",
|
||||
default="EAATrDf0UAS8BQWSKbljCUDMbluZBbxZCSWLJkZBGIviBtK8IQ7FDHfGQZBHHm7lsgLhZBL2trT3ZBGPtsWRjntFWQovhkhx726ZBexRZCKitEMhxAiZBmls7uX946432k963Myl6aYBzJzwLhSyygZAFOGP7iIIZANVf6GtLlvAnWn0NXRwZAYR0CNNUwCEEsZAAc",
|
||||
)
|
||||
INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID", default="")
|
||||
INSTAGRAM_ACCESS_TOKEN = env("INSTAGRAM_ACCESS_TOKEN", default="")
|
||||
|
||||
# Twitter/X Configuration
|
||||
TWITTER_BEARER_TOKEN = env("TWITTER_BEARER_TOKEN", default=None)
|
||||
@ -526,9 +508,6 @@ GOOGLE_TOKEN_FILE = env("GOOGLE_TOKEN_FILE", default="token.json")
|
||||
GOOGLE_LOCATIONS = env.list("GOOGLE_LOCATIONS", default=[])
|
||||
|
||||
# OpenRouter Configuration for AI Comment Analysis
|
||||
OPENROUTER_API_KEY = env(
|
||||
"OPENROUTER_API_KEY", default="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"
|
||||
)
|
||||
OPENROUTER_MODEL = env("OPENROUTER_MODEL", default="google/gemma-3-27b-it:free")
|
||||
ANALYSIS_BATCH_SIZE = env.int("ANALYSIS_BATCH_SIZE", default=2)
|
||||
ANALYSIS_ENABLED = env.bool("ANALYSIS_ENABLED", default=True)
|
||||
|
||||
@ -6,7 +6,7 @@ from .base import * # noqa
|
||||
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0", ".ngrok-free.app", "micha-nonparabolic-lovie.ngrok-free.dev"]
|
||||
ALLOWED_HOSTS = ["192.168.8.13","localhost", "127.0.0.1", "0.0.0.0", ".ngrok-free.app", "micha-nonparabolic-lovie.ngrok-free.dev"]
|
||||
|
||||
# Database - Use PostgreSQL even in dev for consistency
|
||||
# Override with SQLite if needed for quick local testing
|
||||
@ -41,9 +41,6 @@ CSRF_TRUSTED_ORIGINS = [
|
||||
CELERY_TASK_ALWAYS_EAGER = env.bool("CELERY_TASK_ALWAYS_EAGER", default=False)
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
|
||||
# CORS for development (if needed for frontend)
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
|
||||
# Django Debug Toolbar (optional)
|
||||
if DEBUG:
|
||||
INSTALLED_APPS += ["django_extensions"] # noqa
|
||||
|
||||
@ -3,19 +3,21 @@ Production settings for PX360 project.
|
||||
"""
|
||||
from .base import * # noqa
|
||||
|
||||
DEBUG = False
|
||||
DEBUG = env.bool("DEBUG", default=False)
|
||||
|
||||
# Hosts and trusted origins (driven by environment, works for any deploy target)
|
||||
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[]) + ["localhost", "127.0.0.1"]
|
||||
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
|
||||
|
||||
# Caddy handles SSL termination, so trust the X-Forwarded-Proto header
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
SECURE_SSL_REDIRECT = env.bool('SECURE_SSL_REDIRECT', default=False)
|
||||
SECURE_SSL_REDIRECT = env.bool('SECURE_SSL_REDIRECT', default=True)
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
|
||||
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[]) + ['localhost', '127.0.0.1']
|
||||
|
||||
DATABASES = {
|
||||
'default': env.db('DATABASE_URL')
|
||||
}
|
||||
|
||||
4
deploy/.gitignore
vendored
Normal file
4
deploy/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
# Real env files contain secrets — never commit them.
|
||||
# The *.example files are committed templates.
|
||||
env.dev
|
||||
env.prod
|
||||
78
deploy/compose.dev.yml
Normal file
78
deploy/compose.dev.yml
Normal file
@ -0,0 +1,78 @@
|
||||
# PX360 — DEV compose for `target deploy` (bundled postgres + redis)
|
||||
#
|
||||
# target injects into the `web_app` service on deploy:
|
||||
# - container_name: <app>-web_app (so the shared Caddy can route to it)
|
||||
# - networks: [target-proxy, default]
|
||||
# - removes any `ports:` (the shared Caddy handles ingress)
|
||||
#
|
||||
# The web service MUST be named `web_app` — target's Caddy injection looks for it.
|
||||
# `{{ .ImageTag }}` is replaced by target with the freshly built image tag.
|
||||
#
|
||||
# env file (in the deploy dir ~/.target/hh-dev/):
|
||||
# .env — ALL config + secrets, shipped by `target deploy` (deploy/env.dev)
|
||||
services:
|
||||
web_app:
|
||||
image: {{ .ImageTag }}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- media_volume:/app/media
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8888:8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
celery:
|
||||
image: {{ .ImageTag }}
|
||||
restart: unless-stopped
|
||||
command: celery -A config worker -l info --concurrency=2
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
# celery-beat:
|
||||
# image: {{ .ImageTag }}
|
||||
# restart: unless-stopped
|
||||
# command: celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
|
||||
# env_file:
|
||||
# - .env
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5432:5432"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-px360}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
redis_data:
|
||||
media_volume:
|
||||
53
deploy/compose.prod.yml
Normal file
53
deploy/compose.prod.yml
Normal file
@ -0,0 +1,53 @@
|
||||
# PX360 — PROD compose for `target deploy` (external PostgreSQL, bundled redis)
|
||||
#
|
||||
# Same injection rules as compose.dev.yml: web service must be named `web_app`,
|
||||
# target adds container_name + networks and removes ports.
|
||||
#
|
||||
# The database is EXTERNAL (managed elsewhere). DATABASE_URL (in .env) is used by
|
||||
# Django; DB_HOST/DB_PORT/DB_USER (in .env) are used by the entrypoint's pg_isready.
|
||||
#
|
||||
# env file (in the deploy dir ~/.target/hh-prod/):
|
||||
# .env — ALL config + secrets, shipped by `target deploy` (deploy/env.prod)
|
||||
services:
|
||||
web_app:
|
||||
image: {{ .ImageTag }}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- media_volume:/app/media
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
celery:
|
||||
image: {{ .ImageTag }}
|
||||
restart: unless-stopped
|
||||
command: celery -A config worker -l info --concurrency=4
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
celery-beat:
|
||||
image: {{ .ImageTag }}
|
||||
restart: unless-stopped
|
||||
command: celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
media_volume:
|
||||
100
deploy/env.dev.example
Normal file
100
deploy/env.dev.example
Normal file
@ -0,0 +1,100 @@
|
||||
# ============================================================
|
||||
# PX360 DEV environment — ALL config + secrets in ONE file.
|
||||
# Shipped to the server as ~/.target/hh-dev/.env by `target deploy`
|
||||
# (see deploy/target.dev.yaml → env_file).
|
||||
#
|
||||
# This is the committed TEMPLATE. To create your real (gitignored) file:
|
||||
# make init-dev # copies this to deploy/env.dev
|
||||
# $EDITOR deploy/env.dev # fill in the secrets marked below
|
||||
# ============================================================
|
||||
|
||||
# --- Django ---
|
||||
DJANGO_SETTINGS_MODULE=config.settings.prod
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=django.ismailmosaibrahim.com,.ismailmosaibrahim.com
|
||||
CSRF_TRUSTED_ORIGINS=https://django.ismailmosaibrahim.com,
|
||||
ADMIN_URL=admin/
|
||||
SECRET_KEY=CHANGE-ME-run: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
|
||||
|
||||
# --- Database (dev: bundled postgres in compose.dev.yml) ---
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
DB_USER=px360
|
||||
POSTGRES_DB=px360
|
||||
POSTGRES_USER=px360
|
||||
# IMPORTANT: POSTGRES_PASSWORD must match the password embedded in DATABASE_URL.
|
||||
DATABASE_URL=postgresql://px360:CHANGE-ME@db:5432/px360
|
||||
POSTGRES_PASSWORD=CHANGE-ME
|
||||
|
||||
# --- Celery ---
|
||||
CELERY_BROKER_URL=redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
CELERY_TASK_ALWAYS_EAGER=False
|
||||
|
||||
# --- AI ---
|
||||
AI_MODEL=openrouter/deepseek/deepseek-v4-flash
|
||||
AI_TEMPERATURE=0.3
|
||||
AI_MAX_TOKENS=500
|
||||
#OPENROUTER_MODEL=google/gemma-3-27b-it:free
|
||||
ANALYSIS_ENABLED=True
|
||||
ANALYSIS_BATCH_SIZE=2
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# --- Notifications (dev: console providers) ---
|
||||
SMS_ENABLED=False
|
||||
SMS_PROVIDER=console
|
||||
WHATSAPP_ENABLED=False
|
||||
WHATSAPP_PROVIDER=console
|
||||
EMAIL_ENABLED=True
|
||||
EMAIL_PROVIDER=console
|
||||
|
||||
# --- Email (dev: console) ---
|
||||
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
|
||||
DEFAULT_FROM_EMAIL=noreply@px360.sa
|
||||
|
||||
# --- HIS API ---
|
||||
HIS_API_URL=https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps
|
||||
HIS_API_USERNAME=
|
||||
HIS_API_PASSWORD=
|
||||
HIS_API_KEY=
|
||||
|
||||
# --- Twilio ---
|
||||
TWILIO_ACCOUNT_SID=
|
||||
TWILIO_AUTH_TOKEN=
|
||||
TWILIO_PHONE_NUMBER=
|
||||
TWILIO_MESSAGING_SERVICE_SID=
|
||||
|
||||
# --- Mshastra SMS ---
|
||||
MSHASTRA_USERNAME=
|
||||
MSHASTRA_PASSWORD=
|
||||
MSHASTRA_SENDER_ID=
|
||||
|
||||
# --- External notification APIs ---
|
||||
EMAIL_API_KEY=
|
||||
SMS_API_KEY=
|
||||
|
||||
# --- Social media ---
|
||||
YOUTUBE_API_KEY=
|
||||
YOUTUBE_CHANNEL_ID=
|
||||
FACEBOOK_PAGE_ID=
|
||||
FACEBOOK_ACCESS_TOKEN=
|
||||
INSTAGRAM_ACCOUNT_ID=
|
||||
INSTAGRAM_ACCESS_TOKEN=
|
||||
TWITTER_BEARER_TOKEN=
|
||||
TWITTER_USERNAME=
|
||||
LINKEDIN_ACCESS_TOKEN=
|
||||
LINKEDIN_ORGANIZATION_ID=
|
||||
|
||||
# --- Google Reviews ---
|
||||
GOOGLE_CREDENTIALS_FILE=client_secret.json
|
||||
GOOGLE_TOKEN_FILE=token.json
|
||||
GOOGLE_LOCATIONS=
|
||||
|
||||
# --- Other integration APIs ---
|
||||
MOH_API_URL=
|
||||
MOH_API_KEY=
|
||||
CHI_API_URL=
|
||||
CHI_API_KEY=
|
||||
|
||||
# --- Dev only: redirect all SMS to this number when DEBUG=True ---
|
||||
DEV_SMS_RECIPIENT=
|
||||
103
deploy/env.prod.example
Normal file
103
deploy/env.prod.example
Normal file
@ -0,0 +1,103 @@
|
||||
# ============================================================
|
||||
# PX360 PROD environment — ALL config + secrets in ONE file.
|
||||
# Shipped to the server as ~/.target/hh-prod/.env by `target deploy`
|
||||
# (see deploy/target.prod.yaml → env_file).
|
||||
#
|
||||
# This is the committed TEMPLATE. To create your real (gitignored) file:
|
||||
# make init-prod # copies this to deploy/env.prod
|
||||
# $EDITOR deploy/env.prod # fill in the secrets marked below
|
||||
# ============================================================
|
||||
|
||||
# --- Django ---
|
||||
DJANGO_SETTINGS_MODULE=config.settings.prod
|
||||
DEBUG=False
|
||||
ALLOWED_HOSTS=your-production-domain.com
|
||||
CSRF_TRUSTED_ORIGINS=https://your-production-domain.com
|
||||
ADMIN_URL=CHANGE-ME/
|
||||
SECURE_SSL_REDIRECT=True
|
||||
SECRET_KEY=CHANGE-ME-run: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
|
||||
|
||||
# --- Database (prod: EXTERNAL postgres) ---
|
||||
DB_HOST=your-db-host
|
||||
DB_PORT=5432
|
||||
DB_USER=px360
|
||||
POSTGRES_DB=px360
|
||||
POSTGRES_USER=px360
|
||||
# IMPORTANT: POSTGRES_PASSWORD must match the password embedded in DATABASE_URL.
|
||||
DATABASE_URL=postgresql://px360:CHANGE-ME@your-db-host:5432/px360
|
||||
POSTGRES_PASSWORD=CHANGE-ME
|
||||
|
||||
# --- Celery ---
|
||||
CELERY_BROKER_URL=redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
CELERY_TASK_ALWAYS_EAGER=False
|
||||
|
||||
# --- AI ---
|
||||
AI_MODEL=z-ai/glm-4.5-air:free
|
||||
AI_TEMPERATURE=0.3
|
||||
AI_MAX_TOKENS=500
|
||||
OPENROUTER_MODEL=google/gemma-3-27b-it:free
|
||||
ANALYSIS_ENABLED=True
|
||||
ANALYSIS_BATCH_SIZE=10
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# --- Notifications ---
|
||||
SMS_ENABLED=False
|
||||
SMS_PROVIDER=console
|
||||
WHATSAPP_ENABLED=False
|
||||
WHATSAPP_PROVIDER=console
|
||||
EMAIL_ENABLED=True
|
||||
EMAIL_PROVIDER=console
|
||||
|
||||
# --- Email (prod: SMTP) ---
|
||||
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=True
|
||||
EMAIL_HOST_USER=
|
||||
EMAIL_HOST_PASSWORD=
|
||||
DEFAULT_FROM_EMAIL=noreply@px360.sa
|
||||
|
||||
# --- HIS API ---
|
||||
HIS_API_URL=https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps
|
||||
HIS_API_USERNAME=
|
||||
HIS_API_PASSWORD=
|
||||
HIS_API_KEY=
|
||||
|
||||
# --- Twilio ---
|
||||
TWILIO_ACCOUNT_SID=
|
||||
TWILIO_AUTH_TOKEN=
|
||||
TWILIO_PHONE_NUMBER=
|
||||
TWILIO_MESSAGING_SERVICE_SID=
|
||||
|
||||
# --- Mshastra SMS ---
|
||||
MSHASTRA_USERNAME=
|
||||
MSHASTRA_PASSWORD=
|
||||
MSHASTRA_SENDER_ID=
|
||||
|
||||
# --- External notification APIs ---
|
||||
EMAIL_API_KEY=
|
||||
SMS_API_KEY=
|
||||
|
||||
# --- Social media ---
|
||||
YOUTUBE_API_KEY=
|
||||
YOUTUBE_CHANNEL_ID=
|
||||
FACEBOOK_PAGE_ID=
|
||||
FACEBOOK_ACCESS_TOKEN=
|
||||
INSTAGRAM_ACCOUNT_ID=
|
||||
INSTAGRAM_ACCESS_TOKEN=
|
||||
TWITTER_BEARER_TOKEN=
|
||||
TWITTER_USERNAME=
|
||||
LINKEDIN_ACCESS_TOKEN=
|
||||
LINKEDIN_ORGANIZATION_ID=
|
||||
|
||||
# --- Google Reviews ---
|
||||
GOOGLE_CREDENTIALS_FILE=client_secret.json
|
||||
GOOGLE_TOKEN_FILE=token.json
|
||||
GOOGLE_LOCATIONS=
|
||||
|
||||
# --- Other integration APIs ---
|
||||
MOH_API_URL=
|
||||
MOH_API_KEY=
|
||||
CHI_API_URL=
|
||||
CHI_API_KEY=
|
||||
24
deploy/target.dev.yaml
Normal file
24
deploy/target.dev.yaml
Normal file
@ -0,0 +1,24 @@
|
||||
# PX360 — DEV deploy target.
|
||||
# Usage: `make deploy-dev` (runs: target deploy --config deploy/target.dev.yaml)
|
||||
app:
|
||||
name: "hh-dev" # isolated project dir: ~/.target/hh-dev/
|
||||
domain: "test.ismailmosaibrahim.com" # shared Caddy routes this domain → web_app
|
||||
port: 8888
|
||||
|
||||
server:
|
||||
ip: "10.132.16.5"
|
||||
user: "ahh-ops"
|
||||
ssh_key: "~/.ssh/id_rsa"
|
||||
|
||||
build:
|
||||
engine: "local"
|
||||
context: "." # repo root (where the Dockerfile lives)
|
||||
|
||||
deploy:
|
||||
use_caddy: false # shared target-caddy handles HTTPS
|
||||
caddy_email: "ismail.mosa.ibrahim@gmail.com"
|
||||
compose_file: "deploy/compose.dev.yml" # user-provided compose (uses {{ .ImageTag }})
|
||||
env_file: "deploy/env.dev" # shipped to ~/.target/hh-dev/.env each deploy
|
||||
pre_deploy: "python manage.py migrate --noinput" # runs before the swap (old keeps serving)
|
||||
cleanup: ["remote", "local"]
|
||||
keep_versions: 2
|
||||
26
deploy/target.prod.yaml
Normal file
26
deploy/target.prod.yaml
Normal file
@ -0,0 +1,26 @@
|
||||
# PX360 — PROD deploy target.
|
||||
# Usage: `make deploy-prod` (runs: target deploy --config deploy/target.prod.yaml)
|
||||
#
|
||||
# TODO: fill in the real prod server IP + domain below before first deploy.
|
||||
app:
|
||||
name: "hh-prod" # isolated project dir: ~/.target/hh-prod/
|
||||
domain: "your-production-domain.com" # TODO: real prod domain
|
||||
port: 8000
|
||||
|
||||
server:
|
||||
ip: "10.10.1.132" # TODO: real prod server IP
|
||||
user: "root"
|
||||
ssh_key: "~/.ssh/id_rsa"
|
||||
|
||||
build:
|
||||
engine: "local"
|
||||
context: "."
|
||||
|
||||
deploy:
|
||||
use_caddy: true
|
||||
caddy_email: "ismail.mosa.ibrahim@gmail.com"
|
||||
compose_file: "deploy/compose.prod.yml"
|
||||
env_file: "deploy/env.prod" # shipped to ~/.target/hh-prod/.env each deploy
|
||||
pre_deploy: "python manage.py migrate --noinput" # runs before the swap (old keeps serving)
|
||||
cleanup: ["remote", "local"]
|
||||
keep_versions: 3
|
||||
@ -50,6 +50,10 @@ EXCLUDE_TABLES=(
|
||||
|
||||
# Flags
|
||||
DEPLOY=false
|
||||
CONTAINER="" # DB container name (e.g. hh-dev-db-1) → restore via docker exec
|
||||
MEDIA_CONTAINER="" # app container name (e.g. hh-dev-web_app) → media restore via docker exec
|
||||
PROJECT="" # compose project name (e.g. hh-dev) → for stop/start app containers
|
||||
NO_MEDIA=false # skip media dump+restore
|
||||
|
||||
# ============================================================
|
||||
# Parse arguments
|
||||
@ -61,15 +65,39 @@ while [[ $# -gt 0 ]]; do
|
||||
PROD_SSH="$2"
|
||||
shift 2
|
||||
;;
|
||||
--container)
|
||||
CONTAINER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--media-container)
|
||||
MEDIA_CONTAINER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--project)
|
||||
PROJECT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--no-media)
|
||||
NO_MEDIA=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: ./scripts/db_sync.sh [--deploy user@db-server-ip]"
|
||||
echo "Usage: ./scripts/db_sync.sh [options] [--deploy user@host]"
|
||||
echo ""
|
||||
echo " Without --deploy: Creates a local dump only (dry-run)"
|
||||
echo " With --deploy: Dumps locally, copies to prod DB server, restores"
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo " ./scripts/db_sync.sh # local dump"
|
||||
echo " ./scripts/db_sync.sh --deploy root@10.10.1.100 # full deploy"
|
||||
echo "Containerized target (DB runs in a Docker container on the server):"
|
||||
echo " --container <name> DB container (e.g. hh-dev-db-1)"
|
||||
echo " --media-container <n> app container for media (e.g. hh-dev-web_app)"
|
||||
echo " --project <name> compose project (e.g. hh-dev) for stop/start app"
|
||||
echo " --no-media skip media dump+restore"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " ./scripts/db_sync.sh # local dump"
|
||||
echo " ./scripts/db_sync.sh --deploy root@10.10.1.100 # host-installed pg tools"
|
||||
echo " ./scripts/db_sync.sh --deploy root@host \\\\"
|
||||
echo " --container hh-dev-db-1 --media-container hh-dev-web_app --project hh-dev"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
@ -89,7 +117,9 @@ if [ "$DEPLOY" = true ]; then
|
||||
fi
|
||||
|
||||
ENV_FILE="$PROJECT_DIR/.env.production"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
# In container mode the DB lives in the container; no DB_HOST needed.
|
||||
# Only read .env.production for host-mode (no --container) or if it exists.
|
||||
if [ -z "$CONTAINER" ] && [ -f "$ENV_FILE" ]; then
|
||||
PROD_DB_HOST=$(grep -E "^DB_HOST=" "$ENV_FILE" | cut -d= -f2 || echo "")
|
||||
PROD_DB_USER=$(grep -E "^DB_USER=" "$ENV_FILE" | cut -d= -f2 || echo "px360")
|
||||
PROD_DB_NAME=$(grep -E "^DB_NAME=" "$ENV_FILE" | cut -d= -f2 || echo "px360")
|
||||
@ -98,19 +128,30 @@ if [ "$DEPLOY" = true ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$PROD_DB_HOST" ]; then
|
||||
if [ -z "$CONTAINER" ] && [ -z "$PROD_DB_HOST" ]; then
|
||||
echo -e "${YELLOW}Warning: DB_HOST not found in .env.production${NC}"
|
||||
echo -e "Enter DB server IP/hostname: "
|
||||
read -r PROD_DB_HOST
|
||||
fi
|
||||
|
||||
# Derive compose project from media container (target names it <project>-web_app) if not given
|
||||
if [ -n "$MEDIA_CONTAINER" ] && [ -z "$PROJECT" ]; then
|
||||
PROJECT="${MEDIA_CONTAINER%-web_app}"
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}Production target:${NC}"
|
||||
echo -e " SSH: $PROD_SSH"
|
||||
echo -e " DB Host: $PROD_DB_HOST"
|
||||
echo -e " DB User: $PROD_DB_USER"
|
||||
echo -e " DB Name: $PROD_DB_NAME"
|
||||
echo -e " SSH: $PROD_SSH"
|
||||
if [ -n "$CONTAINER" ]; then
|
||||
echo -e " DB container: $CONTAINER"
|
||||
echo -e " Media target: ${MEDIA_CONTAINER:-<none>}"
|
||||
echo -e " Project: ${PROJECT:-<none>}"
|
||||
else
|
||||
echo -e " DB Host: $PROD_DB_HOST"
|
||||
fi
|
||||
echo -e " DB User: $PROD_DB_USER"
|
||||
echo -e " DB Name: $PROD_DB_NAME"
|
||||
echo ""
|
||||
echo -e "${YELLOW}This will OVERWRITE production database data. Continue? (y/N)${NC}"
|
||||
echo -e "${YELLOW}This will OVERWRITE the target database data. Continue? (y/N)${NC}"
|
||||
read -r CONFIRM
|
||||
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
@ -186,7 +227,10 @@ fi
|
||||
echo ""
|
||||
echo -e "${BLUE}[2/5] Dumping media files...${NC}"
|
||||
|
||||
if [ -d "$PROJECT_DIR/media" ] && [ "$(ls -A "$PROJECT_DIR/media" 2>/dev/null)" ]; then
|
||||
if [ "$NO_MEDIA" = true ]; then
|
||||
echo -e "${YELLOW} Skipped (--no-media).${NC}"
|
||||
MEDIA_DUMP=""
|
||||
elif [ -d "$PROJECT_DIR/media" ] && [ "$(ls -A "$PROJECT_DIR/media" 2>/dev/null)" ]; then
|
||||
tar -czf "$MEDIA_DUMP" -C "$PROJECT_DIR/media" .
|
||||
MEDIA_SIZE=$(du -h "$MEDIA_DUMP" | cut -f1)
|
||||
echo -e "${GREEN} Media dump: $MEDIA_DUMP ($MEDIA_SIZE)${NC}"
|
||||
@ -250,49 +294,113 @@ fi
|
||||
echo ""
|
||||
echo -e "${BLUE}[5/5] Restoring on production database...${NC}"
|
||||
|
||||
ssh "$PROD_SSH" bash -s << REMOTE_SCRIPT
|
||||
set -e
|
||||
DB_USER="${PROD_DB_USER}"
|
||||
DB_NAME="${PROD_DB_NAME}"
|
||||
DB_HOST="${PROD_DB_HOST}"
|
||||
if [ -n "$CONTAINER" ]; then
|
||||
# ----- Container mode: restore via docker exec into the DB container -----
|
||||
|
||||
echo " Creating database if not exists..."
|
||||
createdb -U "\$DB_USER" "\$DB_NAME" 2>/dev/null || echo " Database already exists."
|
||||
# Stop app containers so nothing holds connections during --clean restore
|
||||
if [ -n "$PROJECT" ]; then
|
||||
echo -e "${BLUE} Stopping app containers (project $PROJECT)...${NC}"
|
||||
ssh "$PROD_SSH" "cd \$HOME/.target/${PROJECT} && docker compose -p ${PROJECT} stop web_app celery celery-beat 2>&1 || true"
|
||||
fi
|
||||
|
||||
echo " Restoring database (this may take a minute)..."
|
||||
pg_restore -U "\$DB_USER" -d "\$DB_NAME" \
|
||||
--no-owner --no-privileges --clean --if-exists \
|
||||
--no-tablespaces \
|
||||
/tmp/px360_restore.dump 2>&1 | grep -v "does not exist" || true
|
||||
ssh "$PROD_SSH" bash -s << REMOTE_SCRIPT
|
||||
set -e
|
||||
DB_USER="${PROD_DB_USER}"
|
||||
DB_NAME="${PROD_DB_NAME}"
|
||||
DB_CONTAINER="${CONTAINER}"
|
||||
|
||||
echo " Verifying restore..."
|
||||
psql -U "\$DB_USER" -d "\$DB_NAME" -t -c "
|
||||
SELECT ' Total tables: ' || count(*) FROM pg_stat_user_tables;
|
||||
" 2>/dev/null || true
|
||||
echo " Copying dump into DB container \$DB_CONTAINER..."
|
||||
docker cp /tmp/px360_restore.dump "\$DB_CONTAINER":/tmp/px360_restore.dump
|
||||
|
||||
psql -U "\$DB_USER" -d "\$DB_NAME" -t -c "
|
||||
SELECT ' ' || relname || ': ' || n_live_tup
|
||||
FROM pg_stat_user_tables
|
||||
WHERE n_live_tup > 0
|
||||
ORDER BY n_live_tup DESC
|
||||
LIMIT 10;
|
||||
" 2>/dev/null || true
|
||||
echo " Restoring database (this may take a minute)..."
|
||||
docker exec "\$DB_CONTAINER" pg_restore -U "\$DB_USER" -d "\$DB_NAME" \
|
||||
--no-owner --no-privileges --clean --if-exists --no-tablespaces \
|
||||
/tmp/px360_restore.dump 2>&1 | grep -v "does not exist" || true
|
||||
|
||||
echo " Cleaning up temp files..."
|
||||
rm -f /tmp/px360_restore.dump /tmp/px360_media.tar.gz
|
||||
echo " Verifying restore..."
|
||||
docker exec "\$DB_CONTAINER" psql -U "\$DB_USER" -d "\$DB_NAME" -t -c "
|
||||
SELECT ' Total tables: ' || count(*) FROM pg_stat_user_tables;
|
||||
" 2>/dev/null || true
|
||||
docker exec "\$DB_CONTAINER" psql -U "\$DB_USER" -d "\$DB_NAME" -t -c "
|
||||
SELECT ' ' || relname || ': ' || n_live_tup
|
||||
FROM pg_stat_user_tables
|
||||
WHERE n_live_tup > 0
|
||||
ORDER BY n_live_tup DESC
|
||||
LIMIT 10;
|
||||
" 2>/dev/null || true
|
||||
|
||||
echo " Restore complete."
|
||||
echo " Cleaning DB container temp..."
|
||||
docker exec "\$DB_CONTAINER" rm -f /tmp/px360_restore.dump
|
||||
echo " DB restore complete."
|
||||
REMOTE_SCRIPT
|
||||
|
||||
# ============================================================
|
||||
# Media restore on app server (if separate from DB server)
|
||||
# ============================================================
|
||||
if [ -n "$MEDIA_DUMP" ]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}Note: Media files were uploaded to the DB server at /tmp/px360_media.tar.gz${NC}"
|
||||
echo -e "${YELLOW}If your app server is separate, copy and extract there:${NC}"
|
||||
echo -e " scp ${PROD_SSH}:/tmp/px360_media.tar.gz app-server:/tmp/"
|
||||
echo -e " ssh app-server 'mkdir -p /path/to/media && tar -xzf /tmp/px360_media.tar.gz -C /path/to/media'"
|
||||
# Start app containers back up
|
||||
if [ -n "$PROJECT" ]; then
|
||||
echo -e "${BLUE} Starting app containers...${NC}"
|
||||
ssh "$PROD_SSH" "cd \$HOME/.target/${PROJECT} && docker compose -p ${PROJECT} up -d 2>&1 || true"
|
||||
fi
|
||||
|
||||
# Media restore (web_app must be running, so after `up -d`)
|
||||
if [ -n "$MEDIA_DUMP" ] && [ -n "$MEDIA_CONTAINER" ]; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Restoring media into ${MEDIA_CONTAINER}...${NC}"
|
||||
ssh "$PROD_SSH" bash -s << REMOTE_MEDIA
|
||||
set -e
|
||||
MC="${MEDIA_CONTAINER}"
|
||||
docker cp /tmp/px360_media.tar.gz "\$MC":/tmp/px360_media.tar.gz
|
||||
# web_app runs as appuser (UID 1000); chown so the app can write uploads later
|
||||
docker exec "\$MC" sh -c "mkdir -p /app/media && tar -xzf /tmp/px360_media.tar.gz -C /app/media && chown -R 1000:1000 /app/media"
|
||||
docker exec "\$MC" rm -f /tmp/px360_media.tar.gz
|
||||
echo " Media restored to \$MC:/app/media"
|
||||
REMOTE_MEDIA
|
||||
fi
|
||||
|
||||
# Clean up host temp copies
|
||||
ssh "$PROD_SSH" "rm -f /tmp/px360_restore.dump /tmp/px360_media.tar.gz" 2>/dev/null || true
|
||||
|
||||
else
|
||||
# ----- Host mode: host-installed pg client tools (original behavior) -----
|
||||
ssh "$PROD_SSH" bash -s << REMOTE_SCRIPT
|
||||
set -e
|
||||
DB_USER="${PROD_DB_USER}"
|
||||
DB_NAME="${PROD_DB_NAME}"
|
||||
DB_HOST="${PROD_DB_HOST}"
|
||||
|
||||
echo " Creating database if not exists..."
|
||||
createdb -U "\$DB_USER" "\$DB_NAME" 2>/dev/null || echo " Database already exists."
|
||||
|
||||
echo " Restoring database (this may take a minute)..."
|
||||
pg_restore -U "\$DB_USER" -d "\$DB_NAME" \
|
||||
--no-owner --no-privileges --clean --if-exists \
|
||||
--no-tablespaces \
|
||||
/tmp/px360_restore.dump 2>&1 | grep -v "does not exist" || true
|
||||
|
||||
echo " Verifying restore..."
|
||||
psql -U "\$DB_USER" -d "\$DB_NAME" -t -c "
|
||||
SELECT ' Total tables: ' || count(*) FROM pg_stat_user_tables;
|
||||
" 2>/dev/null || true
|
||||
|
||||
psql -U "\$DB_USER" -d "\$DB_NAME" -t -c "
|
||||
SELECT ' ' || relname || ': ' || n_live_tup
|
||||
FROM pg_stat_user_tables
|
||||
WHERE n_live_tup > 0
|
||||
ORDER BY n_live_tup DESC
|
||||
LIMIT 10;
|
||||
" 2>/dev/null || true
|
||||
|
||||
echo " Cleaning up temp files..."
|
||||
rm -f /tmp/px360_restore.dump /tmp/px360_media.tar.gz
|
||||
|
||||
echo " Restore complete."
|
||||
REMOTE_SCRIPT
|
||||
|
||||
if [ -n "$MEDIA_DUMP" ]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}Note: Media files were uploaded to the DB server at /tmp/px360_media.tar.gz${NC}"
|
||||
echo -e "${YELLOW}Restore manually on the app server:${NC}"
|
||||
echo -e " scp ${PROD_SSH}:/tmp/px360_media.tar.gz app-server:/tmp/"
|
||||
echo -e " ssh app-server 'mkdir -p /path/to/media && tar -xzf /tmp/px360_media.tar.gz -C /path/to/media'"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
|
||||
BIN
static/images/stamps/Artboard 61@2x.png
Normal file
BIN
static/images/stamps/Artboard 61@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
BIN
static/images/stamps/Artboard 62@2x.png
Normal file
BIN
static/images/stamps/Artboard 62@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
BIN
static/images/stamps/stamp.png
Normal file
BIN
static/images/stamps/stamp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
@ -17,6 +17,25 @@
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% if linked_complaint_id %}
|
||||
<input type="hidden" name="complaint_id" value="{{ linked_complaint_id }}">
|
||||
{% endif %}
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="m-6 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||
<p class="text-sm font-bold text-red-700 mb-2">{% trans "Please fix the following errors:" %}</p>
|
||||
<ul class="text-sm text-red-600 space-y-1">
|
||||
{% for field in form %}
|
||||
{% for error in field.errors %}
|
||||
<li>{{ field.label }}: {{ error }}</li>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% for error in form.non_field_errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@ -52,42 +71,48 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="due_date" class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Due Date" %}</label>
|
||||
<input type="date" class="w-full px-4 py-3 border border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-navy focus:border-transparent transition" id="due_date" name="due_date" value="{{ action.due_date|date:'Y-m-d'|default:'' }}">
|
||||
<label for="due_at" class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Due Date" %} <span class="text-navy">*</span></label>
|
||||
<input type="datetime-local" class="w-full px-4 py-3 border border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-navy focus:border-transparent transition" id="due_at" name="due_at" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="related_complaint" class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Related Complaint" %}</label>
|
||||
<select class="w-full px-4 py-3 border border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-navy focus:border-transparent transition" id="related_complaint" name="related_complaint">
|
||||
<option value="">{% trans "Select Complaint" %}</option>
|
||||
{% for complaint in complaints %}
|
||||
<option value="{{ complaint.id }}" {% if action.related_complaint_id == complaint.id %}selected{% endif %}>{{ complaint.ticket_number }} - {{ complaint.category.name }}</option>
|
||||
{% endfor %}
|
||||
<label for="category" class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Category" %} <span class="text-navy">*</span></label>
|
||||
<select class="w-full px-4 py-3 border border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-navy focus:border-transparent transition" id="category" name="category" required>
|
||||
<option value="process_improvement" selected>{% trans "Process Improvement" %}</option>
|
||||
<option value="clinical_quality">{% trans "Clinical Quality" %}</option>
|
||||
<option value="patient_safety">{% trans "Patient Safety" %}</option>
|
||||
<option value="service_quality">{% trans "Service Quality" %}</option>
|
||||
<option value="staff_behavior">{% trans "Staff Behavior" %}</option>
|
||||
<option value="facility">{% trans "Facility & Environment" %}</option>
|
||||
<option value="other">{% trans "Other" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label for="related_survey" class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Related Survey" %}</label>
|
||||
<select class="w-full px-4 py-3 border border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-navy focus:border-transparent transition" id="related_survey" name="related_survey">
|
||||
<option value="">{% trans "Select Survey" %}</option>
|
||||
{% for survey in surveys %}
|
||||
<option value="{{ survey.id }}" {% if action.related_survey_id == survey.id %}selected{% endif %}>{{ survey.patient_name }}</option>
|
||||
{% endfor %}
|
||||
<label for="severity" class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Severity" %} <span class="text-navy">*</span></label>
|
||||
<select class="w-full px-4 py-3 border border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-navy focus:border-transparent transition" id="severity" name="severity" required>
|
||||
<option value="low">{% trans "Low" %}</option>
|
||||
<option value="medium" selected>{% trans "Medium" %}</option>
|
||||
<option value="high">{% trans "High" %}</option>
|
||||
<option value="critical">{% trans "Critical" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if linked_complaint %}
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Status" %}</label>
|
||||
<select class="w-full px-4 py-3 border border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-navy focus:border-transparent transition" id="status" name="status">
|
||||
<option value="pending" {% if action.status == 'pending' or not action %}selected{% endif %}>{% trans "Pending" %}</option>
|
||||
<option value="in_progress" {% if action.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
|
||||
<option value="completed" {% if action.status == 'completed' %}selected{% endif %}>{% trans "Completed" %}</option>
|
||||
<option value="cancelled" {% if action.status == 'cancelled' %}selected{% endif %}>{% trans "Cancelled" %}</option>
|
||||
</select>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Related Complaint" %}</label>
|
||||
<div class="flex items-center gap-2 px-4 py-3 bg-blue-50 border border-blue-200 rounded-xl">
|
||||
<i data-lucide="link" class="w-4 h-4 text-blue-600 shrink-0"></i>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-bold text-navy truncate">{{ linked_complaint.reference_number }}</p>
|
||||
<p class="text-xs text-slate-500 truncate">{{ linked_complaint.title|truncatechars:60 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3">
|
||||
|
||||
@ -6,6 +6,16 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.tab-active {
|
||||
border-bottom: 3px solid #005696;
|
||||
color: #005696;
|
||||
font-weight: 700;
|
||||
}
|
||||
.tab-inactive {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tab-inactive:hover { color: #005696; }
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 30px;
|
||||
@ -28,14 +38,14 @@
|
||||
position: absolute;
|
||||
left: -26px;
|
||||
top: 5px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
background: white;
|
||||
border: 3px solid #f57c00;
|
||||
z-index: 1;
|
||||
}
|
||||
.timeline-item.status_change::before { border-color: #1e3a5f; }
|
||||
.timeline-item.status_change::before { border-color: #005696; }
|
||||
.timeline-item.assignment::before { border-color: #388e3c; }
|
||||
.timeline-item.escalation::before { border-color: #d32f2f; }
|
||||
.timeline-item.approval::before { border-color: #4caf50; }
|
||||
@ -44,182 +54,180 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Back Button -->
|
||||
<div class="mb-6">
|
||||
<a href="{% url 'actions:action_list' %}" class="inline-flex items-center gap-2 text-slate hover:text-navy transition font-medium">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to Actions" %}
|
||||
</a>
|
||||
</div>
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
|
||||
|
||||
<!-- Action Header -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 p-6 mb-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
|
||||
<div class="flex-1">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-3">
|
||||
<h1 class="text-2xl font-bold text-navy">{{ action.title }}</h1>
|
||||
{% if action.status == 'completed' %}
|
||||
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-green-100 text-green-600">{{ action.get_status_display }}</span>
|
||||
{% elif action.status == 'in_progress' %}
|
||||
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-600">{{ action.get_status_display }}</span>
|
||||
{% elif action.status == 'cancelled' %}
|
||||
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-gray-100 text-gray-600">{{ action.get_status_display }}</span>
|
||||
{% else %}
|
||||
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-yellow-100 text-yellow-600">{{ action.get_status_display }}</span>
|
||||
{% endif %}
|
||||
|
||||
{% if action.priority == 'high' %}
|
||||
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-red-100 text-red-600">{{ action.get_priority_display }}</span>
|
||||
{% elif action.priority == 'medium' %}
|
||||
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-orange-100 text-orange-600">{{ action.get_priority_display }}</span>
|
||||
{% else %}
|
||||
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-green-100 text-green-600">{{ action.get_priority_display }}</span>
|
||||
{% endif %}
|
||||
|
||||
{% if action.escalation_level > 0 %}
|
||||
<span class="px-3 py-1 rounded-full text-sm font-semibold bg-red-500 text-white">
|
||||
<i data-lucide="arrow-up-circle" class="w-3 h-3 inline me-1"></i>Level {{ action.escalation_level }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm text-slate">
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="hash" class="w-4 h-4"></i>
|
||||
<strong>ID:</strong> {{ action.id|slice:":8" }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="building" class="w-4 h-4"></i>
|
||||
{{ action.hospital.get_localized_name }}
|
||||
</span>
|
||||
{% if action.department %}
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="door-open" class="w-4 h-4"></i>
|
||||
{{ action.department.get_localized_name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SLA Progress -->
|
||||
<div class="lg:w-72 p-4 rounded-xl {% if action.is_overdue %}bg-red-50 border border-red-200{% else %}bg-slate-50{% endif %}">
|
||||
<div class="flex items-center gap-2 mb-3 text-sm font-semibold text-slate">
|
||||
<i data-lucide="clock" class="w-4 h-4"></i> {% trans "SLA Progress" %}
|
||||
</div>
|
||||
<div class="w-full h-6 bg-gray-200 rounded-full overflow-hidden mb-2">
|
||||
<div class="h-full rounded-full flex items-center justify-center text-xs font-bold text-white {% if action.is_overdue %}bg-red-500{% elif sla_progress > 80 %}bg-orange-500{% else %}bg-green-500{% endif %}" style="width: {{ sla_progress }}%">
|
||||
{{ sla_progress }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-slate">
|
||||
<span>{% trans "Due:" %} {{ action.due_at|date:"M d, Y H:i" }}</span>
|
||||
{% if action.is_overdue %}
|
||||
<span class="text-red-600 font-bold">{% trans "OVERDUE" %}</span>
|
||||
{% else %}
|
||||
<span>{{ action.due_at|timeuntil }} {% trans "left" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Breadcrumb & Header -->
|
||||
<header class="mb-6">
|
||||
<div class="flex items-center gap-2 text-sm text-slate mb-2 flex-wrap">
|
||||
<a href="{% url 'actions:action_list' %}" class="hover:text-navy font-medium">{% trans "Actions" %}</a>
|
||||
<i data-lucide="chevron-right" class="w-4 h-4"></i>
|
||||
<span class="font-bold text-navy">{{ action.id|slice:":8" }}</span>
|
||||
<span class="ml-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider
|
||||
{% if action.status == 'completed' %}bg-green-100 text-green-700
|
||||
{% elif action.status == 'in_progress' %}bg-blue-100 text-blue-700
|
||||
{% elif action.status == 'cancelled' %}bg-slate-100 text-slate-600
|
||||
{% elif action.status == 'pending_approval' %}bg-purple-100 text-purple-700
|
||||
{% else %}bg-yellow-100 text-yellow-700{% endif %}">
|
||||
{{ action.get_status_display }}
|
||||
</span>
|
||||
{% if action.is_overdue %}
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase bg-red-100 text-red-700">
|
||||
{% trans "Overdue" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if action.escalation_level > 0 %}
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase bg-red-500 text-white">
|
||||
Level {{ action.escalation_level }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-navy mb-2">{{ action.title }}</h1>
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm text-slate">
|
||||
{% if action.department %}
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="building-2" class="w-4 h-4"></i>
|
||||
{{ action.department.get_localized_name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="calendar" class="w-4 h-4"></i>
|
||||
{{ action.created_at|date:"d M Y" }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1 {% if action.is_overdue %}text-red-600 font-bold{% endif %}">
|
||||
<i data-lucide="clock" class="w-4 h-4"></i>
|
||||
{% trans "Due" %} {{ action.due_at|date:"d M Y, h:i A" }}
|
||||
</span>
|
||||
{% if action.assigned_to %}
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="user-check" class="w-4 h-4"></i>
|
||||
{{ action.assigned_to.get_full_name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- SLA Progress -->
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<div class="flex-1 max-w-xs h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full {% if action.is_overdue %}bg-red-500{% elif sla_progress > 80 %}bg-orange-500{% else %}bg-green-500{% endif %}" style="width: {{ sla_progress }}%"></div>
|
||||
</div>
|
||||
<span class="text-xs font-bold {% if action.is_overdue %}text-red-600{% else %}text-slate{% endif %}">{{ sla_progress }}%</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1">
|
||||
<!-- Tabs -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden">
|
||||
<div class="border-b border-gray-100">
|
||||
<nav class="flex gap-1 p-2" role="tablist">
|
||||
<button type="button" class="tab-btn active px-4 py-2 rounded-xl text-sm font-semibold transition" data-tab="details">
|
||||
<i data-lucide="info" class="w-4 h-4 inline me-1"></i> {% trans "Details" %}
|
||||
</button>
|
||||
<button type="button" class="tab-btn px-4 py-2 rounded-xl text-sm font-semibold transition" data-tab="logs">
|
||||
<i data-lucide="history" class="w-4 h-4 inline me-1"></i> {% trans "Activity" %} ({{ logs.count }})
|
||||
</button>
|
||||
<button type="button" class="tab-btn px-4 py-2 rounded-xl text-sm font-semibold transition" data-tab="evidence">
|
||||
<i data-lucide="file-check" class="w-4 h-4 inline me-1"></i> {% trans "Evidence" %} ({{ evidence_attachments.count }})
|
||||
</button>
|
||||
<button type="button" class="tab-btn px-4 py-2 rounded-xl text-sm font-semibold transition" data-tab="attachments">
|
||||
<i data-lucide="paperclip" class="w-4 h-4 inline me-1"></i> {% trans "Attachments" %} ({{ attachments.count }})
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<!-- Tab Navigation -->
|
||||
<nav class="bg-white px-6 flex gap-6 border-b shadow-sm mb-6 rounded-t-2xl">
|
||||
<button class="py-4 text-sm tab-active" onclick="switchTab('details')" id="tab-details">
|
||||
{% trans "Details" %}
|
||||
</button>
|
||||
<button class="py-4 text-sm tab-inactive" onclick="switchTab('activity')" id="tab-activity">
|
||||
{% trans "Activity" %} ({{ logs.count }})
|
||||
</button>
|
||||
<button class="py-4 text-sm tab-inactive" onclick="switchTab('evidence')" id="tab-evidence">
|
||||
{% trans "Evidence" %} ({{ evidence_attachments.count }})
|
||||
</button>
|
||||
<button class="py-4 text-sm tab-inactive" onclick="switchTab('attachments')" id="tab-attachments">
|
||||
{% trans "Attachments" %} ({{ attachments.count }})
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Details Tab -->
|
||||
<div id="tab-details" class="tab-content p-6">
|
||||
<h3 class="text-lg font-bold text-navy mb-4">{% trans "Action Details" %}</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<!-- Main Content Grid -->
|
||||
<main class="grid grid-cols-12 gap-6">
|
||||
|
||||
<!-- Left Column -->
|
||||
<div class="col-span-8 space-y-6">
|
||||
|
||||
<!-- Details Tab -->
|
||||
<div id="panel-details" class="tab-panel space-y-6">
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4 py-2">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Category" %}</div>
|
||||
<span class="px-3 py-1 rounded-full text-sm font-medium bg-slate-100 text-slate-700">{{ action.get_category_display }}</span>
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase">{% trans "Category" %}</p>
|
||||
<p class="text-sm font-bold text-navy mt-1">{{ action.get_category_display }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Priority" %}</div>
|
||||
<span class="px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-700">{{ action.get_priority_display }}</span>
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase">{% trans "Priority" %}</p>
|
||||
<p class="text-sm font-bold mt-1
|
||||
{% if action.priority == 'high' %}text-red-600
|
||||
{% elif action.priority == 'medium' %}text-orange-600
|
||||
{% else %}text-green-600{% endif %}">{{ action.get_priority_display }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase">{% trans "Severity" %}</p>
|
||||
<p class="text-sm font-bold text-navy mt-1">{{ action.get_severity_display }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase">{% trans "Source" %}</p>
|
||||
<p class="text-sm font-bold text-navy mt-1">{{ action.get_source_type_display }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase">{% trans "Created" %}</p>
|
||||
<p class="text-sm font-bold text-navy mt-1">{{ action.created_at|date:"d M Y, h:i A" }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase">{% trans "Requires Approval" %}</p>
|
||||
<p class="text-sm font-bold mt-1 {% if action.requires_approval %}text-orange-600{% else %}text-slate{% endif %}">
|
||||
{% if action.requires_approval %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-100 mb-6">
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-2">{% trans "Description" %}</div>
|
||||
<p class="text-gray-700 leading-relaxed">{{ action.description|linebreaks }}</p>
|
||||
|
||||
{% if action.description %}
|
||||
<div class="py-3 border-t border-slate-100">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase mb-2">{% trans "Description" %}</p>
|
||||
<div class="bg-slate-50 p-4 rounded-xl border-l-4 border-navy">
|
||||
<p class="text-sm leading-relaxed text-slate-700">{{ action.description|linebreaksbr }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% if action.action_plan %}
|
||||
<hr class="border-gray-100 mb-6">
|
||||
<div class="mb-6">
|
||||
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-2">{% trans "Action Plan" %}</div>
|
||||
<div class="bg-blue-50 border border-blue-100 rounded-xl p-4 text-gray-700">{{ action.action_plan|linebreaks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if action.outcome %}
|
||||
<hr class="border-gray-100 mb-6">
|
||||
<div class="mb-6">
|
||||
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-2">{% trans "Outcome" %}</div>
|
||||
<div class="bg-green-50 border border-green-100 rounded-xl p-4 text-gray-700">{{ action.outcome|linebreaks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr class="border-gray-100 mb-6">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Created" %}</div>
|
||||
<div class="text-gray-700">{{ action.created_at|date:"M d, Y H:i" }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Last Updated" %}</div>
|
||||
<div class="text-gray-700">{{ action.updated_at|date:"M d, Y H:i" }}</div>
|
||||
<div class="py-3 border-t border-slate-100">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase mb-2">{% trans "Action Plan" %}</p>
|
||||
<div class="bg-blue-50 p-4 rounded-xl border border-blue-100">
|
||||
<p class="text-sm leading-relaxed text-slate-700">{{ action.action_plan|linebreaksbr }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Activity Log Tab -->
|
||||
<div id="tab-logs" class="tab-content p-6 hidden">
|
||||
<h3 class="text-lg font-bold text-navy mb-4">{% trans "Activity Log" %}</h3>
|
||||
|
||||
{% if action.outcome %}
|
||||
<div class="py-3 border-t border-slate-100">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase mb-2">{% trans "Outcome" %}</p>
|
||||
<div class="bg-green-50 p-4 rounded-xl border border-green-100">
|
||||
<p class="text-sm leading-relaxed text-slate-700">{{ action.outcome|linebreaksbr }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if action.content_type.model == 'complaint' and action.source_object %}
|
||||
<div class="py-3 border-t border-slate-100">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase mb-2">{% trans "Related Complaint" %}</p>
|
||||
<a href="{% url 'complaints:complaint_detail' pk=action.source_object.pk %}" class="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 border border-blue-200 rounded-xl hover:bg-blue-100 transition">
|
||||
<i data-lucide="link" class="w-4 h-4 text-blue-600"></i>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-navy">{{ action.source_object.reference_number }}</p>
|
||||
<p class="text-xs text-slate-500">{{ action.source_object.title|truncatechars:60 }}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Activity Tab -->
|
||||
<div id="panel-activity" class="tab-panel hidden">
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<h3 class="font-bold text-navy mb-4">{% trans "Activity Log" %}</h3>
|
||||
{% if logs %}
|
||||
<div class="timeline">
|
||||
{% for log in logs %}
|
||||
<div class="timeline-item {{ log.log_type }}">
|
||||
<div class="bg-slate-50 rounded-xl p-4 border border-slate-100">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2.5 py-1 rounded-lg text-xs font-semibold bg-navy text-white">{{ log.get_log_type_display }}</span>
|
||||
{% if log.created_by %}
|
||||
<span class="text-xs text-slate">by {{ log.created_by.get_full_name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="text-xs text-slate">{{ log.created_at|date:"M d, Y H:i" }}</span>
|
||||
<div class="bg-slate-50 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-bold text-navy text-sm">{{ log.message }}</span>
|
||||
<span class="text-xs text-slate-400">{{ log.created_at|date:"M d, Y H:i" }}</span>
|
||||
</div>
|
||||
<p class="text-gray-700 text-sm">{{ log.message }}</p>
|
||||
{% if log.old_status and log.new_status %}
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium bg-slate-100 text-slate-600">{{ log.old_status }}</span>
|
||||
<i data-lucide="arrow-right" class="w-3 h-3 text-slate"></i>
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-600">{{ log.new_status }}</span>
|
||||
{% if log.created_by %}
|
||||
<div class="text-xs text-slate-500">
|
||||
<i data-lucide="user" class="w-3 h-3 inline"></i> {{ log.created_by.get_full_name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -227,32 +235,54 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<i data-lucide="history" class="w-16 h-16 mx-auto mb-4 text-gray-300"></i>
|
||||
<p class="text-slate">{% trans "No activity log entries yet" %}</p>
|
||||
<div class="text-center py-8">
|
||||
<i data-lucide="history" class="w-12 h-12 mx-auto text-slate-300 mb-3"></i>
|
||||
<p class="text-slate text-sm">{% trans "No activity yet" %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Evidence Tab -->
|
||||
<div id="panel-evidence" class="tab-panel hidden">
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<h3 class="font-bold text-navy mb-4">{% trans "Evidence Files" %}</h3>
|
||||
|
||||
{% if can_edit and action.status != 'closed' %}
|
||||
<form method="post" action="{% url 'actions:action_upload_attachment' action.pk %}" enctype="multipart/form-data" class="mb-6 p-4 border-2 border-dashed border-slate-200 rounded-xl">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="is_evidence" value="on">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1">{% trans "File" %}</label>
|
||||
<input type="file" name="file" required class="block w-full text-sm text-slate-500 border border-slate-200 rounded-xl cursor-pointer">
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1">{% trans "Description (optional)" %}</label>
|
||||
<input type="text" name="description" class="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm" placeholder="{% trans 'Brief description...' %}">
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition text-sm flex items-center gap-2">
|
||||
<i data-lucide="upload" class="w-4 h-4"></i> {% trans "Upload Evidence" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<!-- Evidence Tab -->
|
||||
<div id="tab-evidence" class="tab-content p-6 hidden">
|
||||
<h3 class="text-lg font-bold text-navy mb-4">{% trans "Evidence Files" %}</h3>
|
||||
|
||||
{% if evidence_attachments %}
|
||||
<div class="space-y-3">
|
||||
{% for evidence in evidence_attachments %}
|
||||
<div class="flex items-center justify-between p-4 bg-orange-50 border-l-4 border-orange-400 rounded-r-xl hover:translate-x-1 transition">
|
||||
<div class="flex items-center justify-between p-4 bg-orange-50 border-l-4 border-orange-400 rounded-r-xl">
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="file-check" class="w-5 h-5 text-orange-500"></i>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-800">{{ evidence.filename }}</div>
|
||||
<div class="text-xs text-slate">
|
||||
{% trans "Uploaded by" %} {{ evidence.uploaded_by.get_full_name }}
|
||||
<div class="font-semibold text-slate-800">{{ evidence.filename }}</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
{% trans "Uploaded by" %} {{ evidence.uploaded_by.get_full_name }}
|
||||
{% trans "on" %} {{ evidence.created_at|date:"M d, Y H:i" }}
|
||||
({{ evidence.file_size|filesizeformat }})
|
||||
</div>
|
||||
{% if evidence.description %}
|
||||
<div class="text-sm text-gray-600 mt-1">{{ evidence.description }}</div>
|
||||
<div class="text-sm text-slate-600 mt-1">{{ evidence.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@ -263,38 +293,60 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<i data-lucide="file-check" class="w-16 h-16 mx-auto mb-4 text-gray-300"></i>
|
||||
<p class="text-slate mb-2">{% trans "No evidence files uploaded yet" %}</p>
|
||||
<div class="text-center py-8">
|
||||
<i data-lucide="file-check" class="w-12 h-12 mx-auto text-slate-300 mb-3"></i>
|
||||
<p class="text-slate text-sm">{% trans "No evidence files uploaded yet" %}</p>
|
||||
{% if action.requires_approval and action.status != 'closed' %}
|
||||
<p class="text-orange-500 text-sm">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4 inline me-1"></i>
|
||||
{% trans "Evidence is required before requesting approval" %}
|
||||
</p>
|
||||
<p class="text-orange-500 text-sm mt-1">{% trans "Evidence is required before requesting approval" %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Attachments Tab -->
|
||||
<div id="panel-attachments" class="tab-panel hidden">
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<h3 class="font-bold text-navy mb-4">{% trans "All Attachments" %}</h3>
|
||||
|
||||
{% if can_edit and action.status != 'closed' %}
|
||||
<form method="post" action="{% url 'actions:action_upload_attachment' action.pk %}" enctype="multipart/form-data" class="mb-6 p-4 border-2 border-dashed border-slate-200 rounded-xl">
|
||||
{% csrf_token %}
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1">{% trans "File" %}</label>
|
||||
<input type="file" name="file" required class="block w-full text-sm text-slate-500 border border-slate-200 rounded-xl cursor-pointer">
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1">{% trans "Description (optional)" %}</label>
|
||||
<input type="text" name="description" class="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm" placeholder="{% trans 'Brief description...' %}">
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" name="is_evidence" class="w-4 h-4 accent-navy">
|
||||
{% trans "Mark as evidence" %}
|
||||
</label>
|
||||
<button type="submit" class="px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition text-sm flex items-center gap-2">
|
||||
<i data-lucide="upload" class="w-4 h-4"></i> {% trans "Upload" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<!-- Attachments Tab -->
|
||||
<div id="tab-attachments" class="tab-content p-6 hidden">
|
||||
<h3 class="text-lg font-bold text-navy mb-4">{% trans "All Attachments" %}</h3>
|
||||
|
||||
{% if attachments %}
|
||||
<div class="space-y-3">
|
||||
{% for attachment in attachments %}
|
||||
<div class="flex items-center justify-between p-4 bg-slate-50 border border-slate-100 rounded-xl hover:bg-slate-100 transition">
|
||||
<div class="flex items-center justify-between p-4 bg-slate-50 border border-slate-100 rounded-xl">
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="file" class="w-5 h-5 text-slate"></i>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-800">
|
||||
<div class="font-semibold text-slate-800">
|
||||
{{ attachment.filename }}
|
||||
{% if attachment.is_evidence %}
|
||||
<span class="ms-2 px-2 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-600">{% trans "Evidence" %}</span>
|
||||
<span class="ml-2 px-2 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-600">{% trans "Evidence" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs text-slate">
|
||||
{% trans "Uploaded by" %} {{ attachment.uploaded_by.get_full_name }}
|
||||
<div class="text-xs text-slate-500">
|
||||
{% trans "Uploaded by" %} {{ attachment.uploaded_by.get_full_name }}
|
||||
{% trans "on" %} {{ attachment.created_at|date:"M d, Y H:i" }}
|
||||
({{ attachment.file_size|filesizeformat }})
|
||||
</div>
|
||||
@ -307,272 +359,197 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<i data-lucide="paperclip" class="w-16 h-16 mx-auto mb-4 text-gray-300"></i>
|
||||
<p class="text-slate">{% trans "No attachments" %}</p>
|
||||
<div class="text-center py-8">
|
||||
<i data-lucide="paperclip" class="w-12 h-12 mx-auto text-slate-300 mb-3"></i>
|
||||
<p class="text-slate text-sm">{% trans "No attachments" %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="lg:w-80 space-y-4">
|
||||
<!-- Right Column (Sidebar) -->
|
||||
<div class="col-span-4 space-y-6">
|
||||
|
||||
<!-- Approval Required -->
|
||||
{% if action.status == 'pending_approval' and can_approve %}
|
||||
<div class="bg-green-50 border border-green-200 rounded-2xl overflow-hidden">
|
||||
<div class="px-4 py-3 bg-green-500 text-white">
|
||||
<h3 class="font-bold flex items-center gap-2">
|
||||
<i data-lucide="check-circle" class="w-4 h-4"></i> {% trans "Approval Required" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<p class="text-sm text-slate mb-3">{% trans "This action is awaiting your approval." %}</p>
|
||||
<form method="post" action="{% url 'actions:action_approve' action.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="w-full bg-green-500 text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-green-600 transition flex items-center justify-center gap-2">
|
||||
<i data-lucide="check-circle" class="w-4 h-4"></i> {% trans "Approve Action" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<section class="bg-green-50 border border-green-200 rounded-2xl p-6">
|
||||
<h3 class="font-bold text-navy mb-3 flex items-center gap-2">
|
||||
<i data-lucide="check-circle" class="w-5 h-5 text-green-600"></i> {% trans "Approval Required" %}
|
||||
</h3>
|
||||
<p class="text-sm text-slate mb-3">{% trans "This action is awaiting your approval." %}</p>
|
||||
<form method="post" action="{% url 'actions:action_approve' action.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="w-full bg-green-500 text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-green-600 transition flex items-center justify-center gap-2">
|
||||
<i data-lucide="check-circle" class="w-4 h-4"></i> {% trans "Approve Action" %}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- Quick Actions -->
|
||||
{% if can_edit %}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-navy text-white">
|
||||
<h3 class="font-bold flex items-center gap-2">
|
||||
<i data-lucide="zap" class="w-4 h-4"></i> {% trans "Quick Actions" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<h3 class="font-bold text-navy mb-4 text-sm flex items-center gap-2">
|
||||
<i data-lucide="zap" class="w-4 h-4"></i> {% trans "Quick Actions" %}
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<!-- Assign -->
|
||||
<form method="post" action="{% url 'actions:action_assign' action.id %}">
|
||||
{% csrf_token %}
|
||||
<label class="block text-xs font-semibold text-slate uppercase tracking-wide mb-2">{% trans "Assign To" %}</label>
|
||||
<label class="block text-xs font-semibold text-slate-400 uppercase mb-2">{% trans "Assign To" %}</label>
|
||||
<div class="flex gap-2">
|
||||
<select name="user_id" class="flex-1 px-3 py-2 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent" required>
|
||||
<select name="user_id" class="flex-1 px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy/20 outline-none" required>
|
||||
<option value="">{% trans "Select user..." %}</option>
|
||||
{% for user_obj in assignable_users %}
|
||||
<option value="{{ user_obj.id }}" {% if action.assigned_to and action.assigned_to.id == user_obj.id %}selected{% endif %}>
|
||||
{{ user_obj.get_full_name }}
|
||||
</option>
|
||||
<option value="{{ user_obj.id }}" {% if action.assigned_to and action.assigned_to.id == user_obj.id %}selected{% endif %}>{{ user_obj.get_full_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="px-3 py-2 bg-navy text-white rounded-xl hover:bg-blue transition">
|
||||
<i data-lucide="user-check" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<button type="submit" class="px-3 py-2 bg-navy text-white rounded-xl hover:bg-blue transition"><i data-lucide="user-check" class="w-4 h-4"></i></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
<!-- Change Status -->
|
||||
<form method="post" action="{% url 'actions:action_change_status' action.id %}">
|
||||
{% csrf_token %}
|
||||
<label class="block text-xs font-semibold text-slate uppercase tracking-wide mb-2">{% trans "Change Status" %}</label>
|
||||
<select name="status" class="w-full px-3 py-2 border border-gray-200 rounded-xl text-sm mb-2 focus:ring-2 focus:ring-navy focus:border-transparent" required>
|
||||
<label class="block text-xs font-semibold text-slate-400 uppercase mb-2">{% trans "Change Status" %}</label>
|
||||
<select name="status" class="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm mb-2 focus:ring-2 focus:ring-navy/20 outline-none" required>
|
||||
{% for value, label in status_choices %}
|
||||
<option value="{{ value }}" {% if action.status == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<textarea name="note" class="w-full px-3 py-2 border border-gray-200 rounded-xl text-sm mb-2 focus:ring-2 focus:ring-navy focus:border-transparent" rows="2" placeholder="{% trans 'Optional note...' %}"></textarea>
|
||||
<button type="submit" class="w-full bg-navy text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-blue transition flex items-center justify-center gap-2">
|
||||
<textarea name="note" class="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm mb-2 focus:ring-2 focus:ring-navy/20 outline-none" rows="2" placeholder="{% trans 'Optional note...' %}"></textarea>
|
||||
<button type="submit" class="w-full bg-navy text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-blue transition flex items-center justify-center gap-2 text-sm">
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4"></i> {% trans "Update Status" %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
<!-- Escalate -->
|
||||
<button type="button" onclick="document.getElementById('escalateModal').classList.remove('hidden')" class="w-full bg-red-500 text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-red-600 transition flex items-center justify-center gap-2">
|
||||
<button type="button" onclick="document.getElementById('escalateModal').style.display='flex'" class="w-full bg-red-50 border border-red-200 text-red-600 px-4 py-2.5 rounded-xl font-semibold hover:bg-red-100 transition flex items-center justify-center gap-2 text-sm">
|
||||
<i data-lucide="arrow-up-circle" class="w-4 h-4"></i> {% trans "Escalate" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Convert to QI Project -->
|
||||
{% if user.is_px_admin or user.is_hospital_admin or user.is_department_manager %}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-blue-500 text-white">
|
||||
<h3 class="font-bold flex items-center gap-2">
|
||||
<i data-lucide="kanban" class="w-4 h-4"></i> {% trans "QI Project" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
{% if action.qi_projects.exists %}
|
||||
<p class="text-sm text-slate mb-2">{% trans "Linked to:" %}</p>
|
||||
<ul class="space-y-2 mb-3">
|
||||
{% for project in action.qi_projects.all %}
|
||||
<li>
|
||||
<a href="{% url 'projects:project_detail' pk=project.pk %}" class="flex items-center gap-2 text-navy hover:underline">
|
||||
<i data-lucide="link" class="w-4 h-4"></i>{{ project.name }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate mb-3">
|
||||
{% trans "Convert this action to a QI project for long-term tracking." %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<a href="{% url 'projects:convert_action' action_pk=action.pk %}"
|
||||
class="w-full {% if action.qi_projects.exists %}bg-gray-300 text-gray-500 cursor-not-allowed{% else %}bg-blue-500 text-white hover:bg-blue-600{% endif %} px-4 py-2.5 rounded-xl font-semibold transition flex items-center justify-center gap-2">
|
||||
<i data-lucide="arrow-right-circle" class="w-4 h-4"></i>
|
||||
{% if action.qi_projects.exists %}{% trans "Already Converted" %}{% else %}{% trans "Convert to Project" %}{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Add Note -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-green-500 text-white">
|
||||
<h3 class="font-bold flex items-center gap-2">
|
||||
<i data-lucide="message-square" class="w-4 h-4"></i> {% trans "Add Note" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<form method="post" action="{% url 'actions:action_add_note' action.id %}">
|
||||
{% csrf_token %}
|
||||
<textarea name="note" class="w-full px-3 py-2 border border-gray-200 rounded-xl text-sm mb-2 focus:ring-2 focus:ring-navy focus:border-transparent" rows="3" placeholder="{% trans 'Enter your note...' %}" required></textarea>
|
||||
<button type="submit" class="w-full bg-green-500 text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-green-600 transition flex items-center justify-center gap-2">
|
||||
<i data-lucide="plus-circle" class="w-4 h-4"></i> {% trans "Add Note" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Assignment Info -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-100">
|
||||
<h3 class="font-bold text-navy flex items-center gap-2">
|
||||
<i data-lucide="info" class="w-4 h-4"></i> {% trans "Assignment Info" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<h3 class="font-bold text-navy mb-4 text-sm flex items-center gap-2">
|
||||
<i data-lucide="info" class="w-4 h-4"></i> {% trans "Assignment Info" %}
|
||||
</h3>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Assigned To" %}</div>
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase mb-1">{% trans "Assigned To" %}</p>
|
||||
{% if action.assigned_to %}
|
||||
<div class="font-medium text-gray-800">{{ action.assigned_to.get_full_name }}</div>
|
||||
<div class="text-xs text-slate">{% trans "Assigned" %} {{ action.assigned_at|date:"M d, Y H:i" }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-7 h-7 rounded-full bg-navy text-white text-[10px] font-bold flex items-center justify-center">{{ action.assigned_to.first_name|first }}{{ action.assigned_to.last_name|first }}</span>
|
||||
<div>
|
||||
<p class="font-bold text-navy">{{ action.assigned_to.get_full_name }}</p>
|
||||
<p class="text-xs text-slate-500">{% trans "Assigned" %} {{ action.assigned_at|date:"M d, Y" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-slate">{% trans "Unassigned" %}</div>
|
||||
<p class="text-slate italic">{% trans "Unassigned" %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if action.approved_by %}
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Approved By" %}</div>
|
||||
<div class="font-medium text-gray-800">{{ action.approved_by.get_full_name }}</div>
|
||||
<div class="text-xs text-slate">{{ action.approved_at|date:"M d, Y H:i" }}</div>
|
||||
<div class="pt-3 border-t border-slate-100">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase mb-1">{% trans "Approved By" %}</p>
|
||||
<p class="font-bold text-navy">{{ action.approved_by.get_full_name }}</p>
|
||||
<p class="text-xs text-slate-500">{{ action.approved_at|date:"M d, Y H:i" }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if action.closed_by %}
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Closed By" %}</div>
|
||||
<div class="font-medium text-gray-800">{{ action.closed_by.get_full_name }}</div>
|
||||
<div class="text-xs text-slate">{{ action.closed_at|date:"M d, Y H:i" }}</div>
|
||||
<div class="pt-3 border-t border-slate-100">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase mb-1">{% trans "Closed By" %}</p>
|
||||
<p class="font-bold text-navy">{{ action.closed_by.get_full_name }}</p>
|
||||
<p class="text-xs text-slate-500">{{ action.closed_at|date:"M d, Y H:i" }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SLA Info -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-orange-500 text-white">
|
||||
<h3 class="font-bold flex items-center gap-2">
|
||||
<i data-lucide="clock" class="w-4 h-4"></i> {% trans "SLA Information" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Due Date" %}</div>
|
||||
<div class="{% if action.is_overdue %}text-red-600 font-bold{% else %}text-gray-800{% endif %}">
|
||||
{{ action.due_at|date:"M d, Y H:i" }}
|
||||
</div>
|
||||
</div>
|
||||
{% if action.escalated_at %}
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Escalated" %}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{{ action.escalated_at|date:"M d, Y H:i" }}
|
||||
<span class="px-2 py-0.5 rounded text-xs font-bold bg-red-100 text-red-600">Level {{ action.escalation_level }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if action.reminder_sent_at %}
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-slate uppercase tracking-wide mb-1">{% trans "Reminder Sent" %}</div>
|
||||
<div class="text-gray-800">{{ action.reminder_sent_at|date:"M d, Y H:i" }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- QI Project -->
|
||||
{% if user.is_px_admin or user.is_hospital_admin or user.is_department_manager %}
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<h3 class="font-bold text-navy mb-4 text-sm flex items-center gap-2">
|
||||
<i data-lucide="kanban" class="w-4 h-4"></i> {% trans "QI Project" %}
|
||||
</h3>
|
||||
{% if action.qi_projects.exists %}
|
||||
<ul class="space-y-2 mb-3">
|
||||
{% for project in action.qi_projects.all %}
|
||||
<li><a href="{% url 'projects:project_detail' pk=project.pk %}" class="flex items-center gap-2 text-navy hover:underline text-sm"><i data-lucide="link" class="w-4 h-4"></i>{{ project.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate-500 mb-3">{% trans "Convert this action to a QI project for long-term tracking." %}</p>
|
||||
{% endif %}
|
||||
<a href="{% url 'projects:convert_action' action_pk=action.pk %}" class="w-full {% if action.qi_projects.exists %}bg-slate-200 text-slate-400 cursor-not-allowed{% else %}bg-blue-50 border border-blue-200 text-blue-700 hover:bg-blue-100{% endif %} px-4 py-2.5 rounded-xl font-semibold transition flex items-center justify-center gap-2 text-sm">
|
||||
<i data-lucide="arrow-right-circle" class="w-4 h-4"></i>
|
||||
{% if action.qi_projects.exists %}{% trans "Already Converted" %}{% else %}{% trans "Convert to Project" %}{% endif %}
|
||||
</a>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Add Note -->
|
||||
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
|
||||
<h3 class="font-bold text-navy mb-4 text-sm flex items-center gap-2">
|
||||
<i data-lucide="message-square" class="w-4 h-4"></i> {% trans "Add Note" %}
|
||||
</h3>
|
||||
<form method="post" action="{% url 'actions:action_add_note' action.id %}">
|
||||
{% csrf_token %}
|
||||
<textarea name="note" class="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm mb-2 focus:ring-2 focus:ring-navy/20 outline-none" rows="3" placeholder="{% trans 'Enter your note...' %}" required></textarea>
|
||||
<button type="submit" class="w-full bg-navy text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-blue transition flex items-center justify-center gap-2 text-sm">
|
||||
<i data-lucide="plus-circle" class="w-4 h-4"></i> {% trans "Add Note" %}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Escalate Modal -->
|
||||
<div id="escalateModal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-2xl shadow-xl max-w-md w-full">
|
||||
<div id="escalateModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full">
|
||||
<form method="post" action="{% url 'actions:action_escalate' action.id %}">
|
||||
{% csrf_token %}
|
||||
<div class="p-4 border-b border-gray-100">
|
||||
<h3 class="text-lg font-bold text-navy">{% trans "Escalate Action" %}</h3>
|
||||
<div class="p-5 border-b border-slate-100">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center"><i data-lucide="arrow-up-circle" class="w-5 h-5 text-red-500"></i></div>
|
||||
<h3 class="text-xl font-bold text-navy">{% trans "Escalate Action" %}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="p-5">
|
||||
<div class="bg-orange-50 border border-orange-200 rounded-xl p-3 mb-4 text-sm text-orange-700">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4 inline me-2"></i>
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4 inline mr-2"></i>
|
||||
{% trans "This will escalate the action to the next level." %}
|
||||
{% if action.escalation_level > 0 %}
|
||||
{% trans "Current level:" %} {{ action.escalation_level }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate uppercase tracking-wide mb-2">{% trans "Reason for Escalation" %}</label>
|
||||
<textarea name="reason" class="w-full px-3 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-navy focus:border-transparent" rows="3" placeholder="{% trans 'Explain why this action needs escalation...' %}" required></textarea>
|
||||
{% if action.escalation_level > 0 %}({% trans "Current level:" %} {{ action.escalation_level }}){% endif %}
|
||||
</div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-2">{% trans "Reason for Escalation" %} <span class="text-red-500">*</span></label>
|
||||
<textarea name="reason" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" rows="3" placeholder="{% trans 'Explain why this action needs escalation...' %}" required></textarea>
|
||||
</div>
|
||||
<div class="p-4 border-t border-gray-100 flex justify-end gap-3">
|
||||
<button type="button" onclick="document.getElementById('escalateModal').classList.add('hidden')" class="px-4 py-2 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition">
|
||||
{% trans "Cancel" %}
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-red-500 text-white rounded-xl font-semibold hover:bg-red-600 transition flex items-center gap-2">
|
||||
<i data-lucide="arrow-up-circle" class="w-4 h-4"></i> {% trans "Escalate" %}
|
||||
</button>
|
||||
<div class="p-5 border-t border-slate-100 flex gap-3">
|
||||
<button type="button" onclick="document.getElementById('escalateModal').style.display='none'" class="flex-1 px-4 py-2.5 border border-slate-200 text-slate-600 rounded-xl font-semibold hover:bg-slate-50 transition">{% trans "Cancel" %}</button>
|
||||
<button type="submit" class="flex-1 px-4 py-2.5 bg-red-500 text-white rounded-xl font-semibold hover:bg-red-600 transition flex items-center justify-center gap-2"><i data-lucide="arrow-up-circle" class="w-4 h-4"></i> {% trans "Escalate" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Switching -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Tab switching
|
||||
const tabBtns = document.querySelectorAll('.tab-btn');
|
||||
tabBtns.forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
// Update button styles
|
||||
tabBtns.forEach(b => {
|
||||
b.classList.remove('bg-navy', 'text-white');
|
||||
b.classList.add('text-slate', 'hover:bg-gray-100');
|
||||
});
|
||||
this.classList.add('bg-navy', 'text-white');
|
||||
this.classList.remove('text-slate', 'hover:bg-gray-100');
|
||||
|
||||
// Show/hide content
|
||||
const tabId = this.dataset.tab;
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
});
|
||||
document.getElementById('tab-' + tabId).classList.remove('hidden');
|
||||
});
|
||||
function switchTab(tabName) {
|
||||
document.querySelectorAll('.tab-panel').forEach(panel => { panel.classList.add('hidden'); });
|
||||
document.getElementById('panel-' + tabName).classList.remove('hidden');
|
||||
document.querySelectorAll('main nav button').forEach(tab => {
|
||||
tab.classList.remove('tab-active');
|
||||
tab.classList.add('tab-inactive');
|
||||
});
|
||||
|
||||
// Initialize first tab as active
|
||||
const firstTab = document.querySelector('.tab-btn');
|
||||
if (firstTab) {
|
||||
firstTab.classList.add('bg-navy', 'text-white');
|
||||
firstTab.classList.remove('text-slate', 'hover:bg-gray-100');
|
||||
}
|
||||
});
|
||||
var activeBtn = document.getElementById('tab-' + tabName);
|
||||
if (activeBtn) { activeBtn.classList.remove('tab-inactive'); activeBtn.classList.add('tab-active'); }
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@ -165,22 +165,35 @@
|
||||
{% endif %}
|
||||
<span class="text-xs font-bold mt-2 {% if workflow_steps.activated %}text-slate{% else %}text-navy{% endif %}">{% trans "Activate" %}</span>
|
||||
</div>
|
||||
<div class="flex-1 h-1 mx-2 rounded-full {% if workflow_steps.taxonomy_reviewed %}bg-green-500{% else %}bg-slate-200{% endif %}"></div>
|
||||
|
||||
<!-- Step 3: Review Taxonomy -->
|
||||
<div class="flex flex-col items-center flex-shrink-0">
|
||||
{% if workflow_steps.taxonomy_reviewed %}
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-green-500 text-white"><i data-lucide="check" class="w-5 h-5"></i></div>
|
||||
{% elif workflow_steps.activated %}
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-navy text-white ring-4 ring-navy/20"><i data-lucide="clipboard-check" class="w-5 h-5"></i></div>
|
||||
{% else %}
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center border-2 border-slate-200 text-slate-300"><i data-lucide="clipboard-check" class="w-5 h-5"></i></div>
|
||||
{% endif %}
|
||||
<span class="text-xs font-bold mt-2 {% if workflow_steps.taxonomy_reviewed %}text-slate{% elif workflow_steps.activated %}text-navy{% else %}text-slate-300{% endif %}">{% trans "Taxonomy" %}</span>
|
||||
</div>
|
||||
<div class="flex-1 h-1 mx-2 rounded-full {% if workflow_steps.sent_to_department %}bg-green-500{% else %}bg-slate-200{% endif %}"></div>
|
||||
|
||||
<!-- Step 3: Send to Department -->
|
||||
<!-- Step 4: Send to Department -->
|
||||
<div class="flex flex-col items-center flex-shrink-0">
|
||||
{% if workflow_steps.sent_to_department %}
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-green-500 text-white"><i data-lucide="check" class="w-5 h-5"></i></div>
|
||||
{% elif workflow_steps.activated %}
|
||||
{% elif workflow_steps.taxonomy_reviewed %}
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-navy text-white ring-4 ring-navy/20"><i data-lucide="send" class="w-5 h-5"></i></div>
|
||||
{% else %}
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center border-2 border-slate-200 text-slate-300"><i data-lucide="send" class="w-5 h-5"></i></div>
|
||||
{% endif %}
|
||||
<span class="text-xs font-bold mt-2 {% if workflow_steps.sent_to_department %}text-slate{% elif workflow_steps.activated %}text-navy{% else %}text-slate-300{% endif %}">{% trans "Send to Dept" %}</span>
|
||||
<span class="text-xs font-bold mt-2 {% if workflow_steps.sent_to_department %}text-slate{% elif workflow_steps.taxonomy_reviewed %}text-navy{% else %}text-slate-300{% endif %}">{% trans "Send to Dept" %}</span>
|
||||
</div>
|
||||
<div class="flex-1 h-1 mx-2 rounded-full {% if workflow_steps.department_responded %}bg-green-500{% else %}bg-slate-200{% endif %}"></div>
|
||||
|
||||
<!-- Step 4: Response -->
|
||||
<!-- Step 5: Response -->
|
||||
<div class="flex flex-col items-center flex-shrink-0">
|
||||
{% if workflow_steps.department_responded %}
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-green-500 text-white"><i data-lucide="check" class="w-5 h-5"></i></div>
|
||||
@ -193,7 +206,7 @@
|
||||
</div>
|
||||
<div class="flex-1 h-1 mx-2 rounded-full {% if workflow_steps.resolved %}bg-green-500{% else %}bg-slate-200{% endif %}"></div>
|
||||
|
||||
<!-- Step 5: Resolve -->
|
||||
<!-- Step 6: Resolve -->
|
||||
<div class="flex flex-col items-center flex-shrink-0">
|
||||
{% if workflow_steps.resolved %}
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-green-500 text-white"><i data-lucide="check" class="w-5 h-5"></i></div>
|
||||
@ -218,6 +231,34 @@
|
||||
<button type="submit" class="px-4 py-2 bg-navy text-white rounded-lg font-bold text-sm hover:bg-blue transition whitespace-nowrap">{% trans "Activate" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% elif not workflow_steps.taxonomy_reviewed %}
|
||||
<!-- Review Taxonomy callout (gate before Send to Department) -->
|
||||
<div class="mt-5 p-4 bg-amber-50 rounded-xl border border-amber-200 flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-9 h-9 rounded-full bg-amber-100 flex items-center justify-center shrink-0"><i data-lucide="clipboard-check" class="w-4 h-4 text-amber-600"></i></div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-bold text-amber-800">{% trans "Review taxonomy" %}</p>
|
||||
<p class="text-xs text-amber-700">{% trans "Verify the AI classification before sending to the department." %}</p>
|
||||
<div class="flex items-center gap-1 mt-1.5 flex-wrap text-xs font-medium text-amber-900">
|
||||
<span>{{ complaint.domain.get_localized_name|default:"—" }}</span>
|
||||
{% if complaint.category %}<i data-lucide="chevron-right" class="w-3 h-3"></i><span>{{ complaint.category.get_localized_name }}</span>{% endif %}
|
||||
{% if complaint.subcategory_obj %}<i data-lucide="chevron-right" class="w-3 h-3"></i><span>{{ complaint.subcategory_obj.get_localized_name }}</span>{% endif %}
|
||||
{% if complaint.classification_obj %}<i data-lucide="chevron-right" class="w-3 h-3"></i><span>{{ complaint.classification_obj.get_localized_name }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button type="button" onclick="openTaxonomyModal()" class="px-3 py-2 text-sm border-2 border-amber-300 text-amber-800 rounded-lg font-bold hover:bg-amber-100 transition">
|
||||
<i data-lucide="pencil" class="w-3.5 h-3.5 inline"></i> {% trans "Edit" %}
|
||||
</button>
|
||||
<form method="post" action="{% url 'complaints:confirm_taxonomy' pk=complaint.pk %}" class="inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="px-4 py-2 bg-amber-600 text-white rounded-lg font-bold text-sm hover:bg-amber-700 transition whitespace-nowrap">
|
||||
{% trans "Confirm" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% elif not workflow_steps.sent_to_department %}
|
||||
<div class="mt-5 p-4 bg-blue-50 rounded-xl border border-blue-200 flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
@ -227,15 +268,50 @@
|
||||
<button type="button" onclick="openSendToDeptModal()" class="px-4 py-2 bg-navy text-white rounded-lg font-bold text-sm hover:bg-blue transition whitespace-nowrap shrink-0">{% trans "Send to Department" %}</button>
|
||||
</div>
|
||||
{% elif not workflow_steps.department_responded %}
|
||||
<div class="mt-5 p-4 bg-amber-50 rounded-xl border border-amber-200 flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-full bg-amber-100 flex items-center justify-center shrink-0"><i data-lucide="clock" class="w-4 h-4 text-amber-600 animate-pulse"></i></div>
|
||||
<div><p class="text-sm font-bold text-amber-800">{% trans "Awaiting department response" %}</p><p class="text-xs text-amber-700">{% trans "The department champion and manager have been notified. You'll be notified when they respond." %}</p></div>
|
||||
<!-- No department has responded yet -->
|
||||
<div class="mt-5 p-4 bg-amber-50 rounded-xl border border-amber-200">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-9 h-9 rounded-full bg-amber-100 flex items-center justify-center shrink-0"><i data-lucide="clock" class="w-4 h-4 text-amber-600 animate-pulse"></i></div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-amber-800">{% trans "Awaiting department response" %}</p>
|
||||
{% if dept_response_progress.total > 0 %}
|
||||
<p class="text-xs text-amber-700">{% blocktrans with responded=dept_response_progress.responded total=dept_response_progress.total %}{{ responded }} of {{ total }} departments responded{% endblocktrans %}</p>
|
||||
{% else %}
|
||||
<p class="text-xs text-amber-700">{% trans "The department champion and manager have been notified." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if dept_response_progress.pending %}
|
||||
<div class="space-y-1 ml-12">
|
||||
{% for dept_name in dept_response_progress.pending %}
|
||||
<p class="text-xs text-amber-600 flex items-center gap-1.5"><i data-lucide="clock" class="w-3 h-3"></i> {{ dept_name }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mt-3 flex items-center justify-end gap-2 {% if not dept_response_progress.pending %}ml-12{% endif %}">
|
||||
<span class="text-xs text-amber-600">{% trans "Don't want to wait?" %}</span>
|
||||
<button type="button" onclick="switchTab('resolution')"
|
||||
class="px-4 py-2 bg-amber-600 text-white rounded-lg font-bold text-sm hover:bg-amber-700 transition">
|
||||
{% trans "Proceed to Resolve" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% elif not workflow_steps.resolved %}
|
||||
<!-- At least one dept responded — show resolve + any pending -->
|
||||
<div class="mt-5 p-4 bg-green-50 rounded-xl border border-green-200 flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-full bg-green-100 flex items-center justify-center shrink-0"><i data-lucide="check-circle-2" class="w-4 h-4 text-green-600"></i></div>
|
||||
<div><p class="text-sm font-bold text-navy">{% trans "Resolve this complaint" %}</p><p class="text-xs text-slate">{% trans "The department has responded. Review and close the complaint." %}</p></div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-green-800">{% trans "Resolve this complaint" %}</p>
|
||||
<p class="text-xs text-green-700">
|
||||
{% if dept_response_progress.total > 0 and dept_response_progress.responded < dept_response_progress.total %}
|
||||
{% blocktrans with responded=dept_response_progress.responded total=dept_response_progress.total %}{{ responded }} of {{ total }} departments responded.{% endblocktrans %}
|
||||
{% if dept_response_progress.pending %} {% trans "Still waiting for:" %} {{ dept_response_progress.pending|join:", " }}{% endif %}
|
||||
{% else %}
|
||||
{% trans "The department has responded. Review and close the complaint." %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="switchTab('resolution')" class="px-4 py-2 bg-green-600 text-white rounded-lg font-bold text-sm hover:bg-green-700 transition whitespace-nowrap shrink-0">{% trans "Generate Resolution" %}</button>
|
||||
</div>
|
||||
@ -282,6 +358,16 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if complaint.satisfaction_window_expired %}
|
||||
<!-- Satisfaction window expired (5+ days) -->
|
||||
<div class="mt-5 p-4 bg-slate-50 rounded-xl border border-slate-200 flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center shrink-0"><i data-lucide="clock-alert" class="w-4 h-4 text-slate-400"></i></div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-slate-600">{% trans "Satisfaction window expired" %}</p>
|
||||
<p class="text-xs text-slate-400">{% trans "Satisfaction can only be set within 5 days of resolution." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Satisfaction NOT yet recorded: amber next-action callout -->
|
||||
<div class="mt-5 p-4 bg-amber-50 rounded-xl border border-amber-200 flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
@ -294,6 +380,7 @@
|
||||
<button type="button" onclick="switchTab('resolution', 'patient-satisfaction')" class="px-4 py-2 bg-amber-600 text-white rounded-lg font-bold text-sm hover:bg-amber-700 transition whitespace-nowrap shrink-0">{% trans "Record Satisfaction" %}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -474,6 +561,16 @@
|
||||
{% if complaint.category %}<i data-lucide="chevron-right" class="w-3 h-3 text-slate-300"></i><span class="text-sm text-navy font-medium">{{ complaint.category.get_localized_name }}</span>{% endif %}
|
||||
{% if complaint.subcategory_obj %}<i data-lucide="chevron-right" class="w-3 h-3 text-slate-300"></i><span class="text-sm text-navy font-medium">{{ complaint.subcategory_obj.get_localized_name }}</span>{% endif %}
|
||||
{% if complaint.classification_obj %}<i data-lucide="chevron-right" class="w-3 h-3 text-slate-300"></i><span class="text-sm text-navy font-medium">{{ complaint.classification_obj.get_localized_name }}</span>{% endif %}
|
||||
{% if can_edit %}
|
||||
<button type="button" onclick="openTaxonomyModal()" class="ml-1 p-1 text-slate/60 hover:text-navy transition" title="{% trans 'Edit Taxonomy' %}">
|
||||
<i data-lucide="pencil" class="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if complaint.taxonomy_reviewed_at %}
|
||||
<span class="ml-1 px-1.5 py-0.5 rounded text-[10px] font-bold bg-green-100 text-green-700 flex items-center gap-1">
|
||||
<i data-lucide="check-circle" class="w-3 h-3"></i> {% trans "Reviewed" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -595,7 +692,9 @@
|
||||
{% if dept.is_primary %}<span class="ml-1 text-[10px] font-bold text-navy bg-light px-1.5 py-0.5 rounded align-middle">{% trans "Primary" %}</span>{% endif %}
|
||||
</p>
|
||||
{% if dept.assigned_to %}<p class="text-[11px] text-slate-500"><i data-lucide="user-check" class="w-3 h-3 inline"></i> {{ dept.assigned_to.get_full_name }}</p>{% endif %}
|
||||
{% if dept.forwarded_at or dept.sent_at %}
|
||||
{% if dept.routing_status == 'rejected' %}
|
||||
<p class="text-[10px] text-red-500 mt-0.5 italic">{% trans "Rejected:" %} {{ dept.rejection_reason|truncatechars:80 }}{% if dept.suggested_department %} · {% trans "Suggested:" %} {{ dept.suggested_department.get_localized_name }}{% endif %}</p>
|
||||
{% elif dept.forwarded_at or dept.sent_at %}
|
||||
<p class="text-[10px] text-slate-400 mt-0.5"><i data-lucide="send" class="w-2.5 h-2.5 inline"></i> {% trans "Sent" %} {{ dept.forwarded_at|default:dept.sent_at|date:"d M Y" }}</p>
|
||||
{% elif dept.sent %}
|
||||
<p class="text-[10px] text-slate-400 mt-0.5"><i data-lucide="send" class="w-2.5 h-2.5 inline"></i> {% trans "Sent" %}</p>
|
||||
@ -605,11 +704,13 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-[11px] font-bold
|
||||
{% if not dept.response_submitted %}text-slate-400
|
||||
{% if dept.routing_status == 'rejected' %}text-red-600
|
||||
{% elif not dept.response_submitted %}text-slate-400
|
||||
{% elif dept.acceptance_status == 'acceptable' %}text-green-600
|
||||
{% elif dept.acceptance_status == 'not_acceptable' %}text-red-600
|
||||
{% else %}text-amber-600{% endif %}">
|
||||
{% if not dept.response_submitted %}{% trans "No response" %}
|
||||
{% if dept.routing_status == 'rejected' %}{% trans "Wrong Department" %}
|
||||
{% elif not dept.response_submitted %}{% trans "No response" %}
|
||||
{% elif dept.acceptance_status == 'acceptable' %}{% trans "Accepted" %}
|
||||
{% elif dept.acceptance_status == 'not_acceptable' %}{% trans "Rejected" %}
|
||||
{% else %}{% trans "Pending" %}{% endif %}
|
||||
@ -669,6 +770,11 @@
|
||||
<i data-lucide="file-text" class="w-4 h-4"></i>
|
||||
{% trans "PDF" %}
|
||||
</a>
|
||||
<a href="{% url 'complaints:complaint_pdf_v2' pk=complaint.id %}" target="_blank"
|
||||
class="px-3 py-2.5 border border-blue-200 bg-blue-50 rounded-xl text-blue-700 hover:border-navy hover:text-navy transition flex items-center gap-2 text-sm font-semibold">
|
||||
<i data-lucide="file-image" class="w-4 h-4"></i>
|
||||
{% trans "PDF (Letterhead)" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -750,18 +856,25 @@
|
||||
<i data-lucide="clock" class="w-5 h-5 text-slate group-hover:text-orange-500"></i>
|
||||
<span class="text-[10px] font-bold uppercase">{% trans "Follow Up" %}</span>
|
||||
</button>
|
||||
{% if complaint.escalated_at %}
|
||||
<button disabled class="p-3 border-red-200 bg-red-100 rounded-xl flex flex-col items-center gap-2 transition cursor-not-allowed opacity-70">
|
||||
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-500"></i>
|
||||
<span class="text-[10px] font-bold text-red-600 uppercase">{% trans "Escalated" %}</span>
|
||||
</button>
|
||||
{% else %}
|
||||
<button onclick="showEscalateModal()" class="p-3 border-red-100 bg-red-50 rounded-xl hover:bg-red-100 flex flex-col items-center gap-2 group transition">
|
||||
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-500"></i>
|
||||
<span class="text-[10px] font-bold text-red-600 uppercase">{% trans "Escalate" %}</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'complaints:toggle_escalated_ovr' pk=complaint.pk %}" class="contents">
|
||||
{% csrf_token %}
|
||||
<button type="submit" name="is_escalated_ovr" value="{% if not complaint.is_escalated_ovr %}on{% endif %}"
|
||||
class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition
|
||||
{% if complaint.is_escalated_ovr %}border-orange-200 bg-orange-50{% endif %}">
|
||||
<i data-lucide="shield-alert" class="w-5 h-5 {% if complaint.is_escalated_ovr %}text-orange-600{% else %}text-slate group-hover:text-orange-500{% endif %}"></i>
|
||||
{% if complaint.is_escalated_ovr %}border-orange-300 bg-orange-100 ring-2 ring-orange-200{% endif %}">
|
||||
<i data-lucide="{% if complaint.is_escalated_ovr %}shield-check{% else %}shield-alert{% endif %}" class="w-5 h-5 {% if complaint.is_escalated_ovr %}text-orange-600{% else %}text-slate group-hover:text-orange-500{% endif %}"></i>
|
||||
<span class="text-[10px] font-bold uppercase {% if complaint.is_escalated_ovr %}text-orange-700{% endif %}">
|
||||
{% if complaint.is_escalated_ovr %}{% trans "Remove OVR" %}{% else %}{% trans "OVR Escalate" %}{% endif %}
|
||||
{% if complaint.is_escalated_ovr %}{% trans "OVR Active" %}{% else %}{% trans "OVR Escalate" %}{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
@ -939,14 +1052,10 @@
|
||||
<option value="">{% trans "Select Person" %}</option>
|
||||
{% regroup escalation_targets by group as target_groups %}
|
||||
{% for group in target_groups %}
|
||||
{% if group.grouper == 'department_roles' %}
|
||||
<optgroup label="{% trans 'Department Roles' %}">
|
||||
{% else %}
|
||||
<optgroup label="{% trans 'Managers & Admins' %}">
|
||||
{% endif %}
|
||||
<optgroup label="{{ group.grouper }}">
|
||||
{% for target in group.list %}
|
||||
<option value="{{ target.staff.id }}" {% if target.staff.id|stringformat:"s" == default_escalation_target %}selected{% endif %}>
|
||||
{{ target.staff.get_full_name }}{% if target.role_label %} ({{ target.role_label }}){% elif target.is_line_manager %} ({% trans "Line Manager" %}){% elif target.is_manager %} ({% trans "Manager" %}){% endif %}
|
||||
{{ target.staff.get_full_name }} ({{ target.role_label }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
@ -1153,6 +1262,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Taxonomy Edit Modal -->
|
||||
<div id="taxonomyModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-2xl p-6 w-full max-w-lg shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-navy mb-4 flex items-center gap-2">
|
||||
<i data-lucide="clipboard-check" class="w-5 h-5"></i>
|
||||
{% trans "Edit Taxonomy" %}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate/60 uppercase mb-1">{% trans "Domain (Level 1)" %}</label>
|
||||
<select id="taxDomain" class="w-full border-2 border-slate-200 rounded-xl p-2.5 text-sm focus:outline-none focus:border-navy">
|
||||
<option value="">{% trans "Loading..." %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate/60 uppercase mb-1">{% trans "Category (Level 2)" %}</label>
|
||||
<select id="taxCategory" class="w-full border-2 border-slate-200 rounded-xl p-2.5 text-sm focus:outline-none focus:border-navy" disabled>
|
||||
<option value="">{% trans "Select domain first" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate/60 uppercase mb-1">{% trans "Subcategory (Level 3)" %}</label>
|
||||
<select id="taxSubcategory" class="w-full border-2 border-slate-200 rounded-xl p-2.5 text-sm focus:outline-none focus:border-navy" disabled>
|
||||
<option value="">{% trans "Select category first" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate/60 uppercase mb-1">{% trans "Classification (Level 4)" %}</label>
|
||||
<select id="taxClassification" class="w-full border-2 border-slate-200 rounded-xl p-2.5 text-sm focus:outline-none focus:border-navy" disabled>
|
||||
<option value="">{% trans "Select subcategory first" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="taxonomyError" class="hidden mt-3 text-sm text-red-600 font-semibold"></div>
|
||||
|
||||
<div class="flex gap-2 mt-5">
|
||||
<button type="button" onclick="closeTaxonomyModal()" class="flex-1 px-4 py-2 border-2 border-slate-200 text-slate-700 rounded-xl font-bold text-sm hover:bg-slate-50 transition">
|
||||
{% trans "Cancel" %}
|
||||
</button>
|
||||
<button type="button" onclick="saveTaxonomy()" class="flex-1 px-4 py-2 bg-navy text-white rounded-xl font-bold text-sm hover:bg-blue transition">
|
||||
{% trans "Save Changes" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Switching JavaScript -->
|
||||
<script>
|
||||
function showActivationRequired() {
|
||||
@ -1232,6 +1389,171 @@ window.onclick = function(event) {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Taxonomy Edit Modal (cascading dropdowns) ───
|
||||
var taxonomyData = null;
|
||||
var TAX_LANG = document.documentElement.lang || 'en';
|
||||
|
||||
function openTaxonomyModal() {
|
||||
var m = document.getElementById('taxonomyModal');
|
||||
if (!m) return;
|
||||
m.style.display = 'flex';
|
||||
if (!taxonomyData) loadTaxonomyOptions();
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
function closeTaxonomyModal() {
|
||||
var m = document.getElementById('taxonomyModal');
|
||||
if (m) m.style.display = 'none';
|
||||
var err = document.getElementById('taxonomyError');
|
||||
if (err) { err.classList.add('hidden'); err.textContent = ''; }
|
||||
}
|
||||
|
||||
function loadTaxonomyOptions() {
|
||||
fetch('{% url "complaints:complaint-api-taxonomy-options" pk=complaint.pk %}', {credentials: 'same-origin'})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
taxonomyData = data.hierarchy || data.domains || data;
|
||||
populateTaxDomain();
|
||||
})
|
||||
.catch(function(err) {
|
||||
var errEl = document.getElementById('taxonomyError');
|
||||
if (errEl) { errEl.textContent = 'Failed to load taxonomy: ' + err.message; errEl.classList.remove('hidden'); }
|
||||
});
|
||||
}
|
||||
|
||||
function populateTaxDomain() {
|
||||
var sel = document.getElementById('taxDomain');
|
||||
sel.innerHTML = '<option value="">{% trans "Select domain" %}</option>';
|
||||
if (!Array.isArray(taxonomyData)) return;
|
||||
taxonomyData.forEach(function(d) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = d.id;
|
||||
opt.textContent = (TAX_LANG === 'ar' && d.name_ar) ? d.name_ar : (d.name_en || d.name || d.code);
|
||||
if (d.is_selected) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
cascadeTaxCategory();
|
||||
}
|
||||
|
||||
function cascadeTaxCategory() {
|
||||
var domainId = document.getElementById('taxDomain').value;
|
||||
var sel = document.getElementById('taxCategory');
|
||||
sel.innerHTML = '<option value="">{% trans "Select category" %}</option>';
|
||||
sel.disabled = !domainId;
|
||||
if (!domainId) return;
|
||||
|
||||
var domain = (taxonomyData || []).find(function(d) { return d.id === domainId; });
|
||||
var categories = (domain && domain.categories) || [];
|
||||
categories.forEach(function(c) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = c.id;
|
||||
opt.textContent = (TAX_LANG === 'ar' && c.name_ar) ? c.name_ar : (c.name_en || c.name || c.code);
|
||||
if (c.is_selected) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
cascadeTaxSubcategory();
|
||||
}
|
||||
|
||||
function cascadeTaxSubcategory() {
|
||||
var domainId = document.getElementById('taxDomain').value;
|
||||
var categoryId = document.getElementById('taxCategory').value;
|
||||
var sel = document.getElementById('taxSubcategory');
|
||||
sel.innerHTML = '<option value="">{% trans "Select subcategory" %}</option>';
|
||||
sel.disabled = !categoryId;
|
||||
if (!categoryId) return;
|
||||
|
||||
var domain = (taxonomyData || []).find(function(d) { return d.id === domainId; });
|
||||
var category = ((domain && domain.categories) || []).find(function(c) { return c.id === categoryId; });
|
||||
var subcategories = (category && category.subcategories) || [];
|
||||
subcategories.forEach(function(s) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.textContent = (TAX_LANG === 'ar' && s.name_ar) ? s.name_ar : (s.name_en || s.name || s.code);
|
||||
if (s.is_selected) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
cascadeTaxClassification();
|
||||
}
|
||||
|
||||
function cascadeTaxClassification() {
|
||||
var domainId = document.getElementById('taxDomain').value;
|
||||
var categoryId = document.getElementById('taxCategory').value;
|
||||
var subcategoryId = document.getElementById('taxSubcategory').value;
|
||||
var sel = document.getElementById('taxClassification');
|
||||
sel.innerHTML = '<option value="">{% trans "Select classification" %}</option>';
|
||||
sel.disabled = !subcategoryId;
|
||||
if (!subcategoryId) return;
|
||||
|
||||
var domain = (taxonomyData || []).find(function(d) { return d.id === domainId; });
|
||||
var category = ((domain && domain.categories) || []).find(function(c) { return c.id === categoryId; });
|
||||
var subcategory = ((category && category.subcategories) || []).find(function(s) { return s.id === subcategoryId; });
|
||||
var classifications = (subcategory && subcategory.classifications) || [];
|
||||
classifications.forEach(function(c) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = c.id;
|
||||
opt.textContent = (TAX_LANG === 'ar' && c.name_ar) ? c.name_ar : (c.name_en || c.name || c.code);
|
||||
if (c.is_selected) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// Wire up cascading change handlers
|
||||
// Wire up cascading change handlers (directly — script is at bottom of <body>)
|
||||
var taxD = document.getElementById('taxDomain');
|
||||
if (taxD) taxD.addEventListener('change', cascadeTaxCategory);
|
||||
var taxC = document.getElementById('taxCategory');
|
||||
if (taxC) taxC.addEventListener('change', cascadeTaxSubcategory);
|
||||
var taxS = document.getElementById('taxSubcategory');
|
||||
if (taxS) taxS.addEventListener('change', cascadeTaxClassification);
|
||||
|
||||
function saveTaxonomy() {
|
||||
var errEl = document.getElementById('taxonomyError');
|
||||
if (errEl) { errEl.classList.add('hidden'); errEl.textContent = ''; }
|
||||
|
||||
var domainId = document.getElementById('taxDomain').value;
|
||||
var categoryId = document.getElementById('taxCategory').value;
|
||||
var subcategoryId = document.getElementById('taxSubcategory').value;
|
||||
var classificationId = document.getElementById('taxClassification').value;
|
||||
|
||||
if (!domainId) {
|
||||
if (errEl) { errEl.textContent = '{% trans "Please select at least a domain." %}'; errEl.classList.remove('hidden'); }
|
||||
return;
|
||||
}
|
||||
|
||||
var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
if (!csrfToken) {
|
||||
var csrfInput = document.querySelector('#taxonomyModal input[name=csrfmiddlewaretoken]');
|
||||
if (csrfInput) csrfToken = csrfInput.value;
|
||||
else csrfToken = '';
|
||||
} else {
|
||||
csrfToken = csrfToken.value;
|
||||
}
|
||||
|
||||
var body = new URLSearchParams();
|
||||
body.append('domain_id', domainId);
|
||||
if (categoryId) body.append('category_id', categoryId);
|
||||
if (subcategoryId) body.append('subcategory_id', subcategoryId);
|
||||
if (classificationId) body.append('classification_id', classificationId);
|
||||
|
||||
fetch('{% url "complaints:complaint-api-update-taxonomy" pk=complaint.pk %}', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: body.toString(),
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) throw new Error(data.error);
|
||||
location.reload();
|
||||
})
|
||||
.catch(function(err) {
|
||||
if (errEl) { errEl.textContent = err.message; errEl.classList.remove('hidden'); }
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Send to Department modal (department + staff selects) ───
|
||||
function openSendToDeptModal() {
|
||||
var m = document.getElementById('sendToDeptModal');
|
||||
|
||||
@ -595,7 +595,9 @@
|
||||
{% if dept.is_primary %}<span class="ml-1 text-[10px] font-bold text-navy bg-light px-1.5 py-0.5 rounded align-middle">{% trans "Primary" %}</span>{% endif %}
|
||||
</p>
|
||||
{% if dept.assigned_to %}<p class="text-[11px] text-slate-500"><i data-lucide="user-check" class="w-3 h-3 inline"></i> {{ dept.assigned_to.get_full_name }}</p>{% endif %}
|
||||
{% if dept.forwarded_at or dept.sent_at %}
|
||||
{% if dept.routing_status == 'rejected' %}
|
||||
<p class="text-[10px] text-red-500 mt-0.5 italic">{% trans "Rejected:" %} {{ dept.rejection_reason|truncatechars:80 }}{% if dept.suggested_department %} · {% trans "Suggested:" %} {{ dept.suggested_department.get_localized_name }}{% endif %}</p>
|
||||
{% elif dept.forwarded_at or dept.sent_at %}
|
||||
<p class="text-[10px] text-slate-400 mt-0.5"><i data-lucide="send" class="w-2.5 h-2.5 inline"></i> {% trans "Sent" %} {{ dept.forwarded_at|default:dept.sent_at|date:"d M Y" }}</p>
|
||||
{% elif dept.sent %}
|
||||
<p class="text-[10px] text-slate-400 mt-0.5"><i data-lucide="send" class="w-2.5 h-2.5 inline"></i> {% trans "Sent" %}</p>
|
||||
@ -605,11 +607,13 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-[11px] font-bold
|
||||
{% if not dept.response_submitted %}text-slate-400
|
||||
{% if dept.routing_status == 'rejected' %}text-red-600
|
||||
{% elif not dept.response_submitted %}text-slate-400
|
||||
{% elif dept.acceptance_status == 'acceptable' %}text-green-600
|
||||
{% elif dept.acceptance_status == 'not_acceptable' %}text-red-600
|
||||
{% else %}text-amber-600{% endif %}">
|
||||
{% if not dept.response_submitted %}{% trans "No response" %}
|
||||
{% if dept.routing_status == 'rejected' %}{% trans "Wrong Department" %}
|
||||
{% elif not dept.response_submitted %}{% trans "No response" %}
|
||||
{% elif dept.acceptance_status == 'acceptable' %}{% trans "Accepted" %}
|
||||
{% elif dept.acceptance_status == 'not_acceptable' %}{% trans "Rejected" %}
|
||||
{% else %}{% trans "Pending" %}{% endif %}
|
||||
|
||||
243
templates/complaints/complaint_pdf_v2.html
Normal file
243
templates/complaints/complaint_pdf_v2.html
Normal file
@ -0,0 +1,243 @@
|
||||
{% load i18n %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>تقرير الشكوى - {{ complaint.reference_number }}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 0; }
|
||||
@page :first { margin: 0; }
|
||||
|
||||
@font-face { font-family: 'Noto Kufi Arabic'; font-style: normal; font-weight: 400; src: url('vendor/fonts/noto-kufi-arabic/files/noto-kufi-arabic-full-regular.ttf') format('truetype'); }
|
||||
@font-face { font-family: 'Noto Kufi Arabic'; font-style: normal; font-weight: 700; src: url('vendor/fonts/noto-kufi-arabic/files/noto-kufi-arabic-full-bold.ttf') format('truetype'); }
|
||||
|
||||
:root {
|
||||
--blue: #005696;
|
||||
--blue-light: #007bbd;
|
||||
--ink: #1e293b;
|
||||
--body: #334155;
|
||||
--muted: #64748b;
|
||||
--border: #cbd5e1;
|
||||
--surface: #f1f5f9;
|
||||
--white: #ffffff;
|
||||
--font: 'Noto Kufi Arabic', sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; font-family: var(--font); }
|
||||
body { background: var(--white); color: var(--ink); }
|
||||
|
||||
.page {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
page-break-after: always;
|
||||
background-image: url('{{ letterhead_path }}');
|
||||
background-size: 210mm 297mm;
|
||||
background-repeat: no-repeat;
|
||||
background-position: top left;
|
||||
}
|
||||
.page:last-child { page-break-after: auto; }
|
||||
|
||||
.stamp {
|
||||
position: fixed;
|
||||
bottom: 18mm;
|
||||
right: 20mm;
|
||||
width: 40mm;
|
||||
height: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.form-body {
|
||||
padding: 38mm 20mm 35mm 20mm;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
color: var(--blue);
|
||||
padding: 10px 0 16px 0;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.data-table th {
|
||||
background: var(--surface);
|
||||
color: var(--blue);
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-right: 3px solid var(--blue);
|
||||
border: 1px solid var(--border);
|
||||
text-align: right;
|
||||
}
|
||||
.data-table td {
|
||||
border: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.data-table .lbl {
|
||||
background: var(--surface);
|
||||
color: var(--body);
|
||||
font-weight: 700;
|
||||
width: 15%;
|
||||
}
|
||||
.data-table .val {
|
||||
color: var(--ink);
|
||||
width: 35%;
|
||||
}
|
||||
.data-table .text-cell {
|
||||
padding: 12px;
|
||||
line-height: 2;
|
||||
text-align: justify;
|
||||
}
|
||||
.section-header {
|
||||
background: var(--surface);
|
||||
color: var(--blue);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 6px 12px;
|
||||
border-right: 3px solid var(--blue);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
margin-top: 8mm;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page">
|
||||
<div class="form-body">
|
||||
<div class="form-title">تقرير الشكوى</div>
|
||||
|
||||
<!-- Complaint Info -->
|
||||
<table class="data-table">
|
||||
<tr>
|
||||
<td class="lbl">رقم الشكوى</td>
|
||||
<td class="val">{{ complaint.reference_number }}</td>
|
||||
<td class="lbl">الحالة</td>
|
||||
<td class="val">{{ complaint.get_status_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="lbl">العنوان</td>
|
||||
<td class="val">{{ complaint.title }}</td>
|
||||
<td class="lbl">نوع الشكوى</td>
|
||||
<td class="val">{{ complaint.get_complaint_type_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="lbl">القسم</td>
|
||||
<td class="val">{% if complaint.department %}{{ complaint.department.name_ar|default:complaint.department.name_en }}{% else %}—{% endif %}</td>
|
||||
<td class="lbl">الخطورة</td>
|
||||
<td class="val">{{ complaint.get_severity_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="lbl">الموظف المشكو عليه</td>
|
||||
<td class="val">{% if complaint.staff %}{{ complaint.staff.get_full_name }}{% else %}—{% endif %}</td>
|
||||
<td class="lbl">جهة الادعاء</td>
|
||||
<td class="val">{% if complaint.source %}{{ complaint.source.name_ar|default:complaint.source.name_en }}{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="lbl">تاريخ التقديم</td>
|
||||
<td class="val">{{ complaint.created_at|date:"Y/m/d H:i" }}</td>
|
||||
<td class="lbl">تاريخ الحادثة</td>
|
||||
<td class="val">{% if complaint.incident_date %}{{ complaint.incident_date|date:"Y/m/d" }}{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="section-header">مختصر الشكوى</div>
|
||||
<table class="data-table">
|
||||
<tr>
|
||||
<td colspan="4" class="text-cell">{{ complaint.description }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Patient Info (if available) -->
|
||||
{% if complaint.patient %}
|
||||
<div class="section-header">بيانات المريض</div>
|
||||
<table class="data-table">
|
||||
<tr>
|
||||
<td class="lbl">اسم المريض</td>
|
||||
<td class="val">{{ complaint.patient.get_full_name }}</td>
|
||||
<td class="lbl">رقم الملف</td>
|
||||
<td class="val">{{ complaint.patient.mrn|default:"—" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<!-- Contact Info -->
|
||||
{% if complaint.contact_name or complaint.contact_phone %}
|
||||
<div class="section-header">بيانات مقدم الشكوى</div>
|
||||
<table class="data-table">
|
||||
<tr>
|
||||
<td class="lbl">الاسم</td>
|
||||
<td class="val">{{ complaint.contact_name|default:"—" }}</td>
|
||||
<td class="lbl">الهاتف</td>
|
||||
<td class="val">{{ complaint.contact_phone|default:"—" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<!-- Department Responses -->
|
||||
{% if complaint.involved_departments.exists %}
|
||||
<div class="section-header">ردود الأقسام</div>
|
||||
{% for dept in complaint.involved_departments.all %}
|
||||
<table class="data-table">
|
||||
<tr>
|
||||
<td class="lbl">القسم</td>
|
||||
<td class="val">{{ dept.department.name_ar|default:dept.department.name_en }}</td>
|
||||
<td class="lbl">تاريخ الرد</td>
|
||||
<td class="val">{% if dept.response_submitted_at %}{{ dept.response_submitted_at|date:"Y/m/d H:i" }}{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% if dept.response_notes %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-cell">{{ dept.response_notes }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Explanations -->
|
||||
{% if explanations %}
|
||||
<div class="section-header">الردود المباشرة</div>
|
||||
{% for exp in explanations %}
|
||||
{% if exp.explanation %}
|
||||
<table class="data-table">
|
||||
<tr>
|
||||
<td class="lbl">الموظف</td>
|
||||
<td class="val">{{ exp.staff.get_full_name|default:"—" }}</td>
|
||||
<td class="lbl">تاريخ الرد</td>
|
||||
<td class="val">{% if exp.responded_at %}{{ exp.responded_at|date:"Y/m/d H:i" }}{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="4" class="text-cell">{{ exp.explanation }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Resolution -->
|
||||
{% if complaint.resolution %}
|
||||
<div class="section-header">الحل</div>
|
||||
<table class="data-table">
|
||||
<tr>
|
||||
<td colspan="4" class="text-cell">{{ complaint.resolution }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if stamp_path %}
|
||||
<img class="stamp" src="{{ stamp_path }}" alt="">
|
||||
{% endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@ -12,7 +12,6 @@
|
||||
|
||||
:root {
|
||||
--blue: #005696;
|
||||
--blue-light: #007bbd;
|
||||
--ink: #1e293b;
|
||||
--body: #334155;
|
||||
--muted: #64748b;
|
||||
@ -30,268 +29,208 @@
|
||||
height: 297mm;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
page-break-after: always;
|
||||
background-image: url('{{ letterhead_path }}');
|
||||
background-size: 210mm 297mm;
|
||||
background-repeat: no-repeat;
|
||||
background-position: top left;
|
||||
}
|
||||
.page:last-child { page-break-after: auto; }
|
||||
|
||||
/* === Header === */
|
||||
.page-header {
|
||||
position: relative;
|
||||
height: 22mm;
|
||||
margin: 8mm 20mm 0 20mm;
|
||||
}
|
||||
.header-ar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4mm;
|
||||
text-align: left;
|
||||
}
|
||||
.header-logo-wrap {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
}
|
||||
.header-en {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 4mm;
|
||||
text-align: right;
|
||||
}
|
||||
.header-logo {
|
||||
width: 55px;
|
||||
.stamp {
|
||||
position: fixed;
|
||||
bottom: 22mm;
|
||||
right: 20mm;
|
||||
width: 40mm;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
.header-hospital-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--blue);
|
||||
}
|
||||
.header-branch {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.header-cr {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--blue);
|
||||
text-align: center;
|
||||
}
|
||||
.header-line {
|
||||
height: 2px;
|
||||
background: var(--blue);
|
||||
margin: 8px 15mm 0 15mm;
|
||||
}
|
||||
|
||||
/* === Watermark === */
|
||||
.watermark {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0.04;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.watermark img {
|
||||
width: 300px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* === Footer === */
|
||||
.page-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
.footer-line {
|
||||
height: 2px;
|
||||
background: var(--blue);
|
||||
margin: 0 15mm;
|
||||
}
|
||||
.footer-content {
|
||||
padding: 6px 15mm 10mm 15mm;
|
||||
text-align: center;
|
||||
font-size: 8px;
|
||||
color: var(--muted);
|
||||
line-height: 1.8;
|
||||
}
|
||||
.footer-ar {
|
||||
direction: rtl;
|
||||
}
|
||||
.footer-en {
|
||||
direction: ltr;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* === Form Body === */
|
||||
.form-body {
|
||||
padding: 8mm 15mm;
|
||||
padding: 38mm 18mm 30mm 18mm;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* === Title === */
|
||||
.form-title {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--blue);
|
||||
padding: 10px 0 16px 0;
|
||||
padding: 6px 0 14px 0;
|
||||
}
|
||||
|
||||
/* === Section Header === */
|
||||
.section-title {
|
||||
background: var(--surface);
|
||||
color: var(--blue);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* === Data Tables === */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.data-table th {
|
||||
background: var(--surface);
|
||||
color: var(--blue);
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-right: 3px solid var(--blue);
|
||||
border-left: 1px solid var(--border);
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: right;
|
||||
}
|
||||
.data-table td {
|
||||
border: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
padding: 8px 12px;
|
||||
font-size: 10px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.data-table .lbl {
|
||||
background: var(--surface);
|
||||
color: var(--body);
|
||||
font-weight: 700;
|
||||
width: 15%;
|
||||
width: 18%;
|
||||
}
|
||||
.data-table .val {
|
||||
color: var(--ink);
|
||||
width: 35%;
|
||||
}
|
||||
.data-table .text-cell {
|
||||
padding: 12px;
|
||||
line-height: 2;
|
||||
text-align: justify;
|
||||
width: 32%;
|
||||
}
|
||||
|
||||
.signature-block {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
/* === Summary Block === */
|
||||
.summary-box {
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
font-size: 10px;
|
||||
line-height: 2;
|
||||
text-align: justify;
|
||||
min-height: 40mm;
|
||||
}
|
||||
|
||||
/* === Responsible Department === */
|
||||
.responsible-dept {
|
||||
margin-top: 8mm;
|
||||
background: var(--blue);
|
||||
color: var(--white);
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* === Bottom: QR + Notice (fixed position, like stamp) === */
|
||||
.qr-block {
|
||||
position: fixed;
|
||||
bottom: 22mm;
|
||||
left: 18mm;
|
||||
z-index: 100;
|
||||
}
|
||||
.qr-block table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.qr-block td {
|
||||
vertical-align: top;
|
||||
padding: 0;
|
||||
}
|
||||
.qr-block img {
|
||||
width: 25mm;
|
||||
height: 25mm;
|
||||
}
|
||||
.qr-label {
|
||||
font-size: 7px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
text-align: center;
|
||||
}
|
||||
.signature-label {
|
||||
font-size: 11px;
|
||||
color: var(--blue);
|
||||
}
|
||||
.signature-line {
|
||||
width: 200px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin: 30px auto 0 auto;
|
||||
height: 1px;
|
||||
}
|
||||
.signature-stamp-text {
|
||||
font-size: 9px;
|
||||
.qr-notice {
|
||||
font-size: 8px;
|
||||
color: var(--muted);
|
||||
margin-top: 8px;
|
||||
line-height: 1.6;
|
||||
text-align: right;
|
||||
padding-right: 5mm;
|
||||
max-width: 45mm;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Page 1: Complaint Forwarding Form -->
|
||||
<div class="page">
|
||||
{% include "complaints/partials/pdf_letterhead_header.html" %}
|
||||
<div class="watermark"><img src="{{ logo_path }}" alt=""></div>
|
||||
|
||||
<div class="form-body">
|
||||
<!-- Title -->
|
||||
<div class="form-title">نموذج الشكوى</div>
|
||||
|
||||
<!-- Section 1: Complainant Information -->
|
||||
<div class="section-title">بيانات مقدم الشكوى</div>
|
||||
<table class="data-table">
|
||||
<tr>
|
||||
<td class="lbl">رقم الملف</td>
|
||||
<td class="val">{{ complaint.reference_number|default:"—" }}</td>
|
||||
<td class="lbl">الاسم</td>
|
||||
<td class="val">{{ complainant_name|default:"—" }}</td>
|
||||
<td class="lbl">رقم الملف</td>
|
||||
<td class="val">{{ complaint.reference_number|default:"—" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="lbl">جهة الادعاء</td>
|
||||
<td class="val">{{ source_name|default:"—" }}</td>
|
||||
<td class="lbl">تاريخ التقديم</td>
|
||||
<td class="val">{{ submission_date }}</td>
|
||||
<td class="lbl">الموقع</td>
|
||||
<td class="val">{{ department_name|default:"—" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="lbl">تاريخ الحادثة</td>
|
||||
<td class="val">{{ incident_date }}</td>
|
||||
<td class="lbl">رقم الحالة</td>
|
||||
<td class="val">{{ complaint.reference_number|default:"—" }}</td>
|
||||
<td class="val">{{ incident_date|default:"—" }}</td>
|
||||
<td class="lbl">تاريخ التقديم</td>
|
||||
<td class="val">{{ submission_date|default:"—" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="lbl">رقم الاتصال</td>
|
||||
<td class="val" colspan="3">{{ complaint.contact_phone|default:"—" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Section 2: Information About Person Complained Of -->
|
||||
<div class="section-title">بيانات الموظف المشكو عليه</div>
|
||||
<table class="data-table">
|
||||
<tr>
|
||||
<td class="lbl">الموظف</td>
|
||||
<td class="val">{{ accused_staff_name|default:"—" }}</td>
|
||||
<td class="lbl">القسم</td>
|
||||
<td class="val">{{ department_name|default:"—" }}</td>
|
||||
<td class="lbl">الموظف المشكو عليه</td>
|
||||
<td class="val">{{ accused_staff_name|default:"—" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="lbl">الوظيفة</td>
|
||||
<td class="val">{{ accused_staff_title|default:"—" }}</td>
|
||||
<td class="lbl">القسم الرئيسي</td>
|
||||
<td class="val">{% if complaint.department and complaint.department.parent %}{{ complaint.department.parent.name_ar|default:complaint.department.parent.name_en }}{% elif complaint.department %}{{ complaint.department.name_ar|default:complaint.department.name_en }}{% else %}—{% endif %}</td>
|
||||
<td class="lbl">تاريخ الإرسال</td>
|
||||
<td class="val">{{ sent_to_dept_date }}</td>
|
||||
<td class="val">{{ sent_to_dept_date|default:"—" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table class="data-table">
|
||||
<tr><th colspan="4">مختصر الشكوى</th></tr>
|
||||
<tr>
|
||||
<td colspan="4" class="text-cell">{{ content_summary }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- Section 3: Complaint Summary -->
|
||||
<div class="section-title">مختصر الشكوى</div>
|
||||
<div class="summary-box">{{ content_summary|default:complaint.description }}</div>
|
||||
|
||||
<!-- Section 4: Responsible Department -->
|
||||
<div class="responsible-dept">قسم علاقات المرضى</div>
|
||||
</div>
|
||||
|
||||
{% include "complaints/partials/pdf_letterhead_footer.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Page 2: Complaint Response Form -->
|
||||
<div class="page">
|
||||
{% include "complaints/partials/pdf_letterhead_header.html" %}
|
||||
<div class="watermark"><img src="{{ logo_path }}" alt=""></div>
|
||||
|
||||
<div class="form-body">
|
||||
<div class="form-title">نموذج رد الشكوى</div>
|
||||
|
||||
<table class="data-table">
|
||||
<tr>
|
||||
<td class="lbl">الموظف المشكو عليه</td>
|
||||
<td class="val">{{ accused_staff_name|default:"—" }}</td>
|
||||
<td class="lbl">الوظيفة</td>
|
||||
<td class="val">{{ accused_staff_title|default:"—" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="lbl">القسم</td>
|
||||
<td class="val">{{ department_name|default:"—" }}</td>
|
||||
<td class="lbl">تاريخ الرد</td>
|
||||
<td class="val">{{ response_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="lbl">تاريخ الإرسال</td>
|
||||
<td class="val">{{ sent_to_dept_date }}</td>
|
||||
<td class="lbl"></td>
|
||||
<td class="val"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table class="data-table">
|
||||
<tr><th colspan="4">مختصر الرد</th></tr>
|
||||
<tr>
|
||||
<td colspan="4" class="text-cell">{% if dept_response_summary %}{{ dept_response_summary }}{% else %}لم يتم تسجيل رد من القسم بعد.{% endif %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% include "complaints/partials/pdf_letterhead_footer.html" %}
|
||||
<!-- QR Code + Notice (fixed bottom-left, table-aligned tops) -->
|
||||
{% if qr_code_path %}
|
||||
<div class="qr-block">
|
||||
<table dir="ltr">
|
||||
<tr>
|
||||
<td>
|
||||
<img src="{{ qr_code_path }}" alt="QR">
|
||||
<div class="qr-label">للتحقق من صحة المستند</div>
|
||||
</td>
|
||||
<td class="qr-notice">
|
||||
هذه الوثيقة صادرة إلكترونياً ولا تتطلب توقيعاً يدوياً.<br>
|
||||
This document is electronically generated<br>
|
||||
and does not require a signature.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if stamp_path %}
|
||||
<img class="stamp" src="{{ stamp_path }}" alt="">
|
||||
{% endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -110,6 +110,64 @@
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="button" onclick="showRejectForm()" class="w-full mt-3 flex items-center justify-center gap-2 px-4 py-2.5 border-2 border-red-200 text-red-600 rounded-xl font-bold text-sm hover:border-red-400 hover:bg-red-50 transition">
|
||||
<i data-lucide="log-out" class="w-4 h-4"></i>
|
||||
{% trans "Wrong Department" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Reject Routing Form -->
|
||||
<div id="reject-section" style="display:none">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 mt-4">
|
||||
{% if error %}
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-3 mb-4 text-sm text-red-700 font-semibold">
|
||||
{% trans error %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" class="space-y-4">
|
||||
{% csrf_token %}
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-500"></i>
|
||||
<h2 class="text-base font-bold text-navy">{% trans "Reject Routing" %}</h2>
|
||||
</div>
|
||||
<p class="text-sm text-slate">{% trans "Use this if the complaint was sent to the wrong department. The PX team will be notified to re-route it." %}</p>
|
||||
|
||||
<div>
|
||||
<label for="rejection_reason" class="block text-sm font-semibold text-navy mb-2">
|
||||
{% trans "Reason" %} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea name="rejection_reason" id="rejection_reason" rows="4" required
|
||||
class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl text-sm text-slate focus:outline-none focus:border-navy focus:ring-2 focus:ring-navy/20 resize-none"
|
||||
placeholder="{% trans 'Explain why this complaint does not belong to your department...' %}">{{ rejection_reason|default:'' }}</textarea>
|
||||
</div>
|
||||
|
||||
{% if hospital_departments %}
|
||||
<div>
|
||||
<label for="suggested_department_id" class="block text-sm font-semibold text-navy mb-2">
|
||||
{% trans "Suggest Correct Department (optional)" %}
|
||||
</label>
|
||||
<select name="suggested_department_id" id="suggested_department_id"
|
||||
class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl text-sm text-slate focus:outline-none focus:border-navy bg-white">
|
||||
<option value="">{% trans "Select Department" %}</option>
|
||||
{% for dept in hospital_departments %}
|
||||
<option value="{{ dept.id }}">{{ dept.get_localized_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="button" onclick="hideRejectForm()" class="flex-1 px-4 py-3 border-2 border-slate-200 text-slate-700 rounded-xl font-bold text-sm hover:border-slate-400 transition">
|
||||
{% trans "Cancel" %}
|
||||
</button>
|
||||
<button type="submit" name="action" value="reject_routing" class="flex-1 px-4 py-3 bg-red-500 text-white rounded-xl font-bold text-sm hover:bg-red-600 transition flex items-center justify-center gap-2"
|
||||
onclick="return confirm('{% trans "Reject this routing as wrong department?" %}')">
|
||||
<i data-lucide="log-out" class="w-4 h-4"></i> {% trans "Reject Routing" %}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-slate/60 text-center">{% trans "This action cannot be undone." %}</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reply Form -->
|
||||
@ -124,6 +182,57 @@
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-4">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if otp_sent %}
|
||||
<!-- ═══ OTP VERIFICATION BANNER (top, prominent) ═══ -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="shield-check" class="w-5 h-5 text-green-600"></i>
|
||||
<p class="text-sm font-bold text-green-800">{% trans "Verify your submission" %}</p>
|
||||
</div>
|
||||
<button type="submit" name="action" value="cancel_otp"
|
||||
class="text-xs text-slate/60 hover:text-navy underline flex items-center gap-1">
|
||||
<i data-lucide="pencil" class="w-3 h-3"></i>
|
||||
{% trans "Edit response" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-green-700 mb-3">
|
||||
{% if otp_phone and otp_email %}{% trans "Verification code sent to your phone and email." %}
|
||||
{% elif otp_phone %}{% trans "Verification code sent to your phone." %}
|
||||
{% elif otp_email %}{% trans "Verification code sent to your email." %}
|
||||
{% else %}{% trans "Verification code sent." %}{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="text" name="otp_code" id="otpInput" maxlength="6" pattern="[0-9]{6}" autofocus
|
||||
class="w-32 px-3 py-2 border-2 border-green-300 rounded-lg text-center text-lg font-bold tracking-widest focus:outline-none focus:border-green-600"
|
||||
placeholder="000000" required
|
||||
oninput="toggleVerifyBtn()">
|
||||
<button type="submit" name="action" value="verify_submit" id="verifyBtn"
|
||||
class="flex-1 px-4 py-2.5 bg-green-600 text-white rounded-xl font-bold text-sm hover:bg-green-700 transition disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
disabled>
|
||||
<i data-lucide="check-circle" class="w-4 h-4"></i>
|
||||
{% trans "Verify & Submit" %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<p class="text-xs text-slate/60">{% trans "Enter the 6-digit code to complete your submission." %}</p>
|
||||
<button type="submit" name="action" value="send_code"
|
||||
class="text-xs text-green-700 hover:text-green-800 underline">
|
||||
{% trans "Resend code" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Locked form fields wrapper -->
|
||||
<div class="relative rounded-xl" style="opacity: 0.5; pointer-events: none;">
|
||||
<div class="absolute -top-2.5 left-4 px-2 py-0.5 bg-slate-200 text-slate-600 rounded text-[10px] font-bold uppercase tracking-wider flex items-center gap-1 z-10">
|
||||
<i data-lucide="lock" class="w-3 h-3"></i>
|
||||
{% trans "Locked" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="explanation" class="block text-sm font-semibold text-navy mb-2">
|
||||
{% trans "Your Response" %} <span class="text-red-500">*</span>
|
||||
@ -218,33 +327,10 @@
|
||||
</div>
|
||||
|
||||
{% if otp_sent %}
|
||||
<!-- Step 2: Code entry -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-xl p-4">
|
||||
<p class="text-sm font-semibold text-green-800 mb-3">
|
||||
{% if otp_phone and otp_email %}{% trans "Verification code sent to your phone and email." %}
|
||||
{% elif otp_phone %}{% trans "Verification code sent to your phone." %}
|
||||
{% elif otp_email %}{% trans "Verification code sent to your email." %}
|
||||
{% else %}{% trans "Verification code sent." %}{% endif %}
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="text" name="otp_code" id="otpInput" maxlength="6" pattern="[0-9]{6}"
|
||||
class="w-32 px-3 py-2 border-2 border-slate-200 rounded-lg text-center text-lg font-bold tracking-widest focus:outline-none focus:border-navy"
|
||||
placeholder="000000" required
|
||||
oninput="toggleVerifyBtn()">
|
||||
<button type="submit" name="action" value="verify_submit" id="verifyBtn"
|
||||
class="flex-1 px-4 py-2.5 bg-green-600 text-white rounded-xl font-bold text-sm hover:bg-green-700 transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled>
|
||||
{% trans "Verify & Submit" %}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-slate/60 mt-2">{% trans "Enter the 6-digit code to complete your submission." %}</p>
|
||||
<!-- Close the locked-fields wrapper -->
|
||||
</div>
|
||||
<button type="submit" name="action" value="send_code"
|
||||
class="text-xs text-slate/60 hover:text-navy underline">
|
||||
{% trans "Resend code" %}
|
||||
</button>
|
||||
{% else %}
|
||||
<!-- Step 1: Send code -->
|
||||
<!-- Step 1: Send code (only shown when OTP not yet sent) -->
|
||||
<button type="submit" name="action" value="send_code" id="sendCodeBtn"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-navy text-white rounded-xl font-bold text-sm hover:bg-blue transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled>
|
||||
@ -267,6 +353,16 @@
|
||||
document.getElementById('reply-section').style.display = 'block';
|
||||
}
|
||||
|
||||
function showRejectForm() {
|
||||
document.getElementById('action-choice').style.display = 'none';
|
||||
document.getElementById('reject-section').style.display = 'block';
|
||||
}
|
||||
|
||||
function hideRejectForm() {
|
||||
document.getElementById('reject-section').style.display = 'none';
|
||||
document.getElementById('action-choice').style.display = 'block';
|
||||
}
|
||||
|
||||
function goToInvestigation() {
|
||||
window.location.href = "{{ investigate_url }}";
|
||||
}
|
||||
@ -286,15 +382,18 @@
|
||||
var field = document.getElementById('projectField');
|
||||
if (field && yesRadio) field.classList.toggle('hidden', !yesRadio.checked);
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
toggleSubmitBtn();
|
||||
toggleVerifyBtn();
|
||||
toggleProjectField();
|
||||
// Auto-show reply form if OTP was already sent (e.g. page reload)
|
||||
{% if otp_sent %}
|
||||
showReplyForm();
|
||||
{% endif %}
|
||||
});
|
||||
// Init directly — script is at the bottom of <body>, all elements exist.
|
||||
// (DOMContentLoaded has already fired by the time this runs, so a listener
|
||||
// would be dead code.)
|
||||
toggleSubmitBtn();
|
||||
toggleVerifyBtn();
|
||||
toggleProjectField();
|
||||
// Auto-show reply form if OTP was already sent (e.g. page reload after send_code)
|
||||
{% if otp_sent %}
|
||||
showReplyForm();
|
||||
{% elif show_reject_form %}
|
||||
showRejectForm();
|
||||
{% endif %}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
66
templates/complaints/explanation_routing_rejected.html
Normal file
66
templates/complaints/explanation_routing_rejected.html
Normal file
@ -0,0 +1,66 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ LANGUAGE_CODE|default:'en' }}" dir="{% if LANGUAGE_CODE == 'ar' %}rtl{% else %}ltr{% endif %}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ complaint.reference_number }} - {% trans "Routing Rejected" %}</title>
|
||||
<link rel="stylesheet" href="{% static 'dist/css/tailwind.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'vendor/fonts/noto-kufi-arabic/arabic.css' %}">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
body { font-family: 'Inter', 'Noto Kufi Arabic', sans-serif; }
|
||||
[dir="rtl"] body { font-family: 'Noto Kufi Arabic', sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-50 min-h-screen py-8 px-4">
|
||||
<div class="w-full max-w-2xl mx-auto">
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-6">
|
||||
<img src="{% static 'img/hh-logo.png' %}" alt="Al Hammadi Hospital" class="h-16 w-auto mx-auto inline-block">
|
||||
</div>
|
||||
|
||||
<!-- Success Card -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-8 text-center">
|
||||
<div class="w-16 h-16 mx-auto bg-amber-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg class="w-8 h-8 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
</div>
|
||||
<h1 class="text-lg font-bold text-navy mb-1">{% trans "Routing Rejected" %}</h1>
|
||||
<p class="text-sm text-slate">{% trans "The PX team has been notified that this complaint was sent to the wrong department. They will re-route it accordingly." %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 mt-4">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-bold text-slate/60 uppercase">{% trans "Reference" %}</span>
|
||||
<span class="text-sm font-bold text-navy">{{ complaint.reference_number }}</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2 mb-3">
|
||||
<span class="text-xs font-bold text-slate/60 uppercase pt-0.5 shrink-0">{% trans "Title" %}</span>
|
||||
<span class="text-sm text-slate">{{ complaint.title }}</span>
|
||||
</div>
|
||||
{% if involved_dept and involved_dept.department %}
|
||||
<div class="flex items-start gap-2 mb-3">
|
||||
<span class="text-xs font-bold text-slate/60 uppercase pt-0.5 shrink-0">{% trans "Rejected Department" %}</span>
|
||||
<span class="text-sm text-slate">{{ involved_dept.department.get_localized_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if involved_dept and involved_dept.rejection_reason %}
|
||||
<div class="flex items-start gap-2 mb-3">
|
||||
<span class="text-xs font-bold text-slate/60 uppercase pt-0.5 shrink-0">{% trans "Reason" %}</span>
|
||||
<span class="text-sm text-slate">{{ involved_dept.rejection_reason }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if involved_dept and involved_dept.suggested_department %}
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-xs font-bold text-slate/60 uppercase pt-0.5 shrink-0">{% trans "Suggested Department" %}</span>
|
||||
<span class="text-sm text-slate">{{ involved_dept.suggested_department.get_localized_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -126,6 +126,9 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Workflow Stepper -->
|
||||
{% include "core/workflow_stepper.html" %}
|
||||
|
||||
<nav class="bg-white px-6 flex gap-6 border-b shadow-sm mb-6 rounded-t-2xl">
|
||||
<button class="py-4 text-sm tab-active" onclick="switchTab('details')" id="tab-details">{% trans "Details" %}</button>
|
||||
<button class="py-4 text-sm tab-inactive" onclick="switchTab('timeline')" id="tab-timeline">{% trans "Timeline" %}</button>
|
||||
@ -556,44 +559,6 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if inquiry.status == 'resolved' or inquiry.status == 'closed' %}
|
||||
<section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
|
||||
<h3 class="font-bold text-navy mb-3 text-sm flex items-center gap-2">
|
||||
<i data-lucide="smile" class="w-4 h-4"></i> {% trans "Satisfaction" %}
|
||||
</h3>
|
||||
{% if inquiry.satisfaction %}
|
||||
<div class="mb-3">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold
|
||||
{% if inquiry.satisfaction == 'satisfied' %}bg-green-100 text-green-800 border border-green-300
|
||||
{% elif inquiry.satisfaction == 'neutral' %}bg-yellow-100 text-yellow-800 border border-yellow-300
|
||||
{% elif inquiry.satisfaction == 'dissatisfied' %}bg-red-100 text-red-800 border border-red-300
|
||||
{% else %}bg-slate-100 text-slate-600 border border-slate-300{% endif %}">
|
||||
{{ inquiry.get_satisfaction_display }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'inquiries:inquiry_update_satisfaction' inquiry.pk %}">
|
||||
{% csrf_token %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="submit" name="satisfaction" value="satisfied"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
|
||||
{% if inquiry.satisfaction == 'satisfied' %}border-green-500 bg-green-50 text-green-700{% else %}border-slate-200 text-slate-600 hover:border-green-400{% endif %}">
|
||||
<i data-lucide="thumbs-up" class="w-3.5 h-3.5"></i> {% trans "Satisfied" %}
|
||||
</button>
|
||||
<button type="submit" name="satisfaction" value="neutral"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
|
||||
{% if inquiry.satisfaction == 'neutral' %}border-yellow-500 bg-yellow-50 text-yellow-700{% else %}border-slate-200 text-slate-600 hover:border-yellow-400{% endif %}">
|
||||
<i data-lucide="minus-circle" class="w-3.5 h-3.5"></i> {% trans "Neutral" %}
|
||||
</button>
|
||||
<button type="submit" name="satisfaction" value="dissatisfied"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
|
||||
{% if inquiry.satisfaction == 'dissatisfied' %}border-red-500 bg-red-50 text-red-700{% else %}border-slate-200 text-slate-600 hover:border-red-400{% endif %}">
|
||||
<i data-lucide="thumbs-down" class="w-3.5 h-3.5"></i> {% trans "Dissatisfied" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
@ -267,7 +267,7 @@
|
||||
var disabled = s.already_added || alreadyInList;
|
||||
var onclickAttr = disabled ? '' : 'onclick="addStaffCard(\'' + s.id + '\', \'' + escapeHtml(s.name).replace(/'/g, "\\'") + '\', \'' + escapeHtml(s.department_name).replace(/'/g, "\\'") + '\')"';
|
||||
return '<div class="px-3 py-2 flex items-center justify-between ' + (disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer hover:bg-slate-50') + '" ' + onclickAttr + '>' +
|
||||
'<span class="text-sm">' + escapeHtml(s.name) + ' <span class="text-xs text-slate/60">' + escapeHtml(s.department_name) + '</span></span>' +
|
||||
'<span class="text-sm">' + escapeHtml(s.name) + (s.employee_id ? ' <span class="text-xs text-slate/40">(' + escapeHtml(s.employee_id) + ')</span>' : '') + ' <span class="text-xs text-slate/60">' + escapeHtml(s.department_name) + '</span></span>' +
|
||||
(disabled ? '<span class="text-[10px] font-bold text-slate/60">{% trans "ADDED" %}</span>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
@ -127,6 +127,58 @@
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-4">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if otp_sent %}
|
||||
<!-- ═══ OTP VERIFICATION BANNER (top, prominent) ═══ -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="shield-check" class="w-5 h-5 text-green-600"></i>
|
||||
<p class="text-sm font-bold text-green-800">{% trans "Verify your submission" %}</p>
|
||||
</div>
|
||||
<button type="submit" name="action" value="cancel_otp"
|
||||
class="text-xs text-slate/60 hover:text-navy underline flex items-center gap-1">
|
||||
<i data-lucide="pencil" class="w-3 h-3"></i>
|
||||
{% trans "Edit response" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-green-700 mb-3">
|
||||
{% if otp_phone and otp_email %}{% trans "Verification code sent to your phone and email." %}
|
||||
{% elif otp_phone %}{% trans "Verification code sent to your phone." %}
|
||||
{% elif otp_email %}{% trans "Verification code sent to your email." %}
|
||||
{% else %}{% trans "Verification code sent." %}{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="text" name="otp_code" id="otpInput" maxlength="6" pattern="[0-9]{6}" autofocus
|
||||
class="w-32 px-3 py-2 border-2 border-green-300 rounded-lg text-center text-lg font-bold tracking-widest focus:outline-none focus:border-green-600"
|
||||
placeholder="000000" required
|
||||
oninput="toggleVerifyBtn()">
|
||||
<button type="submit" name="action" value="verify_submit" id="verifyBtn"
|
||||
class="flex-1 px-4 py-2.5 bg-green-600 text-white rounded-xl font-bold text-sm hover:bg-green-700 transition disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
disabled>
|
||||
<i data-lucide="check-circle" class="w-4 h-4"></i>
|
||||
{% trans "Verify & Submit" %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<p class="text-xs text-slate/60">{% trans "Enter the 6-digit code to complete your submission." %}</p>
|
||||
<button type="submit" name="action" value="send_code"
|
||||
class="text-xs text-green-700 hover:text-green-800 underline">
|
||||
{% trans "Resend code" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Locked form fields wrapper -->
|
||||
<div class="relative rounded-xl" style="opacity: 0.5; pointer-events: none;">
|
||||
<div class="absolute -top-2.5 left-4 px-2 py-0.5 bg-slate-200 text-slate-600 rounded text-[10px] font-bold uppercase tracking-wider flex items-center gap-1 z-10">
|
||||
<i data-lucide="lock" class="w-3 h-3"></i>
|
||||
{% trans "Locked" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<textarea name="final_reply" rows="6"
|
||||
class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl text-sm text-slate focus:outline-none focus:border-navy resize-none"
|
||||
placeholder="{% trans 'Write your final reply based on the feedback...' %}"
|
||||
@ -197,7 +249,7 @@
|
||||
<!-- Consent -->
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="consent" id="consentCheckbox" class="mt-0.5 w-5 h-5 accent-navy shrink-0" required
|
||||
<input type="checkbox" name="consent" id="consentCheckbox" class="mt-0.5 w-5 h-5 accent-navy shrink-0"
|
||||
{% if consent_checked %}checked{% endif %}
|
||||
onchange="toggleSubmitBtn()">
|
||||
<span class="text-sm text-slate-700 leading-relaxed">
|
||||
@ -207,37 +259,14 @@
|
||||
</div>
|
||||
|
||||
{% if otp_sent %}
|
||||
<!-- Step 2: Code entry -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-xl p-4">
|
||||
<p class="text-sm font-semibold text-green-800 mb-3">
|
||||
{% if otp_phone and otp_email %}{% trans "Verification code sent to your phone and email." %}
|
||||
{% elif otp_phone %}{% trans "Verification code sent to your phone." %}
|
||||
{% elif otp_email %}{% trans "Verification code sent to your email." %}
|
||||
{% else %}{% trans "Verification code sent." %}{% endif %}
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="text" name="otp_code" id="otpInput" maxlength="6" pattern="[0-9]{6}"
|
||||
class="w-32 px-3 py-2 border-2 border-slate-200 rounded-lg text-center text-lg font-bold tracking-widest focus:outline-none focus:border-navy"
|
||||
placeholder="000000" required
|
||||
oninput="toggleVerifyBtn()">
|
||||
<button type="submit" name="action" value="verify_submit" id="verifyBtn"
|
||||
class="flex-1 px-4 py-2.5 bg-green-600 text-white rounded-xl font-bold text-sm hover:bg-green-700 transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled>
|
||||
{% trans "Verify & Submit" %}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-slate/60 mt-2">{% trans "Enter the 6-digit code to complete your submission." %}</p>
|
||||
<!-- Close the locked-fields wrapper -->
|
||||
</div>
|
||||
<button type="submit" name="action" value="send_code"
|
||||
class="text-xs text-slate/60 hover:text-navy underline">
|
||||
{% trans "Resend code" %}
|
||||
</button>
|
||||
{% else %}
|
||||
<!-- Step 1: Send code -->
|
||||
<!-- Step 1: Send code (only when OTP not yet sent) -->
|
||||
<button type="submit" name="action" value="send_code" id="sendCodeBtn"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-navy text-white rounded-xl font-bold text-sm hover:bg-blue transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled>
|
||||
{% trans "Send Verification Code" %}
|
||||
<i data-lucide="send" class="w-4 h-4"></i> {% trans "Send Verification Code" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
@ -267,11 +296,11 @@
|
||||
var field = document.getElementById('projectField');
|
||||
if (field && yesRadio) field.classList.toggle('hidden', !yesRadio.checked);
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
toggleSubmitBtn();
|
||||
toggleVerifyBtn();
|
||||
toggleProjectField();
|
||||
});
|
||||
// Init directly — script is at the bottom of <body>, all elements exist.
|
||||
toggleSubmitBtn();
|
||||
toggleVerifyBtn();
|
||||
toggleProjectField();
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -4,9 +4,10 @@
|
||||
<h3 class="text-lg font-bold text-navy">{% trans "Related PX Actions" %}</h3>
|
||||
{% if can_admin and complaint.is_active_status %}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button onclick="createAction()" class="px-3 py-2 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-semibold hover:opacity-90 transition inline-flex items-center gap-2 text-sm">
|
||||
<a href="{% url 'actions:action_create' %}?complaint_id={{ complaint.id }}"
|
||||
class="px-3 py-2 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-semibold hover:opacity-90 transition inline-flex items-center gap-2 text-sm">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Create Action" %}
|
||||
</button>
|
||||
</a>
|
||||
<a href="{% url 'projects:project_create' %}?related_model=complaint&related_id={{ complaint.pk }}"
|
||||
class="px-3 py-2 bg-teal-600 text-white rounded-xl font-semibold hover:bg-teal-700 transition inline-flex items-center gap-2 text-sm">
|
||||
<i data-lucide="folder-plus" class="w-4 h-4"></i> {% trans "QI Project" %}
|
||||
@ -47,9 +48,3 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function createAction() {
|
||||
window.location.href = "{% url 'actions:action_create' %}?source_type=complaint&complaint_id={{ complaint.id }}";
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -130,29 +130,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- AI Resolution Helper -->
|
||||
{% if user.is_px_admin or user.is_hospital_admin %}
|
||||
{% if complaint.is_active_status %}
|
||||
<div class="mt-4 pt-3 border-t-2 border-slate-100">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="font-bold text-navy flex items-center gap-2 text-sm">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4 text-amber-500"></i>
|
||||
{% trans "AI Resolution Helper" %}
|
||||
</h4>
|
||||
</div>
|
||||
<p class="text-slate text-xs mb-3">{% trans "Get AI-powered suggestions on how to resolve this complaint based on its content and any explanations received." %}</p>
|
||||
<div id="aiHelperContent">
|
||||
<div class="text-center py-3">
|
||||
<button onclick="loadAIHelperSuggestions()" class="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-lg font-bold hover:from-purple-600 hover:to-indigo-600 transition text-xs shadow">
|
||||
<i data-lucide="sparkles" class="w-4 h-4"></i>
|
||||
{% trans "Get Resolution Suggestions" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
@ -321,7 +298,9 @@ function showAlert(type, message) {
|
||||
|
||||
function loadAIHelperSuggestions() {
|
||||
const container = document.getElementById('aiHelperContent');
|
||||
container.innerHTML = '<div class="text-center py-4"><div class="inline-block w-6 h-6 border-3 border-purple-200 border-t-purple-500 rounded-full animate-spin"></div><p class="text-slate text-xs mt-2">{% trans "Analyzing complaint for resolution suggestions..." %}</p></div>';
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="text-center py-3"><div class="inline-block w-5 h-5 border-2 border-slate-200 border-t-navy rounded-full animate-spin"></div><p class="text-slate text-xs mt-2">{% trans "Generating suggestions..." %}</p></div>';
|
||||
}
|
||||
|
||||
fetch('/complaints/api/complaints/{{ complaint.pk }}/ai_helper_suggestions/', {
|
||||
headers: {'X-CSRFToken': getCSRFToken()},
|
||||
@ -329,49 +308,18 @@ function loadAIHelperSuggestions() {
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
container.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">' + escapeHtml(data.error || 'Failed to generate suggestions') + '</div>';
|
||||
return;
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">' + escapeHtml(data.error || '{% trans "Failed to generate suggestions" %}') + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
if (data.suggestions && data.suggestions.suggestions) {
|
||||
html += '<div class="space-y-2 mb-4">';
|
||||
data.suggestions.suggestions.forEach((s, i) => {
|
||||
html += '<div class="bg-gradient-to-r from-purple-50 to-indigo-50 border border-purple-100 rounded-lg p-3">';
|
||||
html += '<div class="flex items-start gap-2">';
|
||||
html += '<span class="flex-shrink-0 w-5 h-5 bg-purple-500 text-white rounded-full flex items-center justify-center text-xs font-bold">' + (i+1) + '</span>';
|
||||
html += '<div><p class="font-bold text-navy text-sm">' + escapeHtml(s.title) + '</p>';
|
||||
html += '<p class="text-slate text-xs mt-1">' + escapeHtml(s.description) + '</p></div>';
|
||||
html += '</div></div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if (data.suggestions && data.suggestions.recommended_actions) {
|
||||
html += '<h5 class="font-bold text-navy mb-2 flex items-center gap-2 text-sm"><i data-lucide="list-checks" class="w-4 h-4 text-blue"></i> {% trans "Recommended Next Steps" %}</h5>';
|
||||
html += '<ul class="space-y-1 mb-4">';
|
||||
data.suggestions.recommended_actions.forEach(a => {
|
||||
html += '<li class="flex items-start gap-2 text-xs text-slate-700"><i data-lucide="arrow-right" class="w-3.5 h-3.5 text-green-500 flex-shrink-0 mt-0.5"></i>' + escapeHtml(a) + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
|
||||
if (data.suggestions && data.suggestions.communication_tip) {
|
||||
html += '<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-3">';
|
||||
html += '<h5 class="font-bold text-blue mb-1 flex items-center gap-2 text-sm"><i data-lucide="message-circle" class="w-3.5 h-3.5"></i> {% trans "Communication Tip" %}</h5>';
|
||||
html += '<p class="text-xs text-blue-800">' + escapeHtml(data.suggestions.communication_tip) + '</p>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '<div class="text-center mt-2"><button onclick="loadAIHelperSuggestions()" class="text-xs text-purple-600 hover:text-purple-800 font-semibold">{% trans "Regenerate suggestions" %}</button></div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
lucide.createIcons();
|
||||
})
|
||||
.catch(err => {
|
||||
container.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-3 text-xs text-red-700">{% trans "An error occurred. Please try again." %}</div>';
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-3 text-xs text-red-700">{% trans "An error occurred. Please try again." %}</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -57,12 +57,17 @@
|
||||
{% if dept.response_notes %}
|
||||
<p class="text-[10px] text-slate/70 mt-1 italic max-w-xs truncate">{{ dept.response_notes|truncatechars:60 }}</p>
|
||||
{% endif %}
|
||||
{% if dept.routing_status == 'rejected' and dept.rejection_reason %}
|
||||
<p class="text-[10px] text-red-500 mt-1 italic max-w-xs truncate"><i data-lucide="log-out" class="w-3 h-3 inline"></i> {{ dept.rejection_reason|truncatechars:80 }}{% if dept.suggested_department %} · {% trans "Suggested:" %} {{ dept.suggested_department.get_localized_name }}{% endif %}</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="py-2.5 px-2">
|
||||
<span class="px-1.5 py-0.5 bg-light text-navy rounded text-[10px] font-semibold">{{ dept.get_role_display }}</span>
|
||||
</td>
|
||||
<td class="py-2.5 px-2">
|
||||
{% if not dept.response_submitted %}
|
||||
{% if dept.routing_status == 'rejected' %}
|
||||
<span class="inline-flex items-center gap-1 text-[10px] font-bold text-red-600"><i data-lucide="log-out" class="w-3 h-3"></i> {% trans "Wrong Department" %}</span>
|
||||
{% elif not dept.response_submitted %}
|
||||
<span class="text-[10px] text-slate/50 italic">{% trans "No response" %}</span>
|
||||
{% elif dept.manager_review_status == 'rejected' %}
|
||||
<span class="inline-flex items-center gap-1 text-[10px] font-bold text-red-600"><i data-lucide="x-circle" class="w-3 h-3"></i> {% trans "Rejected" %}</span>
|
||||
|
||||
@ -74,7 +74,7 @@
|
||||
</span>
|
||||
{% if complaint.satisfaction_set_at %}
|
||||
<span class="text-[10px] text-slate-400 ml-2">
|
||||
{{ complaint.satisfaction_set_at|date:"Y-m-d H:i" }}
|
||||
{% if complaint.satisfaction_set_by %}{% trans "by" %} {{ complaint.satisfaction_set_by.get_full_name }} · {% endif %}{{ complaint.satisfaction_set_at|date:"Y-m-d H:i" }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -91,6 +91,34 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif complaint.satisfaction_window_expired %}
|
||||
<!-- 5-day window expired: no editing allowed -->
|
||||
<div class="flex items-center gap-3 mt-3 p-3 bg-amber-50 border border-amber-200 rounded-xl">
|
||||
<i data-lucide="clock-alert" class="w-4 h-4 text-amber-500 shrink-0"></i>
|
||||
<div class="text-xs text-amber-700">
|
||||
<p class="font-bold">{% trans "Satisfaction window expired" %}</p>
|
||||
<p class="text-[10px] text-amber-600">{% trans "Satisfaction can only be set within 5 days of resolution." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif complaint.satisfaction_change_count >= 3 and can_edit %}
|
||||
<!-- Max changes reached -->
|
||||
<div class="flex items-center gap-3 mt-3 p-3 bg-slate-50 border border-slate-200 rounded-xl">
|
||||
<i data-lucide="lock" class="w-4 h-4 text-slate-500 shrink-0"></i>
|
||||
<div class="text-xs text-slate-600">
|
||||
<p class="font-bold">{% trans "No more PX-team changes allowed" %}</p>
|
||||
<p class="text-[10px] text-slate-400">{% trans "Satisfaction has been changed 3 times. Only the patient can override via the tracking page." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- No Response is always allowed even after max changes -->
|
||||
<form method="post" action="{% url 'complaints:update_satisfaction' pk=complaint.pk %}" class="mt-2">
|
||||
{% csrf_token %}
|
||||
<button type="submit" name="satisfaction" value="no_response"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-semibold border-2 transition
|
||||
{% if complaint.satisfaction == 'no_response' %}border-slate-400 bg-slate-50 text-slate-700
|
||||
{% else %}border-slate-200 text-slate-600 hover:border-slate-400 hover:bg-slate-50{% endif %}">
|
||||
<i data-lucide="phone-off" class="w-3.5 h-3.5"></i> {% trans "No Response" %}
|
||||
</button>
|
||||
</form>
|
||||
{% elif can_edit %}
|
||||
<form method="post" action="{% url 'complaints:update_satisfaction' pk=complaint.pk %}" class="mt-3">
|
||||
{% csrf_token %}
|
||||
@ -121,10 +149,32 @@
|
||||
<i data-lucide="phone-off" class="w-3.5 h-3.5"></i> {% trans "No Response" %}
|
||||
</button>
|
||||
</div>
|
||||
{% if complaint.satisfaction_change_count > 0 %}
|
||||
<p class="text-[10px] text-slate-400 mt-2">
|
||||
{{ complaint.satisfaction_change_count }}/3 {% trans "changes used" %} · {{ satisfaction_changes_remaining }} {% trans "remaining" %}
|
||||
<span class="text-slate-300">({% trans "No Response doesn't count" %})</span>
|
||||
</p>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% elif not complaint.satisfaction %}
|
||||
<p class="text-sm text-slate italic">{% trans "No satisfaction feedback recorded yet." %}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Change history -->
|
||||
{% if satisfaction_history and satisfaction_history|length > 0 %}
|
||||
<div class="mt-3 pt-3 border-t border-slate-100">
|
||||
<p class="text-[10px] font-bold text-slate/40 uppercase tracking-wider mb-2">{% trans "Change History" %}</p>
|
||||
<div class="space-y-1">
|
||||
{% for entry in satisfaction_history %}
|
||||
<div class="text-[10px] text-slate/50 flex items-start gap-1.5">
|
||||
<i data-lucide="history" class="w-3 h-3 mt-0.5 shrink-0 text-slate/30"></i>
|
||||
<span>{{ entry.message }}</span>
|
||||
<span class="text-slate/30 shrink-0">· {{ entry.created_at|date:"M d, H:i" }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
@ -160,7 +210,75 @@
|
||||
<p class="text-xs text-slate mt-2">{% trans "Click on a card to select. You can edit the selected resolution in the text area below before submitting." %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- Response Suggestions -->
|
||||
<div class="mb-4 bg-slate-50 border border-slate-200 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="font-bold text-navy flex items-center gap-2 text-sm">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4 text-navy"></i>
|
||||
{% trans "Response Suggestions" %}
|
||||
</h4>
|
||||
{% if complaint.ai_response_suggestions_at %}
|
||||
<span class="text-[10px] text-slate-400">{{ complaint.ai_response_suggestions_at|date:"Y-m-d H:i" }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if complaint.ai_response_suggestions %}
|
||||
<!-- Persisted suggestions rendered server-side -->
|
||||
<div class="space-y-2 mb-3">
|
||||
{% for s in complaint.ai_response_suggestions.suggestions %}
|
||||
<div class="bg-white border border-slate-200 rounded-lg p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 bg-navy text-white rounded-full flex items-center justify-center text-xs font-bold">{{ forloop.counter }}</span>
|
||||
<div>
|
||||
<p class="font-bold text-navy text-sm">{% if LANG == 'ar' and s.title_ar %}{{ s.title_ar }}{% else %}{{ s.title_en }}{% endif %}</p>
|
||||
<p class="text-slate text-xs mt-1">{% if LANG == 'ar' and s.description_ar %}{{ s.description_ar }}{% else %}{{ s.description_en }}{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if complaint.ai_response_suggestions.recommended_actions_en or complaint.ai_response_suggestions.recommended_actions_ar %}
|
||||
<h5 class="font-bold text-navy mb-2 flex items-center gap-2 text-sm"><i data-lucide="list-checks" class="w-4 h-4 text-blue"></i> {% trans "Recommended Next Steps" %}</h5>
|
||||
<ul class="space-y-1 mb-3">
|
||||
{% if LANG == 'ar' and complaint.ai_response_suggestions.recommended_actions_ar %}
|
||||
{% for a in complaint.ai_response_suggestions.recommended_actions_ar %}
|
||||
<li class="flex items-start gap-2 text-xs text-slate-700"><i data-lucide="arrow-right" class="w-3.5 h-3.5 text-green-500 flex-shrink-0 mt-0.5"></i>{{ a }}</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for a in complaint.ai_response_suggestions.recommended_actions_en %}
|
||||
<li class="flex items-start gap-2 text-xs text-slate-700"><i data-lucide="arrow-right" class="w-3.5 h-3.5 text-green-500 flex-shrink-0 mt-0.5"></i>{{ a }}</li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if complaint.ai_response_suggestions.communication_tip_en or complaint.ai_response_suggestions.communication_tip_ar %}
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-3">
|
||||
<h5 class="font-bold text-blue mb-1 flex items-center gap-2 text-sm"><i data-lucide="message-circle" class="w-3.5 h-3.5"></i> {% trans "Communication Tip" %}</h5>
|
||||
<p class="text-xs text-blue-800">{% if LANG == 'ar' and complaint.ai_response_suggestions.communication_tip_ar %}{{ complaint.ai_response_suggestions.communication_tip_ar }}{% else %}{{ complaint.ai_response_suggestions.communication_tip_en }}{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-center mt-2">
|
||||
<button type="button" onclick="loadAIHelperSuggestions()" class="text-xs text-navy hover:text-blue font-semibold underline">
|
||||
{% trans "Regenerate" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- No suggestions yet -->
|
||||
<p class="text-xs text-slate mb-3">{% trans "Get suggestions on how to respond to the patient." %}</p>
|
||||
<div id="aiHelperContent">
|
||||
<button type="button" onclick="loadAIHelperSuggestions()" class="inline-flex items-center gap-2 px-3 py-2 border-2 border-navy text-navy rounded-lg font-semibold hover:bg-navy hover:text-white transition text-sm">
|
||||
<i data-lucide="sparkles" class="w-4 h-4"></i>
|
||||
{% trans "Get Suggestions" %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Resolution Notes" %}</label>
|
||||
<textarea name="resolution" id="resolutionTextarea" rows="6" class="w-full border border-slate-200 rounded-xl p-4 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="{% trans 'Enter resolution details...' %}" oninput="toggleResolveBtn()"></textarea>
|
||||
|
||||
@ -76,10 +76,16 @@
|
||||
<!-- Q&A pairs: one compact table -->
|
||||
{% if event.type == 'staff_responded' and event.qa_pairs %}
|
||||
<table class="w-full mt-2 text-[11px] border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b-2 border-slate-200">
|
||||
<th class="py-1 pr-3 text-left text-[9px] font-bold text-slate/50 uppercase tracking-wider" style="width:55%">{% trans "Question" %}</th>
|
||||
<th class="py-1 text-left text-[9px] font-bold text-slate/50 uppercase tracking-wider">{% trans "Answer" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for qa in event.qa_pairs %}
|
||||
<tr class="border-b border-slate-100 last:border-0">
|
||||
<td class="py-1.5 pr-3 font-semibold text-slate-600 align-top" style="width:55%">
|
||||
<td class="py-1.5 pr-3 font-semibold text-slate-600 align-top">
|
||||
{{ forloop.counter }}. {{ qa.question }}
|
||||
</td>
|
||||
<td class="py-1.5 text-slate-700 align-top">
|
||||
@ -104,35 +110,73 @@
|
||||
<!-- Final reply -->
|
||||
{% if event.type == 'reply_submitted' %}
|
||||
{% if event.final_reply %}
|
||||
<div class="bg-green-50 rounded-lg p-2 mt-2">
|
||||
<p class="text-[11px] text-slate-700">{{ event.final_reply|truncatewords:50 }}</p>
|
||||
<div class="mt-2">
|
||||
<p class="text-[10px] font-bold text-slate/40 uppercase tracking-wider mb-1">{% trans "Response" %}</p>
|
||||
<div class="bg-green-50 rounded-lg p-2">
|
||||
<p class="text-[11px] text-slate-700">{{ event.final_reply|truncatewords:50 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if event.negligence or event.policy_issue or event.improvement_project %}
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
{% if event.negligence == 'yes' %}
|
||||
<span class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-red-100 text-red-700">{% trans "Negligence" %}</span>
|
||||
<div class="mt-2">
|
||||
<p class="text-[10px] font-bold text-slate/40 uppercase tracking-wider mb-1">{% trans "Assessment" %}</p>
|
||||
<table class="w-full text-[11px] border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b-2 border-slate-200">
|
||||
<th class="py-1 pr-3 text-left text-[9px] font-bold text-slate/50 uppercase tracking-wider" style="width:55%">{% trans "Question" %}</th>
|
||||
<th class="py-1 text-left text-[9px] font-bold text-slate/50 uppercase tracking-wider">{% trans "Answer" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-slate-100">
|
||||
<td class="py-1.5 pr-3 font-semibold text-slate-600 align-top">{% trans "Is there any negligence resulting from carelessness?" %}</td>
|
||||
<td class="py-1.5 align-top">
|
||||
{% if event.negligence == 'yes' %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-red-100 text-red-700">{% trans "Yes" %}</span>
|
||||
{% elif event.negligence == 'no' %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-green-100 text-green-700">{% trans "No" %}</span>
|
||||
{% else %}<span class="text-slate-300">—</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-slate-100">
|
||||
<td class="py-1.5 pr-3 font-semibold text-slate-600 align-top">{% trans "Is it resulting from policies or regulations?" %}</td>
|
||||
<td class="py-1.5 align-top">
|
||||
{% if event.policy_issue == 'yes' %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-red-100 text-red-700">{% trans "Yes" %}</span>
|
||||
{% elif event.policy_issue == 'no' %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-green-100 text-green-700">{% trans "No" %}</span>
|
||||
{% else %}<span class="text-slate-300">—</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1.5 pr-3 font-semibold text-slate-600 align-top">{% trans "Does this complaint require an improvement project?" %}</td>
|
||||
<td class="py-1.5 align-top">
|
||||
{% if event.improvement_project == 'yes' %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-amber-100 text-amber-700">{% trans "Yes" %}</span>
|
||||
{% elif event.improvement_project == 'no' %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-slate-100 text-slate-600">{% trans "No" %}</span>
|
||||
{% else %}<span class="text-slate-300">—</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% if event.improvement_project == 'yes' and event.improvement_note %}
|
||||
<div class="bg-amber-50 rounded-lg p-2 mt-1.5">
|
||||
<span class="text-[10px] font-bold text-amber-700">{% trans "Improvement Project:" %}</span>
|
||||
<span class="text-[11px] text-amber-800">{{ event.improvement_note }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if event.policy_issue == 'yes' %}
|
||||
<span class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-orange-100 text-orange-700">{% trans "Policy Issue" %}</span>
|
||||
{% endif %}
|
||||
{% if event.improvement_project == 'yes' %}
|
||||
<span class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-amber-100 text-amber-700">{% trans "Improvement Project" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if event.improvement_project == 'yes' and event.improvement_note %}
|
||||
<div class="bg-amber-50 rounded-lg p-2 mt-1.5">
|
||||
<span class="text-[10px] font-bold text-amber-700">{% trans "Improvement Project:" %}</span>
|
||||
<span class="text-[11px] text-amber-800">{{ event.improvement_note }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Direct reply -->
|
||||
{% if event.type == 'direct_reply' and event.reply %}
|
||||
<div class="bg-green-50 rounded-lg p-2 mt-2">
|
||||
<p class="text-[11px] text-slate-700">{{ event.reply|truncatewords:50 }}</p>
|
||||
<div class="mt-2">
|
||||
<p class="text-[10px] font-bold text-slate/40 uppercase tracking-wider mb-1">{% trans "Response" %}</p>
|
||||
<div class="bg-green-50 rounded-lg p-2">
|
||||
<p class="text-[11px] text-slate-700">{{ event.reply|truncatewords:50 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@ -485,7 +485,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
var satThankYou = document.getElementById('satisfactionThankYou');
|
||||
var isResolved = resp.has_response && resp.en;
|
||||
|
||||
if (isResolved) {
|
||||
if (isResolved && selectedType === 'complaint') {
|
||||
satSection.classList.remove('hidden');
|
||||
if (data.satisfaction_locked_by_patient) {
|
||||
satButtons.classList.add('hidden');
|
||||
|
||||
26
templates/core/workflow_stepper.html
Normal file
26
templates/core/workflow_stepper.html
Normal file
@ -0,0 +1,26 @@
|
||||
{% load i18n %}
|
||||
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
{% for step in workflow_steps %}
|
||||
<div class="flex flex-col items-center flex-shrink-0">
|
||||
{% if step.done %}
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-green-500 text-white">
|
||||
<i data-lucide="check" class="w-5 h-5"></i>
|
||||
</div>
|
||||
{% elif step.current %}
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-navy text-white ring-4 ring-navy/20">
|
||||
<i data-lucide="{{ step.icon }}" class="w-5 h-5"></i>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center border-2 border-slate-200 text-slate-300">
|
||||
<i data-lucide="{{ step.icon }}" class="w-5 h-5"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<span class="text-xs font-bold mt-2 {% if step.done %}text-slate{% elif step.current %}text-navy{% else %}text-slate-300{% endif %}">{{ step.label }}</span>
|
||||
</div>
|
||||
{% if not forloop.last %}
|
||||
<div class="flex-1 h-1 mx-2 rounded-full {% if step.done %}bg-green-500{% else %}bg-slate-200{% endif %}"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@ -57,6 +57,9 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Workflow Stepper -->
|
||||
{% include "core/workflow_stepper.html" %}
|
||||
|
||||
<nav class="bg-white px-6 flex gap-6 border-b shadow-sm mb-6 rounded-t-2xl">
|
||||
<button class="py-4 text-sm tab-active" onclick="switchTab('details')" id="tab-details">{% trans "Details" %}</button>
|
||||
{% if feedback.has_ai_analysis %}
|
||||
|
||||
@ -72,6 +72,9 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Workflow Stepper -->
|
||||
{% include "core/workflow_stepper.html" %}
|
||||
|
||||
<nav class="bg-white px-6 flex gap-6 border-b shadow-sm mb-6 rounded-t-2xl">
|
||||
<button class="py-4 text-sm tab-active" onclick="switchTab('details')" id="tab-details">{% trans "Details" %}</button>
|
||||
<button class="py-4 text-sm tab-inactive" onclick="switchTab('timeline')" id="tab-timeline">{% trans "Timeline" %}</button>
|
||||
@ -706,44 +709,6 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if observation.status == 'resolved' or observation.status == 'closed' %}
|
||||
<section class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
|
||||
<h3 class="font-bold text-navy mb-3 text-sm flex items-center gap-2">
|
||||
<i data-lucide="smile" class="w-4 h-4"></i> {% trans "Satisfaction" %}
|
||||
</h3>
|
||||
{% if observation.satisfaction %}
|
||||
<div class="mb-3">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold
|
||||
{% if observation.satisfaction == 'satisfied' %}bg-green-100 text-green-800 border border-green-300
|
||||
{% elif observation.satisfaction == 'neutral' %}bg-yellow-100 text-yellow-800 border border-yellow-300
|
||||
{% elif observation.satisfaction == 'dissatisfied' %}bg-red-100 text-red-800 border border-red-300
|
||||
{% else %}bg-slate-100 text-slate-600 border border-slate-300{% endif %}">
|
||||
{{ observation.get_satisfaction_display }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'observations:observation_update_satisfaction' observation.pk %}">
|
||||
{% csrf_token %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="submit" name="satisfaction" value="satisfied"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
|
||||
{% if observation.satisfaction == 'satisfied' %}border-green-500 bg-green-50 text-green-700{% else %}border-slate-200 text-slate-600 hover:border-green-400{% endif %}">
|
||||
<i data-lucide="thumbs-up" class="w-3.5 h-3.5"></i> {% trans "Satisfied" %}
|
||||
</button>
|
||||
<button type="submit" name="satisfaction" value="neutral"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
|
||||
{% if observation.satisfaction == 'neutral' %}border-yellow-500 bg-yellow-50 text-yellow-700{% else %}border-slate-200 text-slate-600 hover:border-yellow-400{% endif %}">
|
||||
<i data-lucide="minus-circle" class="w-3.5 h-3.5"></i> {% trans "Neutral" %}
|
||||
</button>
|
||||
<button type="submit" name="satisfaction" value="dissatisfied"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold border-2 transition
|
||||
{% if observation.satisfaction == 'dissatisfied' %}border-red-500 bg-red-50 text-red-700{% else %}border-slate-200 text-slate-600 hover:border-red-400{% endif %}">
|
||||
<i data-lucide="thumbs-down" class="w-3.5 h-3.5"></i> {% trans "Dissatisfied" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
@ -383,6 +383,86 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_respond and pending_routing_involvements %}
|
||||
<div class="bg-white rounded-xl shadow-sm border-2 border-blue-200 p-5 mb-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="inbox" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-navy">{% trans "Incoming Complaints" %}</h3>
|
||||
<p class="text-xs text-slate-400">{% trans "Complaints routed to your department awaiting your response" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{% for inv in pending_routing_involvements %}
|
||||
<div class="border border-slate-200 rounded-xl p-4 bg-slate-50/30">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<a href="{% url 'complaints:complaint_detail' pk=inv.complaint.pk %}" class="font-mono text-xs font-bold text-navy hover:underline">
|
||||
{{ inv.complaint.reference_number }}
|
||||
</a>
|
||||
<p class="text-sm text-slate mt-0.5">{{ inv.complaint.title|truncatechars:60 }}</p>
|
||||
</div>
|
||||
{% if inv.is_primary %}
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase whitespace-nowrap bg-navy/10 text-navy">{% trans "Primary" %}</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase whitespace-nowrap bg-slate-100 text-slate-600">{{ inv.get_role_display }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-slate mb-3">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<i data-lucide="send" class="w-3.5 h-3.5"></i>
|
||||
{% if inv.sent_at %}{% trans "Sent" %} {{ inv.sent_at|date:"d M Y" }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a href="{% url 'complaints:complaint_detail' pk=inv.complaint.pk %}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-navy text-white rounded-lg text-xs font-semibold hover:bg-blue transition">
|
||||
<i data-lucide="external-link" class="w-3.5 h-3.5"></i> {% trans "View" %}
|
||||
</a>
|
||||
<button type="button" onclick="toggleRejectRouting('{{ inv.pk }}')"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 border-2 border-red-200 text-red-600 rounded-lg text-xs font-semibold hover:border-red-400 hover:bg-red-50 transition">
|
||||
<i data-lucide="log-out" class="w-3.5 h-3.5"></i> {% trans "Wrong Department" %}
|
||||
</button>
|
||||
</div>
|
||||
<form method="post" action="{% url 'complaints:involved_department_reject_routing' pk=inv.pk %}" id="reject-routing-{{ inv.pk }}" class="hidden mt-3 pt-3 border-t border-slate-100 space-y-3">
|
||||
{% csrf_token %}
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-navy mb-1">{% trans "Reason" %} <span class="text-red-500">*</span></label>
|
||||
<textarea name="rejection_reason" rows="2" required
|
||||
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:outline-none focus:border-navy resize-none"
|
||||
placeholder="{% trans 'Why does this not belong to your department?' %}"></textarea>
|
||||
</div>
|
||||
{% if hospital_departments %}
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-navy mb-1">{% trans "Suggest Correct Department (optional)" %}</label>
|
||||
<select name="suggested_department_id" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:outline-none focus:border-navy bg-white">
|
||||
<option value="">{% trans "Select Department" %}</option>
|
||||
{% for dept in hospital_departments %}
|
||||
<option value="{{ dept.id }}">{{ dept.get_localized_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex gap-2">
|
||||
<button type="button" onclick="toggleRejectRouting('{{ inv.pk }}')"
|
||||
class="px-3 py-1.5 border-2 border-slate-200 text-slate-600 rounded-lg text-xs font-semibold hover:border-slate-400 transition">{% trans "Cancel" %}</button>
|
||||
<button type="submit" class="px-3 py-1.5 bg-red-500 text-white rounded-lg text-xs font-semibold hover:bg-red-600 transition">{% trans "Reject Routing" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function toggleRejectRouting(pk) {
|
||||
const form = document.getElementById('reject-routing-' + pk);
|
||||
if (form) form.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<!-- Department Head Info -->
|
||||
{% if staff_head %}
|
||||
<div class="bg-gradient-to-r from-navy/5 to-blue/5 border border-navy/10 rounded-2xl p-5 mb-6 flex items-center gap-4">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Departments" %} - PX360{% endblock %}
|
||||
{% block title %}{% trans "Departments" %}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
|
||||
@ -216,15 +216,18 @@
|
||||
{{ form.hospital }}
|
||||
{% endif %}
|
||||
|
||||
<!-- Department -->
|
||||
<div>
|
||||
<label for="{{ form.department.id_for_label }}" class="form-label">
|
||||
{% trans "Department" %}
|
||||
<!-- Departments (multi-select) -->
|
||||
<div class="md:col-span-2">
|
||||
<label for="{{ form.departments.id_for_label }}" class="form-label">
|
||||
{% trans "Departments" %}
|
||||
</label>
|
||||
{{ form.department }}
|
||||
{% if form.department.errors %}
|
||||
<p class="text-red-500 text-xs mt-1">{{ form.department.errors.0 }}</p>
|
||||
{{ form.departments }}
|
||||
{% if form.departments.errors %}
|
||||
<p class="text-red-500 text-xs mt-1">{{ form.departments.errors.0 }}</p>
|
||||
{% endif %}
|
||||
<p class="text-xs text-slate mt-1">
|
||||
{% trans "Select one or more departments. The team is auto-set from each department's champion and manager." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -239,7 +242,7 @@
|
||||
<p class="text-red-500 text-xs mt-1">{{ form.project_lead.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<label for="{{ form.status.id_for_label }}" class="form-label">
|
||||
@ -252,16 +255,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Members -->
|
||||
<!-- Team (derived, read-only) -->
|
||||
<div class="mt-6">
|
||||
<label for="{{ form.team_members.id_for_label }}" class="form-label">
|
||||
<label class="form-label">
|
||||
{% trans "Team Members" %}
|
||||
</label>
|
||||
{{ form.team_members }}
|
||||
{% if form.team_members.errors %}
|
||||
<p class="text-red-500 text-xs mt-1">{{ form.team_members.errors.0 }}</p>
|
||||
{% endif %}
|
||||
<p class="text-xs text-slate mt-1">{% trans "Hold Ctrl/Cmd to select multiple team members" %}</p>
|
||||
<p class="text-xs text-slate bg-blue-50 border border-blue-100 rounded-lg p-3">
|
||||
<i data-lucide="users" class="w-3.5 h-3.5 inline -mt-0.5"></i>
|
||||
{% blocktrans %}Team members are <strong>derived automatically</strong> from each selected department's champion and manager (plus the project lead). They cannot be edited directly.{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6 mt-6">
|
||||
@ -319,54 +321,58 @@
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Dynamic department and staff filtering based on hospital selection
|
||||
// Dynamic departments and project-lead filtering based on hospital selection.
|
||||
// (team_members is derived server-side, so there is no widget to sync here.)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const hospitalSelect = document.getElementById('{{ form.hospital.id_for_label }}');
|
||||
const departmentSelect = document.getElementById('{{ form.department.id_for_label }}');
|
||||
const departmentsSelect = document.getElementById('{{ form.departments.id_for_label }}');
|
||||
const projectLeadSelect = document.getElementById('{{ form.project_lead.id_for_label }}');
|
||||
const teamMembersSelect = document.getElementById('{{ form.team_members.id_for_label }}');
|
||||
|
||||
|
||||
function refreshDepartments(hospitalId) {
|
||||
if (!departmentsSelect || !hospitalId) return;
|
||||
fetch(`/api/organizations/hospitals/${hospitalId}/departments/`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// Preserve currently selected values (for edit form)
|
||||
const previouslySelected = Array.from(departmentsSelect.selectedOptions || []).map(o => o.value);
|
||||
departmentsSelect.innerHTML = '';
|
||||
(data.departments || []).forEach(dept => {
|
||||
const option = document.createElement('option');
|
||||
option.value = dept.id;
|
||||
option.textContent = {% if LANG == 'ar' %}dept.name_ar || dept.name{% else %}dept.name{% endif %};
|
||||
if (previouslySelected.includes(String(dept.id))) {
|
||||
option.selected = true;
|
||||
}
|
||||
departmentsSelect.appendChild(option);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function refreshProjectLead(hospitalId) {
|
||||
if (!projectLeadSelect || !hospitalId) return;
|
||||
fetch(`/api/organizations/hospitals/${hospitalId}/staff/`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const previouslySelected = projectLeadSelect.value;
|
||||
projectLeadSelect.innerHTML = '<option value="">---------</option>';
|
||||
(data.staff || []).forEach(staff => {
|
||||
const option = document.createElement('option');
|
||||
option.value = staff.id;
|
||||
option.textContent = `${staff.first_name} ${staff.last_name}`;
|
||||
if (String(staff.id) === String(previouslySelected)) {
|
||||
option.selected = true;
|
||||
}
|
||||
projectLeadSelect.appendChild(option);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (hospitalSelect) {
|
||||
hospitalSelect.addEventListener('change', function() {
|
||||
const hospitalId = this.value;
|
||||
if (!hospitalId) return;
|
||||
|
||||
// Fetch departments and staff for selected hospital
|
||||
fetch(`/api/organizations/hospitals/${hospitalId}/departments/`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update departments
|
||||
departmentSelect.innerHTML = '<option value="">---------</option>';
|
||||
data.departments.forEach(dept => {
|
||||
const option = document.createElement('option');
|
||||
option.value = dept.id;
|
||||
option.textContent = {% if LANG == 'ar' %}dept.name_ar || dept.name{% else %}dept.name{% endif %};
|
||||
departmentSelect.appendChild(option);
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch staff
|
||||
fetch(`/api/organizations/hospitals/${hospitalId}/staff/`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update project lead
|
||||
projectLeadSelect.innerHTML = '<option value="">---------</option>';
|
||||
data.staff.forEach(staff => {
|
||||
const option = document.createElement('option');
|
||||
option.value = staff.id;
|
||||
option.textContent = `${staff.first_name} ${staff.last_name}`;
|
||||
projectLeadSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Update team members
|
||||
teamMembersSelect.innerHTML = '';
|
||||
data.staff.forEach(staff => {
|
||||
const option = document.createElement('option');
|
||||
option.value = staff.id;
|
||||
option.textContent = `${staff.first_name} ${staff.last_name}`;
|
||||
teamMembersSelect.appendChild(option);
|
||||
});
|
||||
});
|
||||
refreshDepartments(hospitalId);
|
||||
refreshProjectLead(hospitalId);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -109,10 +109,12 @@
|
||||
<p class="text-sm text-navy mt-1">{{ project.hospital.name }}</p>
|
||||
</div>
|
||||
|
||||
{% if project.department %}
|
||||
{% if project.departments.exists %}
|
||||
<div class="mb-4">
|
||||
<span class="text-xs font-bold text-slate uppercase">{% trans "Department" %}</span>
|
||||
<p class="text-sm text-navy mt-1">{{ project.department.name }}</p>
|
||||
<span class="text-xs font-bold text-slate uppercase">{% trans "Departments" %}</span>
|
||||
<p class="text-sm text-navy mt-1">
|
||||
{% for dept in project.departments.all %}{{ dept.name }}{% if not forloop.last %}, {% endif %}{% endfor %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@ -105,10 +105,12 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if template.department %}
|
||||
{% if template.departments.exists %}
|
||||
<div class="mb-4">
|
||||
<span class="text-xs font-bold text-slate uppercase">{% trans "Department" %}</span>
|
||||
<p class="text-sm font-medium text-navy mt-1">{{ template.department.name }}</p>
|
||||
<span class="text-xs font-bold text-slate uppercase">{% trans "Departments" %}</span>
|
||||
<p class="text-sm font-medium text-navy mt-1">
|
||||
{% for dept in template.departments.all %}{{ dept.name }}{% if not forloop.last %}, {% endif %}{% endfor %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user