This commit is contained in:
ismail 2026-07-12 11:18:20 +03:00
parent 6baa34dec3
commit fe0a643607
25 changed files with 871 additions and 160 deletions

View File

@ -77,7 +77,7 @@ class UserCreateForm(DepartmentFieldMixin, forms.ModelForm):
required=False,
widget=forms.PasswordInput(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
"placeholder": _("Leave blank to generate random password"),
"placeholder": _("Leave blank to send a password setup link via email"),
}),
)
password_confirm = forms.CharField(
@ -111,6 +111,8 @@ class UserCreateForm(DepartmentFieldMixin, forms.ModelForm):
if self.user and self.user.is_hospital_admin() and self.user.hospital:
self.fields["hospital"].initial = self.user.hospital
self.fields["hospital"].queryset = Hospital.objects.filter(id=self.user.hospital.id)
elif self.user and self.user.is_px_admin() and getattr(self.request, "tenant_hospital", None):
self.fields["hospital"].initial = self.request.tenant_hospital
def clean_email(self):
email = self.cleaned_data.get("email")
@ -127,18 +129,14 @@ class UserCreateForm(DepartmentFieldMixin, forms.ModelForm):
return cleaned_data
def save(self, commit=True):
import secrets
import string
user = super().save(commit=False)
password = self.cleaned_data.get("password")
if not password:
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
password = ''.join(secrets.choice(alphabet) for _ in range(16))
self._generated_password = password
if password:
user.set_password(password)
else:
user.set_unusable_password()
user.set_password(password)
if commit:
user.save()
selected_groups = self.cleaned_data.get("groups")

View File

@ -81,14 +81,16 @@ class ComplaintService:
return True
if user.is_hospital_admin() and user.hospital == complaint.hospital:
return True
if user.is_px_management() and user.hospital == complaint.hospital:
return True
if user.is_px_employee() and user.hospital == complaint.hospital:
return True
if user.is_department_manager() and user.department == complaint.department:
return True
if complaint.assigned_to and complaint.assigned_to == user:
return True
# Before activation: PX staff can claim/activate the complaint
if not complaint.activated_at:
if user.is_px_management() and user.hospital == complaint.hospital:
return True
if user.is_px_employee() and user.hospital == complaint.hospital:
return True
if user.is_department_manager() and user.department == complaint.department:
return True
if user.department_id and complaint.involved_departments.filter(department_id=user.department_id).exists():
return True
return False

View File

@ -2948,10 +2948,17 @@ def send_inquiry_sla_reminders():
if hours_since_creation >= first_reminder_hours:
if inquiry.assigned_to and inquiry.assigned_to.email:
try:
ctx = {
"inquiry": inquiry,
"recipient_name": inquiry.assigned_to.get_full_name(),
"hours_remaining": max(0, round(hours_until_due)),
"inquiry_url": f"{settings.SITE_URL.rstrip('/')}/inquiries/{inquiry.pk}/",
}
NotificationService.send_email(
email=inquiry.assigned_to.email,
subject=f"SLA Reminder - Inquiry #{inquiry.id}",
message=f"Inquiry '{inquiry.subject}' is due at {inquiry.due_at}. Please take action.",
html_message=render_to_string("emails/inquiry_sla_reminder.html", ctx),
related_object=inquiry,
)
inquiry.reminder_sent_at = now
@ -2965,10 +2972,17 @@ def send_inquiry_sla_reminders():
if hours_since_creation >= second_reminder_hours:
if inquiry.assigned_to and inquiry.assigned_to.email:
try:
ctx = {
"inquiry": inquiry,
"recipient_name": inquiry.assigned_to.get_full_name(),
"hours_remaining": max(0, round(hours_until_due)),
"inquiry_url": f"{settings.SITE_URL.rstrip('/')}/inquiries/{inquiry.pk}/",
}
NotificationService.send_email(
email=inquiry.assigned_to.email,
subject=f"URGENT: SLA Reminder - Inquiry #{inquiry.id}",
message=f"Inquiry '{inquiry.subject}' is due at {inquiry.due_at}. URGENT action required.",
html_message=render_to_string("emails/inquiry_sla_second_reminder.html", ctx),
related_object=inquiry,
)
inquiry.second_reminder_sent_at = now
@ -3233,6 +3247,14 @@ def send_inquiry_dept_response_reminders():
):
for recipient in recipients:
try:
ctx = {
"inquiry": inquiry,
"department_name": dept.name,
"sla_due_at": inquiry.dept_response_sla_due_at,
"hours_remaining": max(0, round(hours_remaining)),
"inquiry_url": f"{settings.SITE_URL.rstrip('/')}/inquiries/{inquiry.pk}/",
"recipient_name": recipient.get_full_name(),
}
NotificationService.send_email(
email=recipient.email,
subject=f"Reminder: Inquiry #{inquiry.reference_number} - Response Required",
@ -3242,6 +3264,7 @@ def send_inquiry_dept_response_reminders():
f"Time remaining: {max(0, round(hours_remaining))} hours. "
f"Please submit your response before the deadline."
),
html_message=render_to_string("emails/inquiry_dept_response_reminder.html", ctx),
related_object=inquiry,
)
except Exception as e:
@ -3260,6 +3283,14 @@ def send_inquiry_dept_response_reminders():
):
for recipient in recipients:
try:
ctx = {
"inquiry": inquiry,
"department_name": dept.name,
"sla_due_at": inquiry.dept_response_sla_due_at,
"hours_remaining": max(0, round(hours_remaining)),
"inquiry_url": f"{settings.SITE_URL.rstrip('/')}/inquiries/{inquiry.pk}/",
"recipient_name": recipient.get_full_name(),
}
NotificationService.send_email(
email=recipient.email,
subject=f"URGENT: Inquiry #{inquiry.reference_number} - Response Overdue Soon",
@ -3269,6 +3300,7 @@ def send_inquiry_dept_response_reminders():
f"Time remaining: {max(0, round(hours_remaining))} hours. "
f"Please submit your response immediately."
),
html_message=render_to_string("emails/inquiry_dept_response_reminder.html", ctx),
related_object=inquiry,
)
except Exception as e:
@ -3534,7 +3566,7 @@ def notify_champion_on_dept_assignment_task(involved_department_id):
department_url = f"{base}/organizations/departments/{department.pk}/"
NotificationService.send_email(
recipient=champion_user.email,
email=champion_user.email,
subject=f"New Complaint Assigned - {complaint.reference_number}",
message=f"""A new complaint has been assigned to your department ({department.name}).

View File

@ -612,6 +612,28 @@ def complaint_detail(request, pk):
ComplaintService.ensure_involved_records(complaint)
# Self-heal: if the complaint was sent to its primary department but no CID
# row exists (legacy data from before the fix), create one now so the primary
# department appears in the Involved Departments list.
if complaint.sent_to_department and complaint.department_id:
if not any(inv.department_id == complaint.department_id
for inv in complaint.involved_departments.all()):
from .models import ComplaintInvolvedDepartment
ComplaintInvolvedDepartment.objects.get_or_create(
complaint=complaint,
department=complaint.department,
defaults={
"role": "primary",
"is_primary": True,
"added_by": request.user,
"forwarded_at": complaint.forwarded_to_dept_at or timezone.now(),
"sent": True,
"sent_at": complaint.sent_to_department_at or timezone.now(),
},
)
if hasattr(complaint, "_prefetched_objects_cache"):
complaint._prefetched_objects_cache.pop("involved_departments", None)
user = request.user
if not user.is_px_admin():
if user.is_hospital_admin() and complaint.hospital != user.hospital:
@ -747,9 +769,12 @@ def complaint_detail(request, pk):
"can_manage_actions": (
user.is_px_admin()
or (user.is_hospital_admin() and user.hospital == complaint.hospital)
or (user.is_px_management() and user.hospital == complaint.hospital)
or (user.is_px_employee() and user.hospital == complaint.hospital)
or (complaint.assigned_to == user)
or (
not complaint.activated_at
and (user.is_px_management() or user.is_px_employee())
and user.hospital == complaint.hospital
)
),
"can_admin": user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital),
"is_active_status": complaint.is_active_status,
@ -1195,6 +1220,35 @@ def complaint_send_to(request, pk):
now = timezone.now()
if complaint.department_id == department.pk:
# Create/update CID for the primary department too so it appears
# in the Involved Departments list and the department-detail queries.
involved_dept, created = ComplaintInvolvedDepartment.objects.get_or_create(
complaint=complaint,
department=department,
defaults={
"role": "primary",
"is_primary": True,
"added_by": user,
"forwarded_at": now,
"sent": True,
"sent_at": now,
},
)
if not created:
involved_dept.forwarded_at = now
involved_dept.sent = True
involved_dept.sent_at = now
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 = now
if not complaint.forwarded_to_dept_at:
@ -1371,6 +1425,123 @@ def complaint_send_to(request, pk):
}, status=500)
@login_required
@require_http_methods(["POST"])
def complaint_send_dept_reminder(request, pk):
"""Send a manual reminder to a department that hasn't responded yet.
Visible in Quick Actions after the complaint has been sent to a department.
The user picks which department from a modal; this endpoint sends an email +
SMS to that department's champion/manager and logs a ComplaintUpdate.
"""
from .models import ComplaintInvolvedDepartment, ComplaintUpdate
from apps.notifications.services import NotificationService, get_email_header_html
from apps.organizations.department_contacts import get_champion_and_manager
from apps.core.utils import build_public_track_url
complaint = get_object_or_404(Complaint, pk=pk)
user = request.user
if not can_manage_complaint(user, complaint):
messages.error(request, _("You don't have permission to send reminders."))
return redirect("complaints:complaint_detail", pk=pk)
inv_dept_id = request.POST.get("involved_department_id", "").strip()
note = request.POST.get("note", "").strip()
if not inv_dept_id:
messages.error(request, _("Please select a department."))
return redirect("complaints:complaint_detail", pk=pk)
involved_dept = get_object_or_404(
ComplaintInvolvedDepartment,
pk=inv_dept_id,
complaint=complaint,
sent=True,
response_submitted=False,
)
department = involved_dept.department
targets = get_champion_and_manager(department)
if not targets:
messages.error(
request,
_(f"Cannot send reminder to {department.get_localized_name()}. This department has no champion or manager assigned."),
)
return redirect("complaints:complaint_detail", pk=pk)
track_url = build_public_track_url("complaint", complaint.reference_number)
notified = []
sent_any = False
for target in targets:
email = target.get("email") or ""
phone = target.get("phone") or ""
label = target.get("label") or ""
display_name = target.get("display_name") or label
if email:
try:
NotificationService.send_email(
email=email,
subject=f"Reminder: Complaint #{complaint.reference_number} - Response Required",
message=(
f"This is a reminder that complaint #{complaint.reference_number} "
f"is awaiting your department's response.\n\n"
f"Track: {track_url}"
),
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Reminder: Response Required</h2>
<p style="margin: 0 0 12px 0;">This is a reminder that complaint <strong>#{complaint.reference_number}</strong> is still awaiting your department's response.</p>
<p style="margin: 0 0 12px 0;"><strong>Department:</strong> {department.name}</p>
<div style="text-align: center; margin: 20px 0;">
<a href="{track_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">Track Complaint</a>
</div>
</div>
</div>
""",
related_object=complaint,
)
sent_any = True
except Exception:
pass
if phone:
try:
NotificationService.send_sms(
phone,
f"PX360: Reminder - Complaint #{complaint.reference_number} sent to {department.name} is awaiting your response. Track: {track_url}",
related_object=complaint,
)
sent_any = True
except Exception:
pass
notified.append(f"{display_name} ({label})")
if sent_any:
ComplaintUpdate.objects.create(
complaint=complaint,
update_type="communication",
message=f"Manual reminder sent to {department.get_localized_name()}{', '.join(notified)}",
created_by=user,
metadata={
"event_type": "manual_reminder",
"department_id": str(department.id),
"involved_department_id": str(involved_dept.id),
"recipients": notified,
"note": note,
},
)
messages.success(request, _(f"Reminder sent to {department.get_localized_name()}."))
else:
messages.error(request, _(f"Could not send reminder to {department.get_localized_name()}. No valid contact info."))
return redirect("complaints:complaint_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def complaint_change_status(request, pk):

View File

@ -180,6 +180,7 @@ urlpatterns = [
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"),
path("<uuid:pk>/send-dept-reminder/", ui_views.complaint_send_dept_reminder, name="complaint_send_dept_reminder"),
# Collect Feedback (champion/manager compose questions for staff)
path("<uuid:pk>/collect-feedback/", ui_views.collect_feedback_start, name="collect_feedback_start"),
# Involved Staff Management

View File

@ -3665,6 +3665,10 @@ def complaint_explanation_form(request, complaint_id, token):
)
investigation.final_reply = explanation_text
investigation.status = InvestigationStatus.DIRECT_REPLY_IN_PROGRESS
investigation.negligence_finding = negligence_finding
investigation.policy_issue_finding = policy_issue_finding
investigation.requires_improvement_project = requires_improvement_project
investigation.improvement_project_note = improvement_project_note
import random
@ -3673,6 +3677,8 @@ def complaint_explanation_form(request, complaint_id, token):
investigation.otp_sent_at = timezone.now()
investigation.save(update_fields=[
"final_reply", "status", "otp_code", "otp_sent_at",
"negligence_finding", "policy_issue_finding",
"requires_improvement_project", "improvement_project_note",
])
sent_channels = []
@ -3977,6 +3983,7 @@ def complaint_explanation_form(request, complaint_id, token):
# Auto-show OTP entry if a direct-reply OTP is still valid (e.g., user reloaded page)
otp_sent_on_get = False
draft_ctx = {}
if not explanation.is_used:
from apps.complaints.models import ChampionInvestigation, InvestigationStatus
from datetime import timedelta
@ -3987,6 +3994,14 @@ def complaint_explanation_form(request, complaint_id, token):
).exclude(otp_code="").first()
if existing and existing.otp_sent_at and timezone.now() <= existing.otp_sent_at + timedelta(minutes=10):
otp_sent_on_get = True
draft_ctx = {
"final_reply": existing.final_reply or "",
"negligence_finding": existing.negligence_finding or "",
"policy_issue_finding": existing.policy_issue_finding or "",
"requires_improvement_project": existing.requires_improvement_project or "",
"improvement_project_note": existing.improvement_project_note or "",
"consent_checked": True,
}
return render(
request,
@ -4005,6 +4020,7 @@ def complaint_explanation_form(request, complaint_id, token):
"token": explanation.token,
})
),
**draft_ctx,
},
)

View File

@ -212,9 +212,10 @@ def user_create(request):
form = UserCreateForm(request.POST, request=request)
if form.is_valid():
user = form.save()
generated_password = getattr(form, '_generated_password', None)
staff_id = request.POST.get("staff_id")
create_staff = request.POST.get("create_staff") == "on"
if staff_id:
from apps.organizations.models import Staff
try:
@ -226,29 +227,46 @@ def user_create(request):
))
except Staff.DoesNotExist:
messages.success(request, _("User '{}' created successfully.").format(user.get_full_name()))
elif create_staff:
from apps.organizations.services import StaffService
staff_type = request.POST.get("staff_type", "other")
job_title = request.POST.get("staff_job_title", "")
try:
staff = StaffService.create_staff_for_user(
user, staff_type=staff_type, job_title=job_title, request=request
)[0]
messages.success(request, _("User '{}' created with staff record '{}'.").format(
user.get_full_name(), staff.get_full_name()
))
except ValueError as e:
messages.warning(request, _("User '{}' created but staff record creation failed: {}").format(
user.get_full_name(), str(e)
))
else:
messages.success(request, _("User '{}' created successfully.").format(user.get_full_name()))
password_was_set = bool(form.cleaned_data.get("password"))
try:
base_url = f"{request.scheme}://{request.get_host()}"
reset_token = PasswordResetTokenService.create_reset_token(user)
reset_url = PasswordResetTokenService.build_reset_url(base_url, reset_token)
subject = _("Your PX360 Account Has Been Created")
if generated_password:
if password_was_set:
login_url = f"{base_url}/accounts/login/"
html_message = render_to_string(
"config/emails/user_created_email.html",
{"user": user, "reset_url": reset_url, "temp_password": generated_password},
{"user": user, "login_url": login_url},
request=request,
)
plain_message = (
f"Dear {user.get_full_name()},\n\n"
f"An account has been created for you on PX360.\n\n"
f"Your temporary password: {generated_password}\n\n"
f"For security, please set your own password using the link below:\n{reset_url}\n\n"
f"This link expires in 24 hours."
f"You can log in at: {login_url}\n\n"
f"Please contact your administrator for your login credentials."
)
subject = _("Your PX360 Account Has Been Created")
else:
reset_token = PasswordResetTokenService.create_reset_token(user)
reset_url = PasswordResetTokenService.build_reset_url(base_url, reset_token)
html_message = render_to_string(
"config/emails/reset_password_email.html",
{"user": user, "reset_url": reset_url},
@ -260,7 +278,6 @@ def user_create(request):
f"Use the link below to set your password:\n{reset_url}\n\n"
f"This link expires in 24 hours."
)
subject = _("Your PX360 Account Has Been Created")
NotificationService.send_email(
email=user.email,
@ -270,8 +287,8 @@ def user_create(request):
user=user,
notification_type="system",
)
if generated_password:
messages.info(request, _("Credentials sent to {}.").format(user.email))
if password_was_set:
messages.info(request, _("Welcome email sent to {}.").format(user.email))
else:
messages.info(request, _("Password setup link sent to {}.").format(user.email))
except Exception as e:

View File

@ -363,7 +363,6 @@ class ExternalAppreciationRetrieveSerializer(serializers.ModelSerializer):
"""Read-only serializer for retrieving an appreciation."""
hospital_name = serializers.CharField(source="hospital.name", read_only=True)
reference_number = serializers.SerializerMethodField()
class Meta:
model = Appreciation
@ -379,11 +378,6 @@ class ExternalAppreciationRetrieveSerializer(serializers.ModelSerializer):
]
read_only_fields = fields
def get_reference_number(self, obj):
if obj.metadata and "reference_number" in obj.metadata:
return obj.metadata["reference_number"]
return None
# ---------------------------------------------------------------------------
# Suggestion (Feedback)
@ -433,7 +427,6 @@ class ExternalSuggestionRetrieveSerializer(serializers.ModelSerializer):
"""Read-only serializer for retrieving a suggestion."""
hospital_name = serializers.CharField(source="hospital.name", read_only=True)
reference_number = serializers.SerializerMethodField()
class Meta:
model = Feedback
@ -455,11 +448,6 @@ class ExternalSuggestionRetrieveSerializer(serializers.ModelSerializer):
]
read_only_fields = fields
def get_reference_number(self, obj):
if obj.metadata and "reference_number" in obj.metadata:
return obj.metadata["reference_number"]
return None
class ExternalComplaintSatisfactionSerializer(serializers.Serializer):
satisfaction = serializers.ChoiceField(

View File

@ -155,7 +155,7 @@ class ExternalComplaintCreateView(ExternalAPIBase, APIView):
status=status.HTTP_403_FORBIDDEN,
)
# Reference number generated by Complaint.save() (unified CMP-YYYYMM-HOSP-NNNN)
# Reference number generated by Complaint.save() (unified CMP-YYYYMM-NNNN)
complaint = Complaint.objects.create(
patient=None,
@ -187,6 +187,8 @@ class ExternalComplaintCreateView(ExternalAPIBase, APIView):
message="Complaint submitted via external API.",
)
reference_number = complaint.reference_number
# Trigger background tasks
try:
from apps.complaints.tasks import analyze_complaint_with_ai, notify_staff_new_item
@ -596,13 +598,6 @@ class ExternalAppreciationCreateView(ExternalAPIBase, APIView):
status=status.HTTP_403_FORBIDDEN,
)
import uuid
from datetime import datetime
today = datetime.now().strftime("%Y%m%d")
random_suffix = str(uuid.uuid4().int)[:6]
reference_number = f"APR-{today}-{random_suffix}"
appreciation = Appreciation.objects.create(
hospital=data["hospital"],
message_en=data["message"],
@ -613,7 +608,6 @@ class ExternalAppreciationCreateView(ExternalAPIBase, APIView):
visibility=AppreciationVisibility.PUBLIC,
metadata={
"source": "external_api",
"reference_number": reference_number,
"submitted_by_name": data["contact_name"],
"submitted_by_phone": data["contact_phone"],
},
@ -636,7 +630,7 @@ class ExternalAppreciationCreateView(ExternalAPIBase, APIView):
return Response(
{
"success": True,
"reference_number": reference_number,
"reference_number": appreciation.reference_number,
"status": appreciation.status,
},
status=status.HTTP_201_CREATED,
@ -646,22 +640,22 @@ class ExternalAppreciationCreateView(ExternalAPIBase, APIView):
class ExternalAppreciationRetrieveView(ExternalAPIBase, APIView):
"""
GET /api/v1/external/appreciations/
GET /api/v1/external/appreciations/<id>/
GET /api/v1/external/appreciations/<reference_number>/
"""
entity_name = "appreciations"
def get(self, request, pk=None):
def get(self, request, reference_number=None):
access_err = self.check_entity_access()
if access_err:
return access_err
if pk:
return self._retrieve(request, pk)
if reference_number:
return self._retrieve(request, reference_number)
return self._list(request)
def _retrieve(self, request, pk):
qs = Appreciation.all_objects.filter(pk=pk)
def _retrieve(self, request, reference_number):
qs = Appreciation.all_objects.filter(reference_number=reference_number)
qs = self.filter_queryset_by_hospital(qs)
appreciation = qs.first()
if not appreciation:
@ -734,13 +728,6 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
"other": FeedbackCategory.OTHER,
}
import uuid as _uuid
from datetime import datetime
today = datetime.now().strftime("%Y%m%d")
random_suffix = str(_uuid.uuid4().int)[:6]
reference_number = f"SG-{today}-{random_suffix}"
title = data.get("title") or data["message"][:100]
feedback = Feedback.objects.create(
@ -756,7 +743,6 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
status=FeedbackStatus.SUBMITTED,
metadata={
"source": "external_api",
"reference_number": reference_number,
"suggestion_area": data.get("category", "general"),
},
)
@ -780,7 +766,7 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
return Response(
{
"success": True,
"reference_number": reference_number,
"reference_number": feedback.reference_number,
"status": feedback.status,
},
status=status.HTTP_201_CREATED,
@ -790,22 +776,22 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
class ExternalSuggestionRetrieveView(ExternalAPIBase, APIView):
"""
GET /api/v1/external/suggestions/
GET /api/v1/external/suggestions/<id>/
GET /api/v1/external/suggestions/<reference_number>/
"""
entity_name = "suggestions"
def get(self, request, pk=None):
def get(self, request, reference_number=None):
access_err = self.check_entity_access()
if access_err:
return access_err
if pk:
return self._retrieve(request, pk)
if reference_number:
return self._retrieve(request, reference_number)
return self._list(request)
def _retrieve(self, request, pk):
qs = Feedback.all_objects.filter(pk=pk)
def _retrieve(self, request, reference_number):
qs = Feedback.all_objects.filter(reference_number=reference_number)
qs = self.filter_queryset_by_hospital(qs)
feedback = qs.first()
if not feedback:

View File

@ -119,7 +119,7 @@ urlpatterns = [
name="external-appreciation-list",
),
path(
"appreciations/<uuid:pk>/",
"appreciations/<str:reference_number>/",
ExternalAppreciationRetrieveView.as_view(),
name="external-appreciation-detail",
),
@ -135,7 +135,7 @@ urlpatterns = [
name="external-suggestion-list",
),
path(
"suggestions/<uuid:pk>/",
"suggestions/<str:reference_number>/",
ExternalSuggestionRetrieveView.as_view(),
name="external-suggestion-detail",
),

View File

@ -312,6 +312,18 @@ class NotificationService:
Returns:
NotificationLog instance
"""
# Auto-wrap plain-text messages in the branded base email template so
# every notification has the hospital logo, card, and footer. Callers
# that already supply html_message are left untouched.
if html_message is None and message:
from django.template.loader import render_to_string as _render_to_string
content_html = "<p>" + message.replace("\n\n", "</p><p>").replace("\n", "<br>\n") + "</p>"
html_message = _render_to_string(
"emails/simple_email.html",
{"subject": subject, "content_html": content_html},
)
from apps.accounts.models import User
# Check if Email API is enabled and use it (simulator or external API)

View File

@ -0,0 +1,129 @@
"""
Tests for the email reminder template consistency fixes:
- Part D: NotificationService.send_email auto-wraps plain-text in branded template
- Part A: dept-champion notification uses email= (not recipient=) kwarg
- Part B/C: branded reminder templates render with the expected context
"""
from unittest.mock import patch
from django.template.loader import render_to_string
from django.test import TestCase
from apps.notifications.services import NotificationService
class AutoWrapBrandedTemplateTests(TestCase):
"""Part D — plain-text messages are auto-wrapped in the branded template."""
@patch("apps.notifications.services.send_mail")
def test_plain_text_gets_branded_html(self, mock_send_mail):
NotificationService.send_email(
email="patient@example.com",
subject="Test Subject",
message="Line one.\n\nLine two.\nLine three.",
)
# send_mail was called with an html_message
_, kwargs = mock_send_mail.call_args
self.assertIsNotNone(kwargs.get("html_message"))
html = kwargs["html_message"]
# The branded template contributes these structural markers
self.assertIn("email-container", html) # the 600px card from base_email_template
self.assertIn("<!DOCTYPE", html) # full HTML document shell
# Plain-text content was converted to HTML paragraphs
self.assertIn("Line one.", html)
self.assertIn("<br>", html) # single newline -> <br>
self.assertIn("<p>", html) # double newline -> new paragraph
@patch("apps.notifications.services.send_mail")
def test_explicit_html_message_is_not_overwritten(self, mock_send_mail):
"""Callers that already provide html_message keep it as-is (no double-wrapping)."""
custom_html = "<div>My custom layout</div>"
NotificationService.send_email(
email="patient@example.com",
subject="Test",
message="Plain text fallback",
html_message=custom_html,
)
_, kwargs = mock_send_mail.call_args
self.assertEqual(kwargs["html_message"], custom_html)
class DeptChampionNotificationKwargTests(TestCase):
"""Part A — the recipient= bug is fixed (send_email uses email= kwarg).
We verify indirectly: send_email no longer receives an unexpected 'recipient'
kwarg, which used to cause a TypeError swallowed by try/except.
"""
@patch("apps.notifications.services.send_mail")
def test_send_email_signature_rejects_recipient_kwarg(self, mock_mail):
"""The send_email method must not accept 'recipient' as a kwarg (the bug).
Python validates kwargs at call time, so TypeError fires before the
body runs we must NOT mock send_email itself.
"""
with self.assertRaises(TypeError):
NotificationService.send_email(
recipient="someone@example.com", # wrong kwarg — should TypeError
subject="Test",
message="Test",
)
class BrandedReminderTemplateTests(TestCase):
"""Parts B & C — the reminder templates extend the base and render branded HTML."""
def _assert_branded(self, html):
"""Structural markers contributed by base_email_template.html."""
self.assertIn("<!DOCTYPE", html)
self.assertIn("email-container", html)
def test_inquiry_dept_response_reminder_renders(self):
html = render_to_string("emails/inquiry_dept_response_reminder.html", {
"inquiry": type("Obj", (), {"reference_number": "INQ-001", "subject": "Test"})(),
"department_name": "Lab",
"hours_remaining": 12,
"sla_due_at": "2026-07-15T10:00:00",
"inquiry_url": "https://example.com/inquiries/1/",
})
self._assert_branded(html)
self.assertIn("INQ-001", html)
self.assertIn("Lab", html)
def test_observation_dept_response_reminder_renders(self):
html = render_to_string("emails/observation_dept_response_reminder.html", {
"observation": type("Obj", (), {"tracking_code": "OBS-001", "title": "Spill"})(),
"department_name": "Lab",
"hours_remaining": 8,
"sla_due_at": "2026-07-15T10:00:00",
"observation_url": "https://example.com/observations/1/",
})
self._assert_branded(html)
self.assertIn("OBS-001", html)
def test_inquiry_sla_reminder_renders(self):
html = render_to_string("emails/inquiry_sla_reminder.html", {
"inquiry": type("Obj", (), {
"reference_number": "INQ-002",
"subject": "Billing",
"due_at": "2026-07-15T10:00:00",
})(),
"hours_remaining": 24,
"inquiry_url": "https://example.com/inquiries/2/",
})
self._assert_branded(html)
self.assertIn("INQ-002", html)
def test_inquiry_sla_second_reminder_renders_urgent(self):
html = render_to_string("emails/inquiry_sla_second_reminder.html", {
"inquiry": type("Obj", (), {
"reference_number": "INQ-003",
"subject": "Billing",
"due_at": "2026-07-15T10:00:00",
})(),
"hours_remaining": 2,
"inquiry_url": "https://example.com/inquiries/3/",
})
self._assert_branded(html)
self.assertIn("URGENT", html)
self.assertIn("#dc2626", html) # red accent for urgency

View File

@ -11,6 +11,8 @@ import datetime
import logging
from celery import shared_task
from django.conf import settings
from django.template.loader import render_to_string
from django.utils import timezone
logger = logging.getLogger(__name__)
@ -554,6 +556,14 @@ def send_observation_dept_response_reminders():
):
for recipient in recipients:
try:
ctx = {
"observation": observation,
"department_name": dept.name,
"sla_due_at": observation.dept_response_sla_due_at,
"hours_remaining": max(0, round(hours_remaining)),
"observation_url": f"{settings.SITE_URL.rstrip('/')}/observations/{observation.pk}/",
"recipient_name": recipient.get_full_name(),
}
NotificationService.send_email(
email=recipient.email,
subject=f"Reminder: Observation {observation.tracking_code} - Response Required",
@ -563,6 +573,7 @@ def send_observation_dept_response_reminders():
f"Time remaining: {max(0, round(hours_remaining))} hours. "
f"Please submit your response before the deadline."
),
html_message=render_to_string("emails/observation_dept_response_reminder.html", ctx),
related_object=observation,
)
except Exception as e:
@ -581,6 +592,14 @@ def send_observation_dept_response_reminders():
):
for recipient in recipients:
try:
ctx = {
"observation": observation,
"department_name": dept.name,
"sla_due_at": observation.dept_response_sla_due_at,
"hours_remaining": max(0, round(hours_remaining)),
"observation_url": f"{settings.SITE_URL.rstrip('/')}/observations/{observation.pk}/",
"recipient_name": recipient.get_full_name(),
}
NotificationService.send_email(
email=recipient.email,
subject=f"URGENT: Observation {observation.tracking_code} - Response Overdue Soon",
@ -590,6 +609,7 @@ def send_observation_dept_response_reminders():
f"Time remaining: {max(0, round(hours_remaining))} hours. "
f"Please submit your response immediately."
),
html_message=render_to_string("emails/observation_dept_response_reminder.html", ctx),
related_object=observation,
)
except Exception as e:

View File

@ -161,6 +161,70 @@ class StaffService:
return user, True, None # New user was created with no emailed password
@staticmethod
def create_staff_for_user(user, staff_type='other', job_title='', request=None):
"""
Create a Staff record from an existing User.
Args:
user: User instance (must have first_name, last_name, hospital, employee_id)
staff_type: Staff type (physician|nurse|admin|other)
job_title: Job title string
request: HTTP request for audit logging
Returns:
tuple: (Staff instance, was_created: bool)
Raises:
ValueError: If user already has a staff profile, lacks hospital,
or employee_id is already taken by another staff member.
"""
from apps.organizations.models import Staff
if hasattr(user, 'staff_profile') and user.staff_profile:
raise ValueError("User already has a staff record")
if not user.hospital:
raise ValueError("User must have a hospital assigned before creating a staff record")
if not user.employee_id:
raise ValueError("Employee ID is required to create a staff record")
if Staff.objects.filter(employee_id=user.employee_id).exists():
raise ValueError(
f"Employee ID '{user.employee_id}' is already in use by another staff member"
)
staff = Staff.objects.create(
user=user,
first_name=user.first_name,
last_name=user.last_name,
email=user.email,
phone=user.phone or '',
employee_id=user.employee_id,
hospital=user.hospital,
department=user.department,
staff_type=staff_type or 'other',
job_title=job_title or '',
status='active',
)
if request:
AuditService.log_from_request(
event_type='other',
description=f"Staff record created for user {user.email}",
request=request,
content_object=staff,
metadata={
'user_id': str(user.id),
'staff_id': str(staff.id),
'staff_name': staff.get_full_name(),
'action': 'created_staff_from_user',
}
)
return staff, True
@staticmethod
def link_user_to_staff(staff, user_id, request=None):
"""

View File

@ -1814,7 +1814,7 @@ def department_list(request):
queryset = queryset.filter(id__in=directed_depts)
else:
queryset = queryset.none()
elif user.is_champion() and user.department:
elif user.is_champion() and user.department and not user.is_px_employee() and not user.is_px_management():
queryset = queryset.filter(id=user.department.id)
elif user.is_basic_staff() and user.department:
queryset = queryset.filter(id=user.department.id)
@ -2055,12 +2055,14 @@ def department_detail(request, pk):
).first()
if not explanation and department.champion:
import secrets as _secrets
explanation = ComplaintExplanation.objects.create(
explanation, _created = ComplaintExplanation.objects.get_or_create(
complaint=pc.complaint,
staff=department.champion,
token=_secrets.token_urlsafe(32),
explanation="",
is_used=False,
defaults={
"token": _secrets.token_urlsafe(32),
"explanation": "",
"is_used": False,
},
)
if explanation:
if explanation.is_used and needs_resubmit:
@ -2117,11 +2119,17 @@ def department_detail(request, pk):
"department_name": mr.department.name,
})
# 2. Complaint Explanations (staff-level)
# 2. Complaint Explanations (staff-level) — only for THIS department's staff
# and only for complaints not already covered by a CID-based entry above.
already_pending_complaint_ids = set(
str(a.get("complaint_id")) for a in pending_actions if a.get("complaint_id")
)
pending_explanations = ComplaintExplanation.objects.filter(
complaint__department=department,
staff__department=department,
is_used=False,
).select_related("complaint", "staff").order_by("sla_due_at")
).exclude(complaint_id__in=already_pending_complaint_ids).select_related(
"complaint", "staff"
).order_by("sla_due_at")
for pe in pending_explanations:
pending_actions.append({
"type": "complaint_explanation",

View File

@ -581,7 +581,7 @@
"name": "Created",
"status": "Created",
"code": 201,
"body": "{\n \"success\": true,\n \"reference_number\": \"APR-20260609-239270\",\n \"status\": \"draft\"\n}"
"body": "{\n \"success\": true,\n \"reference_number\": \"APR-202607-0001\",\n \"status\": \"draft\"\n}"
}
]
},
@ -626,9 +626,9 @@
}
],
"url": {
"raw": "{{base_url}}/api/v1/external/appreciations/<uuid>/",
"host": ["{{base_url}}"],
"path": ["api", "v1", "external", "appreciations", "<uuid>", ""]
"raw": "{{base_url}}/api/v1/external/appreciations/<reference_number>/",
"host": ["{{base_url}}"],
"path": ["api", "v1", "external", "appreciations", "<reference_number>", ""]
}
},
"response": []
@ -669,7 +669,7 @@
"name": "Created",
"status": "Created",
"code": 201,
"body": "{\n \"success\": true,\n \"reference_number\": \"SG-20260609-316316\",\n \"status\": \"submitted\"\n}"
"body": "{\n \"success\": true,\n \"reference_number\": \"SGT-202607-0001\",\n \"status\": \"submitted\"\n}"
}
]
},
@ -714,9 +714,9 @@
}
],
"url": {
"raw": "{{base_url}}/api/v1/external/suggestions/<uuid>/",
"host": ["{{base_url}}"],
"path": ["api", "v1", "external", "suggestions", "<uuid>", ""]
"raw": "{{base_url}}/api/v1/external/suggestions/<reference_number>/",
"host": ["{{base_url}}"],
"path": ["api", "v1", "external", "suggestions", "<reference_number>", ""]
}
},
"response": []

View File

@ -452,7 +452,7 @@ POST /api/v1/external/appreciations/
```json
{
"success": true,
"reference_number": "APR-20260609-239270",
"reference_number": "APR-202607-0001",
"status": "draft"
}
```
@ -461,7 +461,7 @@ POST /api/v1/external/appreciations/
```
GET /api/v1/external/appreciations/list/
GET /api/v1/external/appreciations/<uuid>/
GET /api/v1/external/appreciations/<reference_number>/
```
**Retrieve Response:**
@ -469,7 +469,7 @@ GET /api/v1/external/appreciations/<uuid>/
```json
{
"id": "uuid",
"reference_number": "APR-20260609-239270",
"reference_number": "APR-202607-0001",
"message_en": "Dr. Ahmed was incredibly kind and thorough.",
"status": "draft",
"hospital_name": "Al Nuzha",
@ -506,7 +506,7 @@ POST /api/v1/external/suggestions/
```json
{
"success": true,
"reference_number": "SG-20260609-316316",
"reference_number": "SGT-202607-0001",
"status": "submitted"
}
```
@ -515,7 +515,7 @@ POST /api/v1/external/suggestions/
```
GET /api/v1/external/suggestions/list/
GET /api/v1/external/suggestions/<uuid>/
GET /api/v1/external/suggestions/<reference_number>/
```
**Retrieve Response:**
@ -523,7 +523,7 @@ GET /api/v1/external/suggestions/<uuid>/
```json
{
"id": "uuid",
"reference_number": "SG-20260609-316316",
"reference_number": "SGT-202607-0001",
"title": "Digital queue system",
"message": "Please implement a digital queue management system.",
"category": "technology",
@ -669,5 +669,5 @@ All list endpoints return paginated results:
| Complaint | `CMP-` | `CMP-YYYYMMDD-XXXXXX` |
| Inquiry | `INQ-` | `INQ-YYYYMMDD-XXXXXX` |
| Observation | `OBS-` | `OBS-XXXXXX` (auto-generated by model) |
| Appreciation | `APR-` | `APR-YYYYMMDD-XXXXXX` |
| Suggestion | `SG-` | `SG-YYYYMMDD-XXXXXX` |
| Appreciation | `APR-` | `APR-YYYYMM-NNNN` |
| Suggestion | `SGT-` | `SGT-YYYYMM-NNNN` |

View File

@ -39,7 +39,7 @@ So: **a recognition engine whose originating actors can be (a) PX/hospital staff
**(b) Public portal** (`ui_views.py:1196`): Inputs `contact_name`, `contact_phone`, `message`, `hospital`, optional `staff_name`/`department`/`section`. Required: name, phone, message, hospital (`ui_views.py:1224`). Creates with `status=DRAFT`, `visibility=PUBLIC`, `is_anonymous=False`, `category=None`, metadata `{source:"public_form", ...}` (`ui_views.py:1239`). Fires `notify_staff_new_item.delay("appreciation", ...)` (`ui_views.py:1258`). Returns `reference_number`.
**(c) External API** (`integrations/api_views.py:606`): Creates DRAFT, visibility PUBLIC, metadata `{source:"external_api", ...}`. Builds its own `APR-YYYYMMDD-<random>` (`api_views.py:602`) — informational, since `save()` also generates the canonical `reference_number`. Fires `notify_staff_new_item`.
**(c) External API** (`integrations/api_views.py:606`): Creates DRAFT, visibility PUBLIC, metadata `{source:"external_api", ...}`. Returns the canonical `reference_number` generated by `save()` (`APR-YYYYMM-NNNN`); the same value is used to retrieve the record via `GET /api/v1/external/appreciations/<reference_number>/`. Fires `notify_staff_new_item`.
**(d) REST API create** (`views.py:143`, serializer `serializers.py:132`):
Required: `recipient_type` (`['user','staff']`), `recipient_id` (UUID), `message_en`, `hospital_id`. Optional: `category_id`, `message_ar`, `visibility` (default PRIVATE), `is_anonymous`, `department_id`. Validation: recipient must belong to hospital; category hospital-compatible. `AppreciationViewSet.create` resolves a `User`/`Staff` via `ContentType`, creates the record (status DRAFT), then **immediately calls `appreciation.send()`** (`views.py:198`).

View File

@ -37,7 +37,7 @@ Post-save (`views.py:992-1024`): `patient=None` at creation; linked **asynchrono
### 2.3 Channel C — External API (machine-to-machine)
- View: `ExternalSuggestionCreateView` (`apps/integrations/api_views.py:700`), `POST /api/v1/external/suggestions/`.
- Auth via API key; hospital must match scope. Generates its own ephemeral `SG-{YYYYMMDD}-{random6}` in `metadata` (`api_views.py:740`) — **not** the canonical `reference_number` column (which `save()` still populates). Same post-save tasks (`api_views.py:765`).
- Auth via API key; hospital must match scope. Returns the canonical `reference_number` generated by `save()` (`SGT-YYYYMM-NNNN`); the same value is used to retrieve the record via `GET /api/v1/external/suggestions/<reference_number>/`. Same post-save tasks (`api_views.py:765`).
### 2.4 Channel D — PX Source-User Portal
- View: `source_user_create_suggestion` (`apps/px_sources/ui_views.py:1387`); `@login_required`; guarded by `source_user.can_create_suggestions`.
@ -302,7 +302,7 @@ Raw-unit model of the IT-export pipeline. Imported in monthly batches via `Comme
## Appendix — Flagged gaps / ambiguities
1. **Reopen unreachable via UI** after closure (Quick Actions hidden when `closed`, `feedback_detail.html:299`). CLOSED→REOPENED allowed by map but no staff UI button.
2. **`reference_number` divergence** — `save()` generates canonical `SGT-YYYYMM-NNNN`; public endpoint returns different `SG-{uuid[:8]}` (`views.py:1026`); external API generates `SG-{YYYYMMDD}-{random6}` in metadata only. Public cannot track by real reference.
2. **`reference_number` divergence (external API resolved)** — `save()` generates canonical `SGT-YYYYMM-NNNN`; the external API now returns and retrieves by this canonical reference. **Public portal still diverges**: the public submit endpoint returns a different `SG-{uuid[:8]}` (`views.py:1026`), so the public cannot track by the real reference via the web portal.
3. **`FeedbackForm` unused for creation** — `feedback_create` builds manually, bypassing form `clean()`.
4. **Transitions unenforced at DB/model/admin** — only `feedback_change_status` enforces.
5. **No outbound communication to suggester** by design.

View File

@ -134,7 +134,7 @@
class="text-slate hover:text-navy px-3 py-2 text-sm font-semibold flex items-center gap-2 border rounded-lg hover:bg-light transition">
<i data-lucide="printer" class="w-4 h-4"></i> {% trans "PDF View" %}
</a> {% endcomment %}
{% if can_edit and complaint.is_active_status and complaint.activated_at and complaint.assigned_to == current_user %}
{% if can_admin or can_edit and complaint.is_active_status and complaint.activated_at and complaint.assigned_to == current_user %}
<button onclick="showResolveModal()" class="bg-navy text-white px-4 py-2 rounded-lg text-sm font-bold shadow-md hover:bg-blue transition">
{% trans "Resolve Case" %}
</button>
@ -390,25 +390,25 @@
<button class="py-4 text-sm tab-active" onclick="switchTab('details')" id="tab-details">
{% trans "Details" %}
</button>
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('departments')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-departments">
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}"
{% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('departments')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-departments">
{% trans "Departments" %} ({{ complaint.involved_departments_count }})
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %}
{% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %}
</button>
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('timeline')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-timeline">
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}"
{% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('timeline')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-timeline">
{% trans "Timeline" %} ({{ stage_timeline.stages|length }})
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %}
{% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %}
</button>
{% comment %} <button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('attachments')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-attachments">
{% comment %} <button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}"
{% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('attachments')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-attachments">
{% trans "Attachments" %} ({{ complaint.attachments_count }})
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %}
{% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %}
</button> {% endcomment %}
{% if can_admin %}
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('actions')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-actions">
<i data-lucide="list-checks" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i>
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('actions')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-actions">
<i data-lucide="list-checks" class="w-4 h-4 {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}text-slate-300{% endif %}"></i>
{% trans "PX Actions" %}
{% if px_actions.count %}
<span class="ml-1 px-1.5 py-0.5 bg-blue-100 text-blue-700 text-xs rounded-full">{{ px_actions.count }}</span>
@ -416,36 +416,36 @@
{% if linked_rcas %}
<span class="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full">{{ linked_rcas.count }}</span>
{% endif %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
{% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button>
{% endif %}
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('ai')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-ai">
<i data-lucide="sparkles" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "AI Analysis" %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('ai')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-ai">
<i data-lucide="sparkles" class="w-4 h-4 {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "AI Analysis" %}
{% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button>
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('resolution')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-resolution">
<i data-lucide="check-circle-2" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i>
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('resolution')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-resolution">
<i data-lucide="check-circle-2" class="w-4 h-4 {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}text-slate-300{% endif %}"></i>
{% trans "Resolution" %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
{% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button>
{% comment %} <button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-1"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('adverse')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-adverse">
<i data-lucide="shield-alert" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i>
{% comment %} <button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-1"
{% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('adverse')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-adverse">
<i data-lucide="shield-alert" class="w-4 h-4 {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}text-slate-300{% endif %}"></i>
{% trans "Adverse Actions" %}
{% if adverse_actions %}
<span class="ml-1 px-1.5 py-0.5 bg-red-100 text-red-700 text-xs rounded-full">{{ adverse_actions.count }}</span>
{% endif %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
{% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button> {% endcomment %}
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('notes')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-notes">
<i data-lucide="message-square" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "Notes" %}
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('notes')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-notes">
<i data-lucide="message-square" class="w-4 h-4 {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "Notes" %}
{% if notes_count %}
<span class="ml-1 px-1.5 py-0.5 bg-slate-100 text-slate-600 text-xs rounded-full">{{ notes_count }}</span>
{% endif %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
{% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button>
</nav>
@ -598,7 +598,7 @@
<span class="text-sm font-bold text-yellow-700">{% trans "Pending Approval" %}</span>
</div>
<p class="text-xs text-yellow-600 mb-3">{% trans "OVR escalation requested. Please review and approve or reject." %}</p>
{% if request.user.is_px_admin or request.user.is_hospital_admin or request.user.is_px_management %}
{% if request.user.is_px_admin or request.user.is_hospital_admin or complaint.assigned_to == current_user %}
<div class="flex gap-2">
<form method="post" action="{% url 'complaints:approve_ovr_escalation' pk=complaint.pk %}">
{% csrf_token %}
@ -834,10 +834,10 @@
{% csrf_token %}
<button type="submit"
class="p-3 border-blue-200 bg-blue-50 rounded-xl hover:bg-blue-100 flex flex-col items-center gap-2 group transition col-span-2 mb-2"
{% if complaint.assigned_to == current_user %}disabled style="opacity: 0.5; cursor: not-allowed;"{% endif %}>
<i data-lucide="{% if complaint.assigned_to == current_user %}check-circle{% else %}play-circle{% endif %}" class="w-5 h-5 text-blue"></i>
{% if complaint.activated_at %}disabled style="opacity: 0.5; cursor: not-allowed;"{% endif %}>
<i data-lucide="{% if complaint.activated_at %}check-circle{% else %}play-circle{% endif %}" class="w-5 h-5 text-blue"></i>
<span class="text-[10px] font-bold text-blue uppercase">
{% if complaint.assigned_to == current_user %}{% trans "Activated" %}{% else %}{% trans "Activate" %}{% endif %}
{% if complaint.activated_at %}{% trans "Activated" %}{% else %}{% trans "Activate" %}{% endif %}
</span>
</button>
</form>
@ -876,6 +876,12 @@
</span>
</button>
</form>
{% if complaint.sent_to_department and not workflow_steps.department_responded %}
<button onclick="showReminderModal()" class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition">
<i data-lucide="bell" class="w-5 h-5 text-slate group-hover:text-amber-500"></i>
<span class="text-[10px] font-bold uppercase">{% trans "Remind" %}</span>
</button>
{% endif %}
{% endif %}
{% else %}
<!-- Not yet activated: only Activate / Assign / Close are available -->
@ -1251,6 +1257,43 @@
</div>
</div>
<!-- Department Reminder Modal -->
<div id="reminderModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4">
<div class="flex items-center justify-between p-5 border-b border-slate-100">
<h3 class="text-lg font-bold text-navy flex items-center gap-2"><i data-lucide="bell" class="w-5 h-5"></i> {% trans "Send Reminder" %}</h3>
<button type="button" onclick="closeModal('reminderModal')" class="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<form method="post" action="{% url 'complaints:complaint_send_dept_reminder' pk=complaint.pk %}" class="p-5 space-y-4">
{% csrf_token %}
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{% trans "Select Department" %} <span class="text-red-500">*</span></label>
<select name="involved_department_id" required
class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none">
<option value="">{% trans "Select Department" %}</option>
{% for inv in complaint.involved_departments.all %}
{% if inv.sent and not inv.response_submitted %}
<option value="{{ inv.pk }}">{{ inv.department.get_localized_name }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{% trans "Note (optional)" %}</label>
<textarea name="note" rows="2"
class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none"
placeholder="{% trans 'Add context or instructions...' %}"></textarea>
</div>
<div class="flex gap-3">
<button type="button" onclick="closeModal('reminderModal')" class="flex-1 px-4 py-2.5 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 transition">{% trans "Cancel" %}</button>
<button type="submit" class="flex-1 px-4 py-2.5 bg-amber-500 text-white rounded-xl font-bold hover:bg-amber-600 transition flex items-center justify-center gap-2">
<i data-lucide="bell" class="w-4 h-4"></i> {% trans "Send Reminder" %}
</button>
</div>
</form>
</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">
@ -1371,7 +1414,7 @@ function closeModal(modalId) {
// Close modal when clicking outside the dialog
window.onclick = function(event) {
if (event && event.target) {
var modals = ['resolveModal', 'assignModal', 'followUpModal', 'escalateModal', 'closeModal', 'locationModal', 'sendToDeptModal'];
var modals = ['resolveModal', 'assignModal', 'followUpModal', 'escalateModal', 'closeModal', 'locationModal', 'sendToDeptModal', 'reminderModal'];
if (modals.indexOf(event.target.id) !== -1) {
event.target.style.display = 'none';
}
@ -1544,6 +1587,11 @@ function saveTaxonomy() {
}
// ─── Send to Department modal (department + staff selects) ───
function showReminderModal() {
var m = document.getElementById('reminderModal');
if (m) m.style.display = 'flex';
}
function openSendToDeptModal() {
var m = document.getElementById('sendToDeptModal');
if (!m) return;

View File

@ -2,7 +2,7 @@
{% load i18n %}
{% block title %}{% trans "Your PX360 Account Has Been Created - Al Hammadi Hospital" %}{% endblock %}
{% block preheader %}{% trans "Your account is ready. Use your temporary password to log in." %}{% endblock %}
{% block preheader %}{% trans "Your account is ready." %}{% endblock %}
{% block content %}
<p style="margin: 0 0 15px;">
@ -10,20 +10,23 @@
</p>
<p style="margin: 0 0 20px;">
{% trans "An account has been created for you on PX360. You can log in using the credentials below." %}
{% trans "An account has been created for you on PX360." %}
</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 0 0 20px; background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0;">
<tr>
<td style="padding: 20px;">
<p style="margin: 0 0 8px;"><strong>{% trans "Email:" %}</strong> {{ user.email }}</p>
{% if temp_password %}
<p style="margin: 0 0 8px;"><strong>{% trans "Temporary Password:" %}</strong> <code style="background-color: #fee2e2; padding: 2px 8px; border-radius: 4px; font-size: 14px; color: #dc2626;">{{ temp_password }}</code></p>
{% endif %}
</td>
</tr>
</table>
{% if reset_url %}
<p style="margin: 0 0 25px;">
<strong>{% trans "Important:" %}</strong> {% trans "For security, please change your password immediately after logging in." %}
<strong>{% trans "Important:" %}</strong> {% trans "For security, please set your password using the link below." %}
</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
@ -36,6 +39,22 @@
</td>
</tr>
</table>
{% else %}
<p style="margin: 0 0 25px;">
{% trans "You can now log in using the credentials provided to you by your administrator." %}
</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<a href="{{ login_url }}"
style="display: inline-block; padding: 12px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #005696;">
{% trans "Log In" %}
</a>
</td>
</tr>
</table>
{% endif %}
<p style="margin: 25px 0 0; font-size: 14px; color: #666666;">
{% trans "If you did not expect this account creation, please contact your system administrator immediately." %}

View File

@ -180,9 +180,41 @@
</div>
</div>
</section>
{% endif %}
<!-- Personal Information -->
<!-- Create Staff Record -->
<section class="form-section">
<h3 class="font-bold text-navy mb-6 text-lg flex items-center gap-2">
<i data-lucide="user-plus" class="w-5 h-5 text-blue"></i>
{% trans "Create Staff Record" %}
</h3>
<div class="flex items-start gap-3 p-4 bg-light/30 rounded-xl">
<input type="checkbox" name="create_staff" id="create_staff"
class="w-5 h-5 text-navy border-slate-300 rounded focus:ring-navy mt-0.5">
<div>
<label for="create_staff" class="text-sm font-semibold text-navy block cursor-pointer">{% trans "Also create a staff record" %}</label>
<p class="text-xs text-slate mt-1">{% trans "Creates a staff directory entry linked to this user. Employee ID is required." %}</p>
</div>
</div>
<div id="createStaffFields" class="hidden mt-4 grid grid-cols-2 gap-4">
<div>
<label for="staff_type" class="block text-sm font-semibold text-navy mb-2">{% trans "Staff Type" %} <span class="text-red-500">*</span></label>
<select name="staff_type" id="staff_type"
class="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm">
<option value="other">{% trans "Other" %}</option>
<option value="physician">{% trans "Physician" %}</option>
<option value="nurse">{% trans "Nurse" %}</option>
<option value="admin">{% trans "Administrative" %}</option>
</select>
</div>
<div>
<label for="staff_job_title" class="block text-sm font-semibold text-navy mb-2">{% trans "Job Title" %} <span class="text-red-500">*</span></label>
<input type="text" name="staff_job_title" id="staff_job_title"
class="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm"
placeholder="{% trans 'e.g. Registered Nurse' %}">
</div>
</div>
</section>
{% endif %}
<section class="form-section">
<h3 class="font-bold text-navy mb-6 text-lg flex items-center gap-2">
<i data-lucide="user" class="w-5 h-5 text-blue"></i>
@ -358,7 +390,7 @@
<div class="mt-4 p-3 bg-blue-50 border border-blue-100 rounded-lg">
<p class="text-blue-700 text-xs flex items-start gap-2">
<i data-lucide="info" class="w-4 h-4 flex-shrink-0 mt-0.5"></i>
{% trans "Leave password blank and click Generate for a random password. The credentials will be sent to the user's email." %}
{% trans "Set a password now, or leave blank to send a password setup link via email." %}
</p>
</div>
</section>
@ -419,7 +451,7 @@
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5"></i>
<span>{% trans "A password reset email will be sent automatically" %}</span>
<span>{% trans "Set a password or leave blank to email a setup link" %}</span>
</li>
</ul>
</section>
@ -461,35 +493,63 @@ document.addEventListener('DOMContentLoaded', function() {
// ===== Department loading =====
function loadDepartments(hospitalId, selectedDept) {
if (!departmentSelect) return;
var ts = departmentSelect.tomselect;
var placeholderText = '{% trans "Select Department" %}';
if (!hospitalId) {
departmentSelect.innerHTML = '<option value="">{% trans "Select Department" %}</option>';
if (ts) {
ts.clear();
ts.clearOptions();
ts.addOption({value: '', text: placeholderText});
} else {
departmentSelect.innerHTML = '<option value="">' + placeholderText + '</option>';
}
return;
}
departmentSelect.innerHTML = '<option value="">{% trans "Loading..." %}</option>';
if (ts) { ts.clear(); ts.clearOptions(); ts.addOption({value: '', text: '{% trans "Loading..." %}'}); }
else { departmentSelect.innerHTML = '<option value="">{% trans "Loading..." %}</option>'; }
fetch('/organizations/api/departments/?hospital=' + hospitalId)
.then(response => response.json())
.then(data => {
const results = data.results || data;
departmentSelect.innerHTML = '<option value="">{% trans "Select Department" %}</option>';
results.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);
});
if (ts) {
ts.clear();
ts.clearOptions();
ts.addOption({value: '', text: placeholderText});
results.forEach(dept => {
ts.addOption({
value: dept.id,
text: {% if LANG == 'ar' %}dept.name_ar || dept.name{% else %}dept.name{% endif %}
});
});
} else {
departmentSelect.innerHTML = '<option value="">' + placeholderText + '</option>';
results.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);
});
}
if (selectedDept) {
departmentSelect.value = selectedDept;
} else if ('{{ form.department.value }}') {
departmentSelect.value = '{{ form.department.value }}';
var selectVal = selectedDept || ('{{ form.department.value }}' || '');
if (selectVal) {
if (ts) { ts.setValue(selectVal, true); }
else { departmentSelect.value = selectVal; }
}
})
.catch(error => {
console.error('Error loading departments:', error);
departmentSelect.innerHTML = '<option value="">{% trans "Error loading departments" %}</option>';
if (ts) {
ts.clear();
ts.clearOptions();
ts.addOption({value: '', text: '{% trans "Error loading departments" %}'});
} else {
departmentSelect.innerHTML = '<option value="">{% trans "Error loading departments" %}</option>';
}
});
}
@ -510,6 +570,38 @@ document.addEventListener('DOMContentLoaded', function() {
const selectedStaffName = document.getElementById('selectedStaffName');
const staffSearchInputWrap = document.getElementById('staffSearchInput-wrap');
// ===== Create Staff Toggle =====
const createStaffCheckbox = document.getElementById('create_staff');
const createStaffFields = document.getElementById('createStaffFields');
const staffSearchSection = staffSearchInput ? staffSearchInput.closest('.form-section') : null;
if (createStaffCheckbox) {
createStaffCheckbox.addEventListener('change', function() {
if (this.checked) {
createStaffFields.classList.remove('hidden');
if (staffSearchSection) staffSearchSection.classList.add('hidden');
if (staffIdInput) staffIdInput.value = '';
} else {
createStaffFields.classList.add('hidden');
if (staffSearchSection) staffSearchSection.classList.remove('hidden');
}
lucide.createIcons();
});
}
function disableCreateStaff() {
if (createStaffCheckbox) {
createStaffCheckbox.checked = false;
createStaffFields.classList.add('hidden');
}
}
function enableCreateStaff() {
if (staffSearchSection && createStaffCheckbox && createStaffCheckbox.checked) {
staffSearchSection.classList.add('hidden');
}
}
if (staffSearchInput) {
let debounceTimer;
@ -603,6 +695,8 @@ document.addEventListener('DOMContentLoaded', function() {
staffSearchInputWrap.classList.add('hidden');
selectedStaffChip.classList.remove('hidden');
disableCreateStaff();
// Auto-fill fields
const fields = {
'id_first_name': data.first_name,
@ -636,8 +730,34 @@ document.addEventListener('DOMContentLoaded', function() {
staffSearchInputWrap.classList.remove('hidden');
staffSearchInput.value = '';
staffSearchResults.classList.remove('active');
if (createStaffCheckbox && staffSearchSection) {
staffSearchSection.classList.remove('hidden');
}
};
// ===== Form Validation =====
const userForm = document.getElementById('userForm');
if (userForm && createStaffCheckbox) {
userForm.addEventListener('submit', function(e) {
if (createStaffCheckbox.checked) {
var empId = document.getElementById('id_employee_id');
var jobTitle = document.getElementById('staff_job_title');
var staffType = document.getElementById('staff_type');
var missing = [];
if (!empId || !empId.value.trim()) missing.push('{% trans "Employee ID" %}');
if (!jobTitle || !jobTitle.value.trim()) missing.push('{% trans "Job Title" %}');
if (!staffType || !staffType.value) missing.push('{% trans "Staff Type" %}');
if (missing.length) {
e.preventDefault();
alert('{% trans "Please fill in the following required fields:" %} ' + missing.join(', '));
return false;
}
}
});
}
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}

View File

@ -0,0 +1,40 @@
{% extends 'emails/base_email_template.html' %}
{% load i18n %}
{% block title %}{% trans "SLA Reminder - Inquiry" %}{% endblock %}
{% block preheader %}{% trans "An inquiry assigned to you is approaching its SLA deadline." %}{% endblock %}
{% block content %}
<p style="margin: 0 0 15px;">
{% trans "Dear" %} <strong>{{ recipient_name|default:'Colleague' }}</strong>,
</p>
<p style="margin: 0 0 20px;">
{% trans "This is an automated reminder that an inquiry assigned to you is approaching its SLA deadline. Please review and take appropriate action." %}
</p>
<p style="margin: 0 0 5px;"><strong>{% trans "Reference:" %}</strong> {{ inquiry.reference_number }}</p>
<p style="margin: 0 0 5px;"><strong>{% trans "Subject:" %}</strong> {{ inquiry.subject }}</p>
<p style="margin: 0 0 5px;"><strong>{% trans "Due Date:" %}</strong> {{ inquiry.due_at|date:"F d, Y H:i" }}</p>
<p style="margin: 0 0 20px;"><strong>{% trans "Time Remaining:" %}</strong> {{ hours_remaining }} {% trans "hours" %}</p>
{% if inquiry_url %}
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<a href="{{ inquiry_url }}"
style="display: inline-block; padding: 12px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #005696;">
{% trans "View Inquiry" %}
</a>
</td>
</tr>
</table>
{% endif %}
<p style="margin: 25px 0 5px; font-size: 13px; color: #666666; direction: rtl; text-align: right;">
تذكير اتفاقية مستوى الخدمة - استفسار {{ inquiry.reference_number }}
</p>
<p style="margin: 0; font-size: 12px; color: #999999; direction: rtl; text-align: right; line-height: 1.8;">
تاريخ الاستحقاق: {{ inquiry.due_at|date:"Y F d H:i" }} - الوقت المتبقي: {{ hours_remaining }} ساعة
</p>
{% endblock %}

View File

@ -0,0 +1,40 @@
{% extends 'emails/base_email_template.html' %}
{% load i18n %}
{% block title %}{% trans "URGENT: SLA Reminder - Inquiry" %}{% endblock %}
{% block preheader %}{% trans "An inquiry assigned to you is about to breach its SLA deadline." %}{% endblock %}
{% block content %}
<p style="margin: 0 0 15px;">
{% trans "Dear" %} <strong>{{ recipient_name|default:'Colleague' }}</strong>,
</p>
<p style="margin: 0 0 20px; color: #dc2626; font-weight: bold;">
{% trans "URGENT: An inquiry assigned to you is about to breach its SLA deadline and requires your immediate attention." %}
</p>
<p style="margin: 0 0 5px;"><strong>{% trans "Reference:" %}</strong> {{ inquiry.reference_number }}</p>
<p style="margin: 0 0 5px;"><strong>{% trans "Subject:" %}</strong> {{ inquiry.subject }}</p>
<p style="margin: 0 0 5px;"><strong>{% trans "Due Date:" %}</strong> {{ inquiry.due_at|date:"F d, Y H:i" }}</p>
<p style="margin: 0 0 20px;"><strong>{% trans "Time Remaining:" %}</strong> {{ hours_remaining }} {% trans "hours" %}</p>
{% if inquiry_url %}
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<a href="{{ inquiry_url }}"
style="display: inline-block; padding: 12px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #dc2626;">
{% trans "View Inquiry" %}
</a>
</td>
</tr>
</table>
{% endif %}
<p style="margin: 25px 0 5px; font-size: 13px; color: #666666; direction: rtl; text-align: right;">
عاجل: تذكير اتفاقية مستوى الخدمة - استفسار {{ inquiry.reference_number }}
</p>
<p style="margin: 0; font-size: 12px; color: #999999; direction: rtl; text-align: right; line-height: 1.8;">
تاريخ الاستحقاق: {{ inquiry.due_at|date:"Y F d H:i" }} - الوقت المتبقي: {{ hours_remaining }} ساعة
</p>
{% endblock %}

View File

@ -149,7 +149,7 @@
</a>
<!-- Department Champion: only Dashboard + My Department -->
{% if user.is_champion and not user.is_px_admin and not user.is_hospital_admin and not user.is_department_manager %}
{% if user.is_champion and not user.is_px_admin and not user.is_hospital_admin and not user.is_department_manager and not user.is_px_employee and not user.is_px_management %}
{% if user.department %}
<a href="{% url 'organizations:department_detail' pk=user.department.pk %}"
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'departments' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
@ -324,7 +324,7 @@
<!-- Departments -->
{% if not user.is_basic_staff and not user.is_department_manager and not user.is_director %}
{% if not user.source_user_profile %}
{% if user.is_champion and user.department %}
{% if user.is_champion and user.department and not user.is_px_employee and not user.is_px_management %}
<a href="{% url 'organizations:department_detail' pk=user.department.pk %}"
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'departments' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
<i data-lucide="building-2" class="w-5 h-5 flex-shrink-0"></i>