Compare commits
1 Commits
main
...
temp_branc
| Author | SHA1 | Date | |
|---|---|---|---|
| d8577e44f7 |
@ -0,0 +1,68 @@
|
||||
# Generated by Django 5.0.14 on 2026-01-10 20:27
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('complaints', '0004_inquiryattachment_inquiryupdate'),
|
||||
('organizations', '0006_staff_email'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ComplaintExplanation',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('explanation', models.TextField(help_text="Staff's explanation about the complaint")),
|
||||
('token', models.CharField(db_index=True, help_text='Unique access token for explanation submission', max_length=64, unique=True)),
|
||||
('is_used', models.BooleanField(db_index=True, default=False, help_text='Token expiry tracking - becomes True after submission')),
|
||||
('submitted_via', models.CharField(choices=[('email_link', 'Email Link'), ('direct', 'Direct Entry')], default='email_link', help_text='How the explanation was submitted', max_length=20)),
|
||||
('email_sent_at', models.DateTimeField(blank=True, help_text='When the explanation request email was sent', null=True)),
|
||||
('responded_at', models.DateTimeField(blank=True, help_text='When the explanation was submitted', null=True)),
|
||||
('request_message', models.TextField(blank=True, help_text='Optional message sent with the explanation request')),
|
||||
('complaint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='explanations', to='complaints.complaint')),
|
||||
('requested_by', models.ForeignKey(blank=True, help_text='User who requested the explanation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_complaint_explanations', to=settings.AUTH_USER_MODEL)),
|
||||
('staff', models.ForeignKey(blank=True, help_text='Staff member who submitted the explanation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaint_explanations', to='organizations.staff')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Complaint Explanation',
|
||||
'verbose_name_plural': 'Complaint Explanations',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ExplanationAttachment',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('file', models.FileField(upload_to='explanation_attachments/%Y/%m/%d/')),
|
||||
('filename', models.CharField(max_length=500)),
|
||||
('file_type', models.CharField(blank=True, max_length=100)),
|
||||
('file_size', models.IntegerField(help_text='File size in bytes')),
|
||||
('description', models.TextField(blank=True)),
|
||||
('explanation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='complaints.complaintexplanation')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Explanation Attachment',
|
||||
'verbose_name_plural': 'Explanation Attachments',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='complaintexplanation',
|
||||
index=models.Index(fields=['complaint', '-created_at'], name='complaints__complai_b20e58_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='complaintexplanation',
|
||||
index=models.Index(fields=['token', 'is_used'], name='complaints__token_f8f9b7_idx'),
|
||||
),
|
||||
]
|
||||
@ -811,7 +811,7 @@ class Inquiry(UUIDModel, TimeStampedModel):
|
||||
class InquiryUpdate(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Inquiry update/timeline entry.
|
||||
|
||||
|
||||
Tracks all updates, status changes, and communications for inquiries.
|
||||
"""
|
||||
inquiry = models.ForeignKey(
|
||||
@ -819,7 +819,7 @@ class InquiryUpdate(UUIDModel, TimeStampedModel):
|
||||
on_delete=models.CASCADE,
|
||||
related_name='updates'
|
||||
)
|
||||
|
||||
|
||||
# Update details
|
||||
update_type = models.CharField(
|
||||
max_length=50,
|
||||
@ -832,9 +832,9 @@ class InquiryUpdate(UUIDModel, TimeStampedModel):
|
||||
],
|
||||
db_index=True
|
||||
)
|
||||
|
||||
|
||||
message = models.TextField()
|
||||
|
||||
|
||||
# User who made the update
|
||||
created_by = models.ForeignKey(
|
||||
'accounts.User',
|
||||
@ -842,20 +842,20 @@ class InquiryUpdate(UUIDModel, TimeStampedModel):
|
||||
null=True,
|
||||
related_name='inquiry_updates'
|
||||
)
|
||||
|
||||
|
||||
# Status change tracking
|
||||
old_status = models.CharField(max_length=20, blank=True)
|
||||
new_status = models.CharField(max_length=20, blank=True)
|
||||
|
||||
|
||||
# Metadata
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['inquiry', '-created_at']),
|
||||
]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.inquiry} - {self.update_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
||||
|
||||
@ -867,23 +867,146 @@ class InquiryAttachment(UUIDModel, TimeStampedModel):
|
||||
on_delete=models.CASCADE,
|
||||
related_name='attachments'
|
||||
)
|
||||
|
||||
|
||||
file = models.FileField(upload_to='inquiries/%Y/%m/%d/')
|
||||
filename = models.CharField(max_length=500)
|
||||
file_type = models.CharField(max_length=100, blank=True)
|
||||
file_size = models.IntegerField(help_text="File size in bytes")
|
||||
|
||||
|
||||
uploaded_by = models.ForeignKey(
|
||||
'accounts.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='inquiry_attachments'
|
||||
)
|
||||
|
||||
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.inquiry} - {self.filename}"
|
||||
|
||||
|
||||
class ComplaintExplanation(UUIDModel, TimeStampedModel):
|
||||
"""
|
||||
Staff/recipient explanation about a complaint.
|
||||
|
||||
Allows staff members to submit their perspective via token-based link.
|
||||
Each staff member can submit one explanation per complaint.
|
||||
"""
|
||||
complaint = models.ForeignKey(
|
||||
Complaint,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='explanations'
|
||||
)
|
||||
|
||||
staff = models.ForeignKey(
|
||||
'organizations.Staff',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='complaint_explanations',
|
||||
help_text="Staff member who submitted the explanation"
|
||||
)
|
||||
|
||||
explanation = models.TextField(
|
||||
help_text="Staff's explanation about the complaint"
|
||||
)
|
||||
|
||||
token = models.CharField(
|
||||
max_length=64,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
help_text="Unique access token for explanation submission"
|
||||
)
|
||||
|
||||
is_used = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Token expiry tracking - becomes True after submission"
|
||||
)
|
||||
|
||||
submitted_via = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('email_link', 'Email Link'),
|
||||
('direct', 'Direct Entry'),
|
||||
],
|
||||
default='email_link',
|
||||
help_text="How the explanation was submitted"
|
||||
)
|
||||
|
||||
email_sent_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When the explanation request email was sent"
|
||||
)
|
||||
|
||||
responded_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When the explanation was submitted"
|
||||
)
|
||||
|
||||
# Request details
|
||||
requested_by = models.ForeignKey(
|
||||
'accounts.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='requested_complaint_explanations',
|
||||
help_text="User who requested the explanation"
|
||||
)
|
||||
|
||||
request_message = models.TextField(
|
||||
blank=True,
|
||||
help_text="Optional message sent with the explanation request"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
verbose_name = 'Complaint Explanation'
|
||||
verbose_name_plural = 'Complaint Explanations'
|
||||
indexes = [
|
||||
models.Index(fields=['complaint', '-created_at']),
|
||||
models.Index(fields=['token', 'is_used']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
staff_name = self.staff if self.staff else 'Unknown'
|
||||
return f"{self.complaint} - {staff_name} - {'Submitted' if self.is_used else 'Pending'}"
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
"""Check if token is expired (already used)"""
|
||||
return self.is_used
|
||||
|
||||
def can_submit(self):
|
||||
"""Check if explanation can still be submitted"""
|
||||
return not self.is_used
|
||||
|
||||
|
||||
class ExplanationAttachment(UUIDModel, TimeStampedModel):
|
||||
"""Attachment for complaint explanation"""
|
||||
explanation = models.ForeignKey(
|
||||
ComplaintExplanation,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='attachments'
|
||||
)
|
||||
|
||||
file = models.FileField(upload_to='explanation_attachments/%Y/%m/%d/')
|
||||
filename = models.CharField(max_length=500)
|
||||
file_type = models.CharField(max_length=100, blank=True)
|
||||
file_size = models.IntegerField(help_text="File size in bytes")
|
||||
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
verbose_name = 'Explanation Attachment'
|
||||
verbose_name_plural = 'Explanation Attachments'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.explanation} - {self.filename}"
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import ComplaintAttachmentViewSet, ComplaintViewSet, InquiryViewSet
|
||||
from .views import (
|
||||
ComplaintAttachmentViewSet,
|
||||
ComplaintViewSet,
|
||||
InquiryViewSet,
|
||||
complaint_explanation_form,
|
||||
generate_complaint_pdf,
|
||||
)
|
||||
from . import ui_views
|
||||
|
||||
app_name = 'complaints'
|
||||
@ -55,6 +61,12 @@ urlpatterns = [
|
||||
path('public/api/load-departments/', ui_views.api_load_departments, name='api_load_departments'),
|
||||
path('public/api/load-categories/', ui_views.api_load_categories, name='api_load_categories'),
|
||||
|
||||
# Public Explanation Form (No Authentication Required)
|
||||
path('<uuid:complaint_id>/explain/<str:token>/', complaint_explanation_form, name='complaint_explanation_form'),
|
||||
|
||||
# PDF Export
|
||||
path('<uuid:pk>/pdf/', generate_complaint_pdf, name='complaint_pdf'),
|
||||
|
||||
# API Routes
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
@ -656,6 +656,11 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
||||
|
||||
Sends complaint notification with AI-generated summary (editable by user).
|
||||
Logs the operation to NotificationLog and ComplaintUpdate.
|
||||
|
||||
Recipient Priority:
|
||||
1. Staff with user account
|
||||
2. Staff with email field
|
||||
3. Department manager
|
||||
"""
|
||||
complaint = self.get_object()
|
||||
|
||||
@ -670,26 +675,36 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
||||
# Get additional message (optional)
|
||||
additional_message = request.data.get('additional_message', '').strip()
|
||||
|
||||
# Determine recipient
|
||||
# Determine recipient with priority logic
|
||||
recipient = None
|
||||
recipient_display = None
|
||||
recipient_type = None
|
||||
recipient_email = None
|
||||
|
||||
# Priority 1: Staff member mentioned in complaint
|
||||
# Priority 1: Staff member with user account
|
||||
if complaint.staff and complaint.staff.user:
|
||||
recipient = complaint.staff.user
|
||||
recipient_display = complaint.staff.get_full_name()
|
||||
recipient_type = 'Staff Member'
|
||||
# Priority 2: Department head
|
||||
recipient_display = str(complaint.staff)
|
||||
recipient_type = 'Staff Member (User Account)'
|
||||
recipient_email = recipient.email
|
||||
|
||||
# Priority 2: Staff member with email field (no user account)
|
||||
elif complaint.staff and complaint.staff.email:
|
||||
recipient_display = str(complaint.staff)
|
||||
recipient_type = 'Staff Member (Email)'
|
||||
recipient_email = complaint.staff.email
|
||||
|
||||
# Priority 3: Department head
|
||||
elif complaint.department and complaint.department.manager:
|
||||
recipient = complaint.department.manager
|
||||
recipient_display = recipient.get_full_name()
|
||||
recipient_type = 'Department Head'
|
||||
recipient_email = recipient.email
|
||||
|
||||
# Check if we found a recipient
|
||||
if not recipient or not recipient.email:
|
||||
# Check if we found a recipient with email
|
||||
if not recipient_email:
|
||||
return Response(
|
||||
{'error': 'No valid recipient found. Complaint must have either a staff member with user account, or a department manager with email.'},
|
||||
{'error': 'No valid recipient found. Complaint must have staff with email, or a department manager with email.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
@ -698,7 +713,7 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
||||
|
||||
# Build email body
|
||||
email_body = f"""
|
||||
Dear {recipient.get_full_name()},
|
||||
Dear {recipient_display},
|
||||
|
||||
You have been assigned to review the following complaint:
|
||||
|
||||
@ -755,14 +770,14 @@ This is an automated message from PX360 Complaint Management System.
|
||||
|
||||
try:
|
||||
notification_log = NotificationService.send_email(
|
||||
email=recipient.email,
|
||||
email=recipient_email,
|
||||
subject=subject,
|
||||
message=email_body,
|
||||
related_object=complaint,
|
||||
metadata={
|
||||
'notification_type': 'complaint_notification',
|
||||
'recipient_type': recipient_type,
|
||||
'recipient_id': str(recipient.id),
|
||||
'recipient_id': str(recipient.id) if recipient else None,
|
||||
'sender_id': str(request.user.id),
|
||||
'has_additional_message': bool(additional_message)
|
||||
}
|
||||
@ -781,7 +796,7 @@ This is an automated message from PX360 Complaint Management System.
|
||||
created_by=request.user,
|
||||
metadata={
|
||||
'recipient_type': recipient_type,
|
||||
'recipient_id': str(recipient.id),
|
||||
'recipient_id': str(recipient.id) if recipient else None,
|
||||
'notification_log_id': str(notification_log.id) if notification_log else None
|
||||
}
|
||||
)
|
||||
@ -794,8 +809,8 @@ This is an automated message from PX360 Complaint Management System.
|
||||
content_object=complaint,
|
||||
metadata={
|
||||
'recipient_type': recipient_type,
|
||||
'recipient_id': str(recipient.id),
|
||||
'recipient_email': recipient.email
|
||||
'recipient_id': str(recipient.id) if recipient else None,
|
||||
'recipient_email': recipient_email
|
||||
}
|
||||
)
|
||||
|
||||
@ -804,7 +819,193 @@ This is an automated message from PX360 Complaint Management System.
|
||||
'message': 'Email notification sent successfully',
|
||||
'recipient': recipient_display,
|
||||
'recipient_type': recipient_type,
|
||||
'recipient_email': recipient.email
|
||||
'recipient_email': recipient_email
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def request_explanation(self, request, pk=None):
|
||||
"""
|
||||
Request explanation from staff/recipient.
|
||||
|
||||
Generates a unique token and sends email with secure link.
|
||||
Token can only be used once.
|
||||
"""
|
||||
complaint = self.get_object()
|
||||
|
||||
# Check if complaint has staff to request explanation from
|
||||
if not complaint.staff:
|
||||
return Response(
|
||||
{'error': 'Complaint has no staff assigned to request explanation from'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check if explanation already exists for this staff
|
||||
from .models import ComplaintExplanation
|
||||
existing_explanation = ComplaintExplanation.objects.filter(
|
||||
complaint=complaint,
|
||||
staff=complaint.staff
|
||||
).first()
|
||||
|
||||
if existing_explanation and existing_explanation.is_used:
|
||||
return Response(
|
||||
{'error': 'This staff member has already submitted an explanation'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Get optional message
|
||||
request_message = request.data.get('request_message', '').strip()
|
||||
|
||||
# Generate unique token
|
||||
import secrets
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
# Create or update explanation record
|
||||
if existing_explanation:
|
||||
explanation = existing_explanation
|
||||
explanation.token = token
|
||||
explanation.is_used = False
|
||||
explanation.requested_by = request.user
|
||||
explanation.request_message = request_message
|
||||
explanation.email_sent_at = timezone.now()
|
||||
explanation.save()
|
||||
else:
|
||||
explanation = ComplaintExplanation.objects.create(
|
||||
complaint=complaint,
|
||||
staff=complaint.staff,
|
||||
token=token,
|
||||
is_used=False,
|
||||
submitted_via='email_link',
|
||||
requested_by=request.user,
|
||||
request_message=request_message,
|
||||
email_sent_at=timezone.now()
|
||||
)
|
||||
|
||||
# Send email with explanation link
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from apps.notifications.services import NotificationService
|
||||
|
||||
site = get_current_site(request)
|
||||
explanation_link = f"https://{site.domain}/complaints/{complaint.id}/explain/{token}/"
|
||||
|
||||
# Determine recipient email
|
||||
if complaint.staff.user and complaint.staff.user.email:
|
||||
recipient_email = complaint.staff.user.email
|
||||
recipient_display = str(complaint.staff)
|
||||
elif complaint.staff.email:
|
||||
recipient_email = complaint.staff.email
|
||||
recipient_display = str(complaint.staff)
|
||||
else:
|
||||
return Response(
|
||||
{'error': 'Staff member has no email address'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Build email subject
|
||||
subject = f"Explanation Request - Complaint #{complaint.id}"
|
||||
|
||||
# Build email body
|
||||
email_body = f"""
|
||||
Dear {recipient_display},
|
||||
|
||||
We have received a complaint that requires your explanation.
|
||||
|
||||
COMPLAINT DETAILS:
|
||||
----------------
|
||||
Reference: #{complaint.id}
|
||||
Title: {complaint.title}
|
||||
Severity: {complaint.get_severity_display()}
|
||||
Priority: {complaint.get_priority_display()}
|
||||
|
||||
{complaint.description}
|
||||
|
||||
"""
|
||||
|
||||
# Add patient info if available
|
||||
if complaint.patient:
|
||||
email_body += f"""
|
||||
PATIENT INFORMATION:
|
||||
------------------
|
||||
Name: {complaint.patient.get_full_name()}
|
||||
MRN: {complaint.patient.mrn}
|
||||
"""
|
||||
|
||||
# Add request message if provided
|
||||
if request_message:
|
||||
email_body += f"""
|
||||
|
||||
ADDITIONAL MESSAGE:
|
||||
------------------
|
||||
{request_message}
|
||||
"""
|
||||
|
||||
email_body += f"""
|
||||
|
||||
SUBMIT YOUR EXPLANATION:
|
||||
------------------------
|
||||
Your perspective is important. Please submit your explanation about this complaint:
|
||||
{explanation_link}
|
||||
|
||||
Note: This link can only be used once. After submission, it will expire.
|
||||
|
||||
If you have any questions, please contact the PX team.
|
||||
|
||||
---
|
||||
This is an automated message from PX360 Complaint Management System.
|
||||
"""
|
||||
|
||||
# Send email
|
||||
try:
|
||||
notification_log = NotificationService.send_email(
|
||||
email=recipient_email,
|
||||
subject=subject,
|
||||
message=email_body,
|
||||
related_object=complaint,
|
||||
metadata={
|
||||
'notification_type': 'explanation_request',
|
||||
'staff_id': str(complaint.staff.id),
|
||||
'explanation_id': str(explanation.id),
|
||||
'requested_by_id': str(request.user.id),
|
||||
'has_request_message': bool(request_message)
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Failed to send email: {str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
# Create ComplaintUpdate entry
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type='communication',
|
||||
message=f"Explanation request sent to {recipient_display}",
|
||||
created_by=request.user,
|
||||
metadata={
|
||||
'explanation_id': str(explanation.id),
|
||||
'staff_id': str(complaint.staff.id),
|
||||
'notification_log_id': str(notification_log.id) if notification_log else None
|
||||
}
|
||||
)
|
||||
|
||||
# Log audit
|
||||
AuditService.log_from_request(
|
||||
event_type='explanation_requested',
|
||||
description=f"Explanation request sent to {recipient_display}",
|
||||
request=request,
|
||||
content_object=complaint,
|
||||
metadata={
|
||||
'explanation_id': str(explanation.id),
|
||||
'staff_id': str(complaint.staff.id),
|
||||
'request_message': request_message
|
||||
}
|
||||
)
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Explanation request sent successfully',
|
||||
'explanation_id': str(explanation.id),
|
||||
'recipient': recipient_display,
|
||||
'explanation_link': explanation_link
|
||||
})
|
||||
|
||||
|
||||
@ -888,3 +1089,213 @@ class InquiryViewSet(viewsets.ModelViewSet):
|
||||
inquiry.save()
|
||||
|
||||
return Response({'message': 'Response submitted successfully'})
|
||||
|
||||
|
||||
# Public views (no authentication required)
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.http import JsonResponse
|
||||
|
||||
|
||||
def complaint_explanation_form(request, complaint_id, token):
|
||||
"""
|
||||
Public-facing form for staff to submit explanation.
|
||||
|
||||
This view does NOT require authentication.
|
||||
Validates token and checks if it's still valid (not used).
|
||||
"""
|
||||
from .models import ComplaintExplanation, ExplanationAttachment
|
||||
from apps.notifications.services import NotificationService
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
|
||||
# Get complaint
|
||||
complaint = get_object_or_404(Complaint, id=complaint_id)
|
||||
|
||||
# Validate token
|
||||
explanation = get_object_or_404(ComplaintExplanation, complaint=complaint, token=token)
|
||||
|
||||
# Check if token is already used
|
||||
if explanation.is_used:
|
||||
return render(request, 'complaints/explanation_already_submitted.html', {
|
||||
'complaint': complaint,
|
||||
'explanation': explanation
|
||||
})
|
||||
|
||||
if request.method == 'POST':
|
||||
# Handle form submission
|
||||
explanation_text = request.POST.get('explanation', '').strip()
|
||||
|
||||
if not explanation_text:
|
||||
return render(request, 'complaints/explanation_form.html', {
|
||||
'complaint': complaint,
|
||||
'explanation': explanation,
|
||||
'error': 'Please provide your explanation.'
|
||||
})
|
||||
|
||||
# Save explanation
|
||||
explanation.explanation = explanation_text
|
||||
explanation.is_used = True
|
||||
explanation.responded_at = timezone.now()
|
||||
explanation.save()
|
||||
|
||||
# Handle file attachments
|
||||
files = request.FILES.getlist('attachments')
|
||||
for uploaded_file in files:
|
||||
ExplanationAttachment.objects.create(
|
||||
explanation=explanation,
|
||||
file=uploaded_file,
|
||||
filename=uploaded_file.name,
|
||||
file_type=uploaded_file.content_type,
|
||||
file_size=uploaded_file.size
|
||||
)
|
||||
|
||||
# Notify complaint assignee
|
||||
if complaint.assigned_to and complaint.assigned_to.email:
|
||||
site = get_current_site(request)
|
||||
complaint_url = f"https://{site.domain}/complaints/{complaint.id}/"
|
||||
|
||||
subject = f"New Explanation Received - Complaint #{complaint.id}"
|
||||
|
||||
email_body = f"""
|
||||
Dear {complaint.assigned_to.get_full_name()},
|
||||
|
||||
A new explanation has been submitted for the following complaint:
|
||||
|
||||
COMPLAINT DETAILS:
|
||||
----------------
|
||||
Reference: #{complaint.id}
|
||||
Title: {complaint.title}
|
||||
Severity: {complaint.get_severity_display()}
|
||||
|
||||
EXPLANATION SUBMITTED BY:
|
||||
------------------------
|
||||
{explanation.staff}
|
||||
|
||||
EXPLANATION:
|
||||
-----------
|
||||
{explanation.explanation}
|
||||
|
||||
"""
|
||||
if files:
|
||||
email_body += f"""
|
||||
ATTACHMENTS:
|
||||
------------
|
||||
{len(files)} file(s) attached
|
||||
"""
|
||||
|
||||
email_body += f"""
|
||||
|
||||
To view the complaint and explanation, please visit:
|
||||
{complaint_url}
|
||||
|
||||
---
|
||||
This is an automated message from PX360 Complaint Management System.
|
||||
"""
|
||||
|
||||
try:
|
||||
NotificationService.send_email(
|
||||
email=complaint.assigned_to.email,
|
||||
subject=subject,
|
||||
message=email_body,
|
||||
related_object=complaint,
|
||||
metadata={
|
||||
'notification_type': 'explanation_submitted',
|
||||
'explanation_id': str(explanation.id),
|
||||
'staff_id': str(explanation.staff.id) if explanation.staff else None
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
# Log error but don't fail the submission
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to send notification email: {e}")
|
||||
|
||||
# Create complaint update
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type='communication',
|
||||
message=f"Explanation submitted by {explanation.staff}",
|
||||
metadata={
|
||||
'explanation_id': str(explanation.id),
|
||||
'staff_id': str(explanation.staff.id) if explanation.staff else None
|
||||
}
|
||||
)
|
||||
|
||||
# Redirect to success page
|
||||
return render(request, 'complaints/explanation_success.html', {
|
||||
'complaint': complaint,
|
||||
'explanation': explanation,
|
||||
'attachment_count': len(files)
|
||||
})
|
||||
|
||||
# GET request - display form
|
||||
return render(request, 'complaints/explanation_form.html', {
|
||||
'complaint': complaint,
|
||||
'explanation': explanation
|
||||
})
|
||||
|
||||
|
||||
from django.http import HttpResponse
|
||||
|
||||
|
||||
def generate_complaint_pdf(request, complaint_id):
|
||||
"""
|
||||
Generate PDF for a complaint using WeasyPrint.
|
||||
|
||||
Creates a professionally styled PDF document with all complaint details
|
||||
including AI analysis, staff assignment, and resolution information.
|
||||
"""
|
||||
# Get complaint
|
||||
complaint = get_object_or_404(Complaint, id=complaint_id)
|
||||
|
||||
# Check permissions
|
||||
user = request.user
|
||||
if not user.is_authenticated:
|
||||
return HttpResponse('Unauthorized', status=401)
|
||||
|
||||
# Check if user can view this complaint
|
||||
can_view = False
|
||||
if user.is_px_admin():
|
||||
can_view = True
|
||||
elif user.is_hospital_admin() and user.hospital == complaint.hospital:
|
||||
can_view = True
|
||||
elif user.is_department_manager() and user.department == complaint.department:
|
||||
can_view = True
|
||||
elif user.hospital == complaint.hospital:
|
||||
can_view = True
|
||||
|
||||
if not can_view:
|
||||
return HttpResponse('Forbidden', status=403)
|
||||
|
||||
# Render HTML template
|
||||
from django.template.loader import render_to_string
|
||||
html_string = render_to_string('complaints/complaint_pdf.html', {
|
||||
'complaint': complaint,
|
||||
})
|
||||
|
||||
# Generate PDF using WeasyPrint
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
pdf_file = HTML(string=html_string).write_pdf()
|
||||
|
||||
# Create response
|
||||
response = HttpResponse(pdf_file, content_type='application/pdf')
|
||||
filename = f"complaint_{complaint.id.strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
|
||||
# Log audit
|
||||
AuditService.log_from_request(
|
||||
event_type='pdf_generated',
|
||||
description=f"PDF generated for complaint: {complaint.title}",
|
||||
request=request,
|
||||
content_object=complaint,
|
||||
metadata={'complaint_id': str(complaint.id)}
|
||||
)
|
||||
|
||||
return response
|
||||
except ImportError:
|
||||
return HttpResponse('WeasyPrint is not installed. Please install it to generate PDFs.', status=500)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error generating PDF for complaint {complaint.id}: {e}")
|
||||
return HttpResponse(f'Error generating PDF: {str(e)}', status=500)
|
||||
|
||||
@ -79,7 +79,7 @@ class StaffAdmin(admin.ModelAdmin):
|
||||
fieldsets = (
|
||||
(None, {'fields': ('first_name', 'last_name', 'first_name_ar', 'last_name_ar')}),
|
||||
('Role', {'fields': ('staff_type', 'job_title')}),
|
||||
('Professional', {'fields': ('license_number', 'specialization', 'employee_id')}),
|
||||
('Professional', {'fields': ('license_number', 'specialization', 'employee_id', 'email')}),
|
||||
('Organization', {'fields': ('hospital', 'department')}),
|
||||
('Account', {'fields': ('user',)}),
|
||||
('Status', {'fields': ('status',)}),
|
||||
|
||||
18
apps/organizations/migrations/0006_staff_email.py
Normal file
18
apps/organizations/migrations/0006_staff_email.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.14 on 2026-01-10 14:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('organizations', '0005_alter_staff_department'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='staff',
|
||||
name='email',
|
||||
field=models.EmailField(blank=True, max_length=254),
|
||||
),
|
||||
]
|
||||
@ -157,6 +157,7 @@ class Staff(UUIDModel, TimeStampedModel):
|
||||
# Professional Data (Nullable for non-physicians)
|
||||
license_number = models.CharField(max_length=100, unique=True, null=True, blank=True)
|
||||
specialization = models.CharField(max_length=200, blank=True)
|
||||
email = models.EmailField(blank=True)
|
||||
employee_id = models.CharField(max_length=50, unique=True, db_index=True)
|
||||
|
||||
# Organization
|
||||
|
||||
364
docs/PDF_GENERATION_IMPLEMENTATION.md
Normal file
364
docs/PDF_GENERATION_IMPLEMENTATION.md
Normal file
@ -0,0 +1,364 @@
|
||||
# PDF Generation Implementation for Complaints
|
||||
|
||||
## Overview
|
||||
Implemented professional PDF generation for complaints using WeasyPrint. This feature allows users to download a beautifully formatted PDF document containing all complaint details, including AI analysis, staff assignment, and resolution information.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Dependencies
|
||||
- Added `weasyprint>=60.0` to `pyproject.toml`
|
||||
- WeasyPrint provides CSS-based PDF generation with full support for modern CSS features
|
||||
|
||||
### 2. Files Created
|
||||
|
||||
#### `templates/complaints/complaint_pdf.html`
|
||||
Professional PDF template with:
|
||||
- **Header**: Complaint title, ID, status, severity, patient info
|
||||
- **Basic Information**: Category, source, priority, encounter ID, dates
|
||||
- **Description**: Full complaint details
|
||||
- **Staff Assignment**: Assigned staff member (if any)
|
||||
- **AI Analysis**:
|
||||
- Emotion analysis with confidence score and intensity bar
|
||||
- AI summary
|
||||
- Suggested action
|
||||
- **Resolution**: Resolution details (if resolved)
|
||||
- **Footer**: Generation timestamp, system branding
|
||||
|
||||
**Features:**
|
||||
- A4 page size with proper margins
|
||||
- Professional purple gradient header
|
||||
- Color-coded badges for status and severity
|
||||
- Page numbers in footer
|
||||
- Proper page breaks for multi-page documents
|
||||
- Print-optimized CSS
|
||||
|
||||
### 3. Files Modified
|
||||
|
||||
#### `apps/complaints/views.py`
|
||||
Added `generate_complaint_pdf()` function:
|
||||
- Validates user permissions (PX Admin, Hospital Admin, Department Manager, or hospital staff)
|
||||
- Renders HTML template with complaint context
|
||||
- Converts HTML to PDF using WeasyPrint
|
||||
- Returns PDF as downloadable attachment
|
||||
- Logs PDF generation to audit trail
|
||||
- Handles errors gracefully with user-friendly messages
|
||||
|
||||
#### `apps/complaints/urls.py`
|
||||
- Added URL route: `<uuid:pk>/pdf/` mapped to `generate_complaint_pdf`
|
||||
- Named URL: `complaints:complaint_pdf`
|
||||
|
||||
#### `templates/complaints/complaint_detail.html`
|
||||
Added "PDF View" tab:
|
||||
- New tab in complaints detail page
|
||||
- Download PDF button with icon
|
||||
- Description of PDF contents
|
||||
- Informational alerts about what's included
|
||||
- Note about WeasyPrint requirement
|
||||
|
||||
## Usage
|
||||
|
||||
### For Users
|
||||
|
||||
1. Navigate to a complaint detail page
|
||||
2. Click the "PDF View" tab
|
||||
3. Click the "Download PDF" button
|
||||
4. The PDF will be generated and downloaded automatically
|
||||
|
||||
### For Developers
|
||||
|
||||
#### Generating a PDF Programmatically
|
||||
|
||||
```python
|
||||
from django.template.loader import render_to_string
|
||||
from weasyprint import HTML
|
||||
|
||||
# Render template
|
||||
html_string = render_to_string('complaints/complaint_pdf.html', {
|
||||
'complaint': complaint,
|
||||
})
|
||||
|
||||
# Generate PDF
|
||||
pdf_file = HTML(string=html_string).write_pdf()
|
||||
|
||||
# Return as HTTP response
|
||||
response = HttpResponse(pdf_file, content_type='application/pdf')
|
||||
response['Content-Disposition'] = 'attachment; filename="complaint.pdf"'
|
||||
```
|
||||
|
||||
## Installation Requirements
|
||||
|
||||
### System Dependencies (Linux/Ubuntu)
|
||||
```bash
|
||||
sudo apt-get install python3-dev python3-pip python3-cffi libcairo2 libpango-1.0-0 libgdk-pixbuf2.0-0 shared-mime-info
|
||||
|
||||
# For additional font support
|
||||
sudo apt-get install fonts-liberation
|
||||
```
|
||||
|
||||
### Python Dependencies
|
||||
```bash
|
||||
pip install weasyprint>=60.0
|
||||
```
|
||||
|
||||
Or using uv/pip with the project's pyproject.toml:
|
||||
```bash
|
||||
uv pip install weasyprint
|
||||
# or
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### macOS Dependencies
|
||||
```bash
|
||||
brew install cairo pango gdk-pixbuf libffi shared-mime-info
|
||||
```
|
||||
|
||||
### Windows Dependencies
|
||||
Download and install GTK+ from:
|
||||
https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer
|
||||
|
||||
## PDF Template Customization
|
||||
|
||||
The PDF template uses standard CSS. You can customize:
|
||||
|
||||
### Colors
|
||||
Edit the gradient in the header:
|
||||
```css
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
```
|
||||
|
||||
### Page Margins
|
||||
Edit the `@page` rule:
|
||||
```css
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
}
|
||||
```
|
||||
|
||||
### Badges
|
||||
Modify badge colors by editing the CSS classes:
|
||||
```css
|
||||
.status-open { background: #e3f2fd; color: #1976d2; }
|
||||
.severity-high { background: #ffebee; color: #d32f2f; }
|
||||
```
|
||||
|
||||
### Fonts
|
||||
Add custom fonts using `@font-face`:
|
||||
```css
|
||||
@page {
|
||||
@font-face {
|
||||
src: url('path/to/font.ttf');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Permission Control
|
||||
Only authorized users can generate PDFs:
|
||||
- PX Admins: All complaints
|
||||
- Hospital Admins: Their hospital's complaints
|
||||
- Department Managers: Their department's complaints
|
||||
- Hospital Staff: Their hospital's complaints
|
||||
|
||||
### Audit Logging
|
||||
All PDF generations are logged:
|
||||
- Event type: `pdf_generated`
|
||||
- Description includes complaint title
|
||||
- Metadata includes complaint ID
|
||||
- Tracked by user and timestamp
|
||||
|
||||
### Error Handling
|
||||
- Graceful error messages if WeasyPrint is not installed
|
||||
- Logs errors for debugging
|
||||
- Returns user-friendly error responses
|
||||
|
||||
## PDF Contents
|
||||
|
||||
### Header Section
|
||||
- Complaint title (large, prominent)
|
||||
- Complaint ID (truncated to 8 characters)
|
||||
- Status badge (color-coded)
|
||||
- Severity badge (color-coded)
|
||||
- Patient name and MRN
|
||||
- Hospital name
|
||||
- Department name (if assigned)
|
||||
|
||||
### Basic Information
|
||||
Grid layout with:
|
||||
- Category (with subcategory if present)
|
||||
- Source
|
||||
- Priority
|
||||
- Encounter ID
|
||||
- Created date
|
||||
- SLA deadline
|
||||
|
||||
### Description
|
||||
Full complaint text with line breaks preserved.
|
||||
|
||||
### Staff Assignment (if assigned)
|
||||
- Staff member name (English and Arabic)
|
||||
- Job title
|
||||
- Department
|
||||
- AI-extracted staff name (if available)
|
||||
- Staff match confidence score
|
||||
|
||||
### AI Analysis (if available)
|
||||
- **Emotion Analysis**:
|
||||
- Emotion type with badge
|
||||
- Confidence percentage
|
||||
- Intensity bar visualization
|
||||
- **AI Summary**: Brief summary of the complaint
|
||||
- **Suggested Action**: AI-recommended action to take
|
||||
|
||||
### Resolution (if resolved)
|
||||
- Resolution text
|
||||
- Resolved by (user name)
|
||||
- Resolution date/time
|
||||
|
||||
### Footer
|
||||
- Generation timestamp
|
||||
- PX360 branding
|
||||
- AlHammadi Group branding
|
||||
- Page numbers (for multi-page documents)
|
||||
|
||||
## Styling Highlights
|
||||
|
||||
### Color Scheme
|
||||
- Primary purple gradient: `#667eea` → `#764ba2`
|
||||
- Status colors: Blue, Orange, Green, Gray, Red
|
||||
- Severity colors: Green, Orange, Red, Dark Red
|
||||
- AI section: Purple gradient background
|
||||
- Resolution section: Green background
|
||||
|
||||
### Typography
|
||||
- Font family: Segoe UI, Tahoma, Geneva, Verdana, sans-serif
|
||||
- Base font size: 11pt
|
||||
- Labels: 9pt, uppercase, letter-spacing: 0.5px
|
||||
- Values: 11pt
|
||||
|
||||
### Layout
|
||||
- Max-width: 210mm (A4 width)
|
||||
- 2cm page margins
|
||||
- Grid-based information layout
|
||||
- Card-based sections with borders and shadows
|
||||
- Proper spacing and padding
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
WeasyPrint generates PDFs server-side, so the PDF will look the same regardless of the user's browser. The generated PDF can be viewed in:
|
||||
- Adobe Acrobat Reader
|
||||
- Chrome/Edge PDF viewer
|
||||
- Firefox PDF viewer
|
||||
- Safari PDF viewer
|
||||
- Any modern PDF viewer
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- PDF generation is synchronous (happens in the request/response cycle)
|
||||
- For complaints with extensive updates or attachments, generation may take 1-3 seconds
|
||||
- Consider adding caching for frequently accessed complaints
|
||||
- For high-traffic scenarios, consider asynchronous generation
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
1. **Batch PDF generation**: Generate PDFs for multiple complaints
|
||||
2. **Email PDF**: Option to email PDF directly
|
||||
3. **Custom branding**: Allow hospital-specific logos/colors
|
||||
4. **PDF templates**: Multiple template options (minimal, detailed, etc.)
|
||||
5. **Digital signatures**: Add digital signature capability
|
||||
6. **Watermarks**: Add watermarks for draft/official versions
|
||||
7. **Barcodes/QR codes**: Include complaint barcode for scanning
|
||||
8. **Attachments**: Include complaint attachments in PDF
|
||||
9. **Bilingual PDF**: Generate PDF in Arabic/English side-by-side
|
||||
10. **Charts**: Include complaint trend charts
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### WeasyPrint Import Error
|
||||
**Error**: `ImportError: No module named 'weasyprint'`
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
pip install weasyprint
|
||||
```
|
||||
|
||||
### Cairo Library Error
|
||||
**Error**: `ImportError: No module named 'cairo'`
|
||||
|
||||
**Solution**:
|
||||
Install system dependencies (see Installation Requirements section)
|
||||
|
||||
### Font Issues
|
||||
**Error**: Text appears as squares or missing characters
|
||||
|
||||
**Solution**:
|
||||
Install additional fonts on the system:
|
||||
```bash
|
||||
sudo apt-get install fonts-liberation fonts-noto-cjk
|
||||
```
|
||||
|
||||
### Memory Issues
|
||||
**Error**: PDF generation fails for large complaints
|
||||
|
||||
**Solution**:
|
||||
1. Reduce number of updates included
|
||||
2. Implement pagination for large content
|
||||
3. Increase server memory allocation
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
1. Create a test complaint with AI analysis
|
||||
2. Navigate to complaint detail page
|
||||
3. Click PDF View tab
|
||||
4. Download PDF
|
||||
5. Verify all sections are present
|
||||
6. Check styling and formatting
|
||||
7. Test with different complaint states (open, resolved, etc.)
|
||||
|
||||
### Automated Testing
|
||||
```python
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from apps.complaints.models import Complaint
|
||||
|
||||
class PDFGenerationTest(TestCase):
|
||||
def test_pdf_generation(self):
|
||||
complaint = Complaint.objects.first()
|
||||
url = reverse('complaints:complaint_pdf', kwargs={'pk': complaint.pk})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response['Content-Type'], 'application/pdf')
|
||||
self.assertGreater(len(response.content), 0)
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [WeasyPrint Documentation](https://weasyprint.readthedocs.io/)
|
||||
- [CSS Paged Media Module](https://www.w3.org/TR/css-page-3/)
|
||||
- [Django WeasyPrint Guide](https://weasyprint.readthedocs.io/en/latest/django.html)
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check WeasyPrint installation
|
||||
2. Review system dependencies
|
||||
3. Check Django logs for error details
|
||||
4. Verify user permissions
|
||||
5. Test with a simple PDF template first
|
||||
|
||||
## Changelog
|
||||
|
||||
### v1.0.0 (2025-01-10)
|
||||
- Initial implementation
|
||||
- Professional PDF template with complaint details
|
||||
- AI analysis integration
|
||||
- Staff assignment display
|
||||
- Resolution information
|
||||
- Permission-based access control
|
||||
- Audit logging
|
||||
- Download functionality
|
||||
@ -25,6 +25,7 @@ dependencies = [
|
||||
"djangorestframework-stubs>=3.16.6",
|
||||
"rich>=14.2.0",
|
||||
"reportlab>=4.4.7",
|
||||
"weasyprint>=60.0",
|
||||
"openpyxl>=3.1.5",
|
||||
"litellm>=1.0.0",
|
||||
"watchdog>=6.0.0",
|
||||
|
||||
@ -200,6 +200,18 @@
|
||||
<i class="bi bi-lightning-fill me-1"></i> {{ _("PX Actions")}} ({{ px_actions.count }})
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="explanation-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#explanation" type="button" role="tab">
|
||||
<i class="bi bi-chat-quote me-1"></i> {{ _("Explanation")}}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="pdf-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#pdf" type="button" role="tab">
|
||||
<i class="bi bi-file-earmark-pdf me-1"></i> {{ _("PDF View")}}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Tab Content -->
|
||||
@ -586,6 +598,136 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Explanation Tab -->
|
||||
<div class="tab-pane fade" id="explanation" role="tabpanel">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-4">{% trans "Staff Explanation" %}</h5>
|
||||
|
||||
{% if complaint.explanation %}
|
||||
<!-- Existing Explanation -->
|
||||
<div class="alert alert-info">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="mb-2">
|
||||
<i class="bi bi-chat-quote me-2"></i>
|
||||
{% trans "Explanation Received" %}
|
||||
</h6>
|
||||
<p class="mb-2">{{ complaint.explanation.explanation|linebreaks }}</p>
|
||||
{% if complaint.explanation.staff_name %}
|
||||
<p class="mb-0 text-muted">
|
||||
<small>
|
||||
<strong>{% trans "Staff:" %}</strong> {{ complaint.explanation.staff_name }}
|
||||
</small>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="badge bg-success">
|
||||
<i class="bi bi-check-circle"></i>
|
||||
{% trans "Submitted" %}
|
||||
</span>
|
||||
</div>
|
||||
<hr class="my-2">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
{{ complaint.explanation.responded_at|date:"M d, Y H:i" }}
|
||||
{% if complaint.explanation.attachment_count > 0 %}
|
||||
<span class="mx-2">|</span>
|
||||
<i class="bi bi-paperclip me-1"></i>
|
||||
{{ complaint.explanation.attachment_count }} {% trans "attachment(s)" %}
|
||||
{% endif %}
|
||||
</small>
|
||||
{% if complaint.explanation.attachments %}
|
||||
<div class="mt-3">
|
||||
<h6 class="small text-muted">{% trans "Attachments:" %}</h6>
|
||||
{% for attachment in complaint.explanation.attachments %}
|
||||
<a href="{{ attachment.file.url }}" class="btn btn-sm btn-outline-secondary me-2" download>
|
||||
<i class="bi bi-download"></i> {{ attachment.filename }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if complaint.explanation and complaint.explanation.token %}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-primary" onclick="copyExplanationLink()">
|
||||
<i class="bi bi-link-45deg me-1"></i> {% trans "Copy Link" %}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="resendExplanation()">
|
||||
<i class="bi bi-envelope me-1"></i> {% trans "Resend Email" %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body py-2">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
{% trans "Explanation ID:" %} {{ complaint.explanation.id }} |
|
||||
{% trans "Token:" %} {{ complaint.explanation.token|slice:":8" }}...
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- No Explanation Yet -->
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-chat-quote" style="font-size: 3rem; color: #ccc;"></i>
|
||||
<p class="text-muted mt-3">{% trans "No explanation has been submitted yet." %}</p>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="card border-info mt-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<i class="bi bi-lightning-charge me-2"></i>
|
||||
{% trans "Request Explanation" %}
|
||||
</h6>
|
||||
<p class="card-text text-muted">
|
||||
{% trans "Send a link to the assigned staff member requesting their explanation about this complaint." %}
|
||||
</p>
|
||||
|
||||
{% if complaint.staff %}
|
||||
<div class="alert alert-success mb-3">
|
||||
<i class="bi bi-person-badge me-1"></i>
|
||||
<strong>{% trans "Will be sent to:" %}</strong>
|
||||
{{ complaint.staff.get_full_name }}
|
||||
{% if complaint.staff.user %}
|
||||
<br><small class="text-muted">{{ complaint.staff.user.email }}</small>
|
||||
{% elif complaint.staff.email %}
|
||||
<br><small class="text-muted">{{ complaint.staff.email }}</small>
|
||||
{% else %}
|
||||
<br><small class="text-danger"><i class="bi bi-exclamation-triangle"></i> {% trans "No email configured" %}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Custom Message (Optional)" %}</label>
|
||||
<textarea id="explanationMessage" class="form-control" rows="3"
|
||||
placeholder="{% trans 'Add a custom message to include in the email...' %}"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-primary" onclick="requestExplanation()">
|
||||
<i class="bi bi-send me-1"></i> {% trans "Request Explanation" %}
|
||||
</button>
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-warning mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
{% trans "Please assign a staff member to this complaint before requesting an explanation." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PX Actions Tab -->
|
||||
<div class="tab-pane fade" id="actions" role="tabpanel">
|
||||
<div class="card">
|
||||
@ -622,6 +764,56 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF View Tab -->
|
||||
<div class="tab-pane fade" id="pdf" role="tabpanel">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-4">
|
||||
<i class="bi bi-file-earmark-pdf me-2"></i>{% trans "PDF View" %}
|
||||
</h5>
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<a href="{% url 'complaints:complaint_pdf' complaint.id %}"
|
||||
class="btn btn-primary btn-lg"
|
||||
target="_blank">
|
||||
<i class="bi bi-download me-2"></i>{% trans "Download PDF" %}
|
||||
</a>
|
||||
<div class="mt-3 text-muted">
|
||||
<small>
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
{% trans "This will generate a professionally formatted PDF with all complaint details, including AI analysis, staff assignment, and resolution information." %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<h6 class="alert-heading">
|
||||
<i class="bi bi-file-text me-2"></i>{% trans "PDF Contents" %}
|
||||
</h6>
|
||||
<ul class="mb-0">
|
||||
<li><strong>{% trans "Header:" %}</strong> Complaint title, ID, status, severity, patient info</li>
|
||||
<li><strong>{% trans "Basic Information:" %}</strong> Category, source, priority, encounter ID, dates</li>
|
||||
<li><strong>{% trans "Description:" %}</strong> Full complaint details</li>
|
||||
<li><strong>{% trans "Staff Assignment:" %}</strong> Assigned staff member (if any)</li>
|
||||
<li><strong>{% trans "AI Analysis:" %}</strong> Emotion analysis, summary, suggested action (if available)</li>
|
||||
<li><strong>{% trans "Resolution:" %}</strong> Resolution details (if resolved)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<h6 class="alert-heading">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>{% trans "Note" %}
|
||||
</h6>
|
||||
<p class="mb-0">
|
||||
{% trans "PDF generation requires WeasyPrint to be installed. If you see an error message, please contact your system administrator." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -662,8 +854,11 @@
|
||||
{% for dept in hospital_departments %}
|
||||
<option value="{{ dept.id }}"
|
||||
{% if complaint.department and complaint.department.id == dept.id %}selected{% endif %}>
|
||||
{{ dept.name_en }}
|
||||
{% if dept.name_ar %}({{ dept.name_ar }}){% endif %}
|
||||
{% if LANGUAGE_CODE == 'ar' %}
|
||||
{{ dept.name_ar|default:dept.name }}
|
||||
{% else %}
|
||||
{{ dept.name }}
|
||||
{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
@ -712,8 +907,13 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Request Explanation -->
|
||||
<button type="button" class="btn btn-info w-100 mb-2" onclick="switchToExplanationTab()">
|
||||
<i class="bi bi-chat-quote me-1"></i> {{ _("Request Explanation") }}
|
||||
</button>
|
||||
|
||||
<!-- Send Notification -->
|
||||
<button type="button" class="btn btn-info w-100 mb-2" data-bs-toggle="modal"
|
||||
<button type="button" class="btn btn-outline-info w-100 mb-2" data-bs-toggle="modal"
|
||||
data-bs-target="#sendNotificationModal">
|
||||
<i class="bi bi-envelope me-1"></i> {{ _("Send Notification") }}
|
||||
</button>
|
||||
@ -986,61 +1186,89 @@
|
||||
<i class="bi bi-person-check me-1"></i>Recipient
|
||||
</h6>
|
||||
|
||||
{% if complaint.staff and complaint.staff.user %}
|
||||
<!-- Staff has user account - will receive email -->
|
||||
{% if complaint.staff %}
|
||||
<!-- Staff is assigned - always the primary recipient -->
|
||||
{% if complaint.staff.user %}
|
||||
<!-- Staff has user account -->
|
||||
<div class="alert alert-success mb-2">
|
||||
<i class="bi bi-check-circle-fill me-1"></i>
|
||||
<strong>Primary Recipient:</strong> {{ complaint.staff.get_full_name }}
|
||||
<strong>Primary Recipient (Assigned Staff with User Account):</strong> {{ complaint.staff.get_full_name }}
|
||||
{% if complaint.staff.job_title %}
|
||||
<br><small class="text-muted">{{ complaint.staff.job_title }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% elif complaint.staff %}
|
||||
<!-- Staff exists but has no user account -->
|
||||
<div class="alert alert-warning mb-2">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
<strong>Staff Member Assigned:</strong> {{ complaint.staff.get_full_name }}
|
||||
{% if complaint.staff.job_title %}
|
||||
<br><small class="text-muted">{{ complaint.staff.job_title }}</small>
|
||||
{% if complaint.staff.department %}
|
||||
<br><small class="text-muted">{{ complaint.staff.department.name_en }}</small>
|
||||
{% endif %}
|
||||
<hr class="my-2">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
This staff member has no user account in the system.
|
||||
</small>
|
||||
<small class="text-success"><i class="bi bi-envelope me-1"></i>Email will be sent to: {{ complaint.staff.user.email }}</small>
|
||||
</div>
|
||||
|
||||
{% if complaint.department and complaint.department.manager %}
|
||||
<!-- Department manager is the actual recipient -->
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-person-badge me-1"></i>
|
||||
<strong>Actual Recipient:</strong> {{ complaint.department.manager.get_full_name }}
|
||||
<br><small class="text-muted">Department Head of {{ complaint.department.name_en }}</small>
|
||||
{% elif complaint.staff.email %}
|
||||
<!-- Staff has email but no user account -->
|
||||
<div class="alert alert-warning mb-2">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
<strong>Primary Recipient (Assigned Staff - Email):</strong> {{ complaint.staff.get_full_name }}
|
||||
{% if complaint.staff.job_title %}
|
||||
<br><small class="text-muted">{{ complaint.staff.job_title }}</small>
|
||||
{% endif %}
|
||||
{% if complaint.staff.department %}
|
||||
<br><small class="text-muted">{{ complaint.staff.department.name_en }}</small>
|
||||
{% endif %}
|
||||
<hr class="my-2">
|
||||
<small class="text-warning"><i class="bi bi-envelope me-1"></i>Email will be sent to: {{ complaint.staff.email }}</small>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- No fallback recipient -->
|
||||
<div class="alert alert-danger mb-0">
|
||||
<i class="bi bi-x-circle-fill me-1"></i>
|
||||
<strong>No recipient available</strong>
|
||||
<br><small>The assigned staff has no user account and no department manager is set.</small>
|
||||
<!-- Staff has no user account and no email -->
|
||||
<div class="alert alert-warning mb-2">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
<strong>Assigned Staff:</strong> {{ complaint.staff.get_full_name }}
|
||||
{% if complaint.staff.job_title %}
|
||||
<br><small class="text-muted">{{ complaint.staff.job_title }}</small>
|
||||
{% endif %}
|
||||
{% if complaint.staff.department %}
|
||||
<br><small class="text-muted">{{ complaint.staff.department.name_en }}</small>
|
||||
{% endif %}
|
||||
<hr class="my-2">
|
||||
<small class="text-danger"><i class="bi bi-x-circle me-1"></i>No email configured for this staff member</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not complaint.staff.user and not complaint.staff.email %}
|
||||
<!-- Staff has no user account and no email - show fallback -->
|
||||
{% if complaint.department and complaint.department.manager %}
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-info-circle-fill me-1"></i>
|
||||
<strong>Fallback Recipient (Department Head):</strong> {{ complaint.department.manager.get_full_name }}
|
||||
{% if complaint.department.manager.email %}
|
||||
<br><small class="text-info"><i class="bi bi-envelope me-1"></i>Email will be sent to: {{ complaint.department.manager.email }}</small>
|
||||
{% else %}
|
||||
<br><small class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>Department head has no email address</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-danger mb-0">
|
||||
<i class="bi bi-x-circle-fill me-1"></i>
|
||||
<strong>No Email Recipient Available</strong>
|
||||
<br><small>Please add an email to the assigned staff member, or configure a department manager.</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% elif complaint.department and complaint.department.manager %}
|
||||
<!-- No staff, but department manager exists -->
|
||||
<!-- No staff assigned, but department manager exists -->
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-person-badge me-1"></i>
|
||||
<strong>Department Head:</strong> {{ complaint.department.manager.get_full_name }}
|
||||
<strong>Recipient (Department Head):</strong> {{ complaint.department.manager.get_full_name }}
|
||||
{% if complaint.department %}
|
||||
<br><small class="text-muted">Manager of {{ complaint.department.name_en }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- No recipient at all -->
|
||||
<div class="alert alert-danger mb-0">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
<strong>No recipient available</strong>
|
||||
<br><small>No staff or department manager assigned to this complaint.</small>
|
||||
<strong>No Recipient Available</strong>
|
||||
<br><small>No staff member or department manager is assigned to this complaint.</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -1376,6 +1604,101 @@ function getCookie(name) {
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
function switchToExplanationTab() {
|
||||
const explanationTab = document.getElementById('explanation-tab');
|
||||
if (explanationTab) {
|
||||
explanationTab.click();
|
||||
}
|
||||
}
|
||||
|
||||
function requestExplanation() {
|
||||
const message = document.getElementById('explanationMessage')?.value || '';
|
||||
|
||||
if (!confirm('{% trans "Are you sure you want to request an explanation?" %}')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = event.target;
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Sending...';
|
||||
|
||||
fetch(`/complaints/api/complaints/{{ complaint.id }}/request_explanation/`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: message
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('{% trans "Explanation request sent successfully!" %}');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('{% trans "Error:" %} ' + (data.error || '{% trans "Unknown error" %}'));
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('{% trans "Failed to send explanation request. Please try again." %}');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
{% if complaint.explanation and complaint.explanation.token %}
|
||||
function copyExplanationLink() {
|
||||
const link = `{% if request.is_secure %}https{% else %}http{% endif %}://{{ request.get_host }}{% url 'complaints:complaint_explanation_form' complaint.id complaint.explanation.token %}`;
|
||||
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
alert('{% trans "Link copied to clipboard!" %}');
|
||||
}).catch(() => {
|
||||
// Fallback
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = link;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
alert('{% trans "Link copied to clipboard!" %}');
|
||||
});
|
||||
}
|
||||
|
||||
function resendExplanation() {
|
||||
if (!confirm('{% trans "Resend explanation request email?" %}')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/complaints/api/complaints/{{ complaint.id }}/resend_explanation_email/`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('{% trans "Email resent successfully!" %}');
|
||||
} else {
|
||||
alert('{% trans "Error:" %} ' + (data.error || '{% trans "Unknown error" %}'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('{% trans "Failed to resend email. Please try again." %}');
|
||||
});
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
function sendNotification() {
|
||||
const btn = document.getElementById('sendNotificationBtn');
|
||||
const emailMessage = document.getElementById('emailMessage').value;
|
||||
|
||||
438
templates/complaints/complaint_pdf.html
Normal file
438
templates/complaints/complaint_pdf.html
Normal file
@ -0,0 +1,438 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Complaint #{{ complaint.id|slice:":8" }}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
|
||||
@top-center {
|
||||
content: "PX360 - Patient Experience Management";
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@bottom-center {
|
||||
content: "Page " counter(page) " of " counter(pages);
|
||||
font-size: 9pt;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.document {
|
||||
max-width: 210mm;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px 30px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 18pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header .meta {
|
||||
font-size: 10pt;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.header .meta div {
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 25px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14pt;
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 11pt;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 9pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-open { background: #e3f2fd; color: #1976d2; }
|
||||
.badge-in_progress { background: #fff3e0; color: #f57c00; }
|
||||
.badge-resolved { background: #e8f5e9; color: #388e3c; }
|
||||
.badge-closed { background: #f5f5f5; color: #616161; }
|
||||
.badge-cancelled { background: #ffebee; color: #d32f2f; }
|
||||
|
||||
.badge-low { background: #e8f5e9; color: #388e3c; }
|
||||
.badge-medium { background: #fff3e0; color: #f57c00; }
|
||||
.badge-high { background: #ffebee; color: #d32f2f; }
|
||||
.badge-critical { background: #880e4f; color: #fff; }
|
||||
|
||||
.description-box {
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ai-section {
|
||||
background: linear-gradient(135deg, #f3e5f5 0%, #e8eaf6 100%);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.ai-section .section-title {
|
||||
color: #7b1fa2;
|
||||
border-bottom-color: #7b1fa2;
|
||||
}
|
||||
|
||||
.ai-box {
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
border-left: 4px solid #7b1fa2;
|
||||
}
|
||||
|
||||
.ai-box .ai-label {
|
||||
font-size: 9pt;
|
||||
color: #7b1fa2;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.emotion-bar {
|
||||
height: 8px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.emotion-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.resolution-box {
|
||||
background: #e8f5e9;
|
||||
border: 1px solid #c8e6c9;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.resolution-box .resolution-label {
|
||||
font-size: 9pt;
|
||||
color: #2e7d32;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.timeline-entry {
|
||||
padding-left: 20px;
|
||||
border-left: 3px solid #667eea;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.timeline-entry .timestamp {
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.footer .generated {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.confidence-score {
|
||||
font-size: 10pt;
|
||||
font-weight: 600;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.staff-card {
|
||||
background: #e3f2fd;
|
||||
border-left: 4px solid #1976d2;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.suggestion-box {
|
||||
background: linear-gradient(135deg, #e8f5e9 0%, #e1f5fe 100%);
|
||||
border: 1px solid #4caf50;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.page-break {
|
||||
page-break-before: always;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>{{ complaint.title }}</h1>
|
||||
<div class="meta">
|
||||
<div>
|
||||
<strong>ID:</strong> {{ complaint.id|slice:":8" }}
|
||||
<strong> • Status:</strong>
|
||||
<span class="badge badge-{{ complaint.status }}">{{ complaint.get_status_display }}</span>
|
||||
<strong> • Severity:</strong>
|
||||
<span class="badge badge-{{ complaint.severity }}">{{ complaint.get_severity_display }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Patient:</strong> {{ complaint.patient.get_full_name }} (MRN: {{ complaint.patient.mrn }})
|
||||
</div>
|
||||
<div>
|
||||
<strong>Hospital:</strong> {{ complaint.hospital.name_en }}
|
||||
{% if complaint.department %}
|
||||
<strong> • Department:</strong> {{ complaint.department.name_en }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Basic Information -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Complaint Information</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<div class="info-label">Category</div>
|
||||
<div class="info-value">
|
||||
<span class="badge" style="background: #e0e0e0;">{{ complaint.get_category_display }}</span>
|
||||
{% if complaint.subcategory %}
|
||||
<span style="margin-left: 8px;">/ {{ complaint.subcategory }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Source</div>
|
||||
<div class="info-value">{{ complaint.get_source_display }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Priority</div>
|
||||
<div class="info-value">
|
||||
<span class="badge" style="background: #e3f2fd; color: #1976d2;">{{ complaint.get_priority_display }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Encounter ID</div>
|
||||
<div class="info-value">
|
||||
{% if complaint.encounter_id %}
|
||||
{{ complaint.encounter_id }}
|
||||
{% else %}
|
||||
<em>N/A</em>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Created Date</div>
|
||||
<div class="info-value">{{ complaint.created_at|date:"F d, Y H:i" }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">SLA Deadline</div>
|
||||
<div class="info-value">{{ complaint.due_at|date:"F d, Y H:i" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">Complaint Description</h2>
|
||||
<div class="description-box">
|
||||
{{ complaint.description|linebreaks }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Staff Assignment -->
|
||||
{% if complaint.staff %}
|
||||
<div class="section">
|
||||
<h2 class="section-title">Staff Assignment</h2>
|
||||
<div class="staff-card">
|
||||
<div class="info-label">Assigned Staff Member</div>
|
||||
<div class="info-value" style="margin-top: 8px;">
|
||||
<strong>{{ complaint.staff.get_full_name }}</strong>
|
||||
{% if complaint.staff.first_name_ar or complaint.staff.last_name_ar %}
|
||||
<br><span style="color: #666;">({{ complaint.staff.first_name_ar }} {{ complaint.staff.last_name_ar }})</span>
|
||||
{% endif %}
|
||||
{% if complaint.staff.job_title %}
|
||||
<br><span style="color: #666; font-size: 10pt;">{{ complaint.staff.job_title }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if complaint.metadata.ai_analysis.extracted_staff_name %}
|
||||
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #bbdefb; font-size: 9pt; color: #666;">
|
||||
<em>AI Extracted: "{{ complaint.metadata.ai_analysis.extracted_staff_name }}"</em>
|
||||
{% if complaint.metadata.ai_analysis.staff_confidence %}
|
||||
(Confidence: {{ complaint.metadata.ai_analysis.staff_confidence|mul:100|floatformat:0 }}%)
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- AI Analysis -->
|
||||
{% if complaint.short_description or complaint.suggested_action or complaint.emotion %}
|
||||
<div class="section ai-section">
|
||||
<h2 class="section-title">
|
||||
<span style="margin-right: 8px;">🤖</span>AI Analysis
|
||||
</h2>
|
||||
|
||||
<!-- Emotion Analysis -->
|
||||
{% if complaint.emotion %}
|
||||
<div class="ai-box">
|
||||
<div class="ai-label">Emotion Analysis</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<span class="badge" style="background: #7b1fa2; color: white;">
|
||||
{{ complaint.get_emotion_display }}
|
||||
</span>
|
||||
<span class="confidence-score" style="margin-left: 10px;">
|
||||
Confidence: {{ complaint.emotion_confidence|mul:100|floatformat:0 }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<div style="display: flex; justify-content: space-between; font-size: 9pt; color: #666; margin-bottom: 3px;">
|
||||
<span>Intensity</span>
|
||||
<span>{{ complaint.emotion_intensity|floatformat:2 }} / 1.0</span>
|
||||
</div>
|
||||
<div class="emotion-bar">
|
||||
<div class="emotion-fill" style="width: {{ complaint.emotion_intensity|mul:100 }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- AI Summary -->
|
||||
{% if complaint.short_description %}
|
||||
<div class="ai-box">
|
||||
<div class="ai-label">AI Summary</div>
|
||||
<div style="line-height: 1.6; color: #333;">
|
||||
{{ complaint.short_description }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Suggested Action -->
|
||||
{% if complaint.suggested_action %}
|
||||
<div class="suggestion-box">
|
||||
<div class="ai-label" style="color: #2e7d32;">
|
||||
<span style="margin-right: 5px;">⚡</span>Suggested Action
|
||||
</div>
|
||||
<div style="line-height: 1.6; color: #1b5e20;">
|
||||
{{ complaint.suggested_action }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Resolution -->
|
||||
{% if complaint.resolution %}
|
||||
<div class="section page-break">
|
||||
<h2 class="section-title">Resolution</h2>
|
||||
<div class="resolution-box">
|
||||
<div class="resolution-label">
|
||||
<span style="margin-right: 5px;">✓</span>Complaint Resolved
|
||||
</div>
|
||||
<div style="line-height: 1.6; color: #2e7d32; margin-bottom: 15px;">
|
||||
{{ complaint.resolution|linebreaks }}
|
||||
</div>
|
||||
<div style="font-size: 9pt; color: #666;">
|
||||
<strong>Resolved by:</strong> {{ complaint.resolved_by.get_full_name }}
|
||||
<br>
|
||||
<strong>Resolved on:</strong> {{ complaint.resolved_at|date:"F d, Y H:i" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<div class="generated">
|
||||
Generated on {% now "F d, Y H:i" %}
|
||||
</div>
|
||||
<div style="margin-top: 5px; color: #999; font-size: 8pt;">
|
||||
PX360 - Patient Experience Management System
|
||||
</div>
|
||||
<div style="margin-top: 5px; color: #999; font-size: 8pt;">
|
||||
AlHammadi Group
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
111
templates/complaints/explanation_already_submitted.html
Normal file
111
templates/complaints/explanation_already_submitted.html
Normal file
@ -0,0 +1,111 @@
|
||||
{% load i18n %}
|
||||
<!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>{% trans "Already Submitted" %} - PX360</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
|
||||
}
|
||||
.info-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
.info-circle {
|
||||
fill: #ffc107;
|
||||
}
|
||||
.info-symbol {
|
||||
fill: white;
|
||||
font-size: 60px;
|
||||
font-weight: bold;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
}
|
||||
.complaint-summary {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body p-5 text-center">
|
||||
<!-- Info Icon -->
|
||||
<svg class="info-icon" viewBox="0 0 100 100">
|
||||
<circle class="info-circle" cx="50" cy="50" r="50"/>
|
||||
<text class="info-symbol" x="50" y="55">i</text>
|
||||
</svg>
|
||||
|
||||
<h2 class="mb-3 text-warning">{% trans "Already Submitted" %}</h2>
|
||||
|
||||
<p class="text-muted mb-4">
|
||||
{% trans "This explanation link has already been used. Each explanation link can only be used once." %}
|
||||
</p>
|
||||
|
||||
<div class="complaint-summary text-start mb-4">
|
||||
<h5 class="mb-3">{% trans "Complaint Information" %}</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-2">
|
||||
<strong>{% trans "Reference:" %}</strong> #{{ complaint.id }}
|
||||
</div>
|
||||
<div class="col-md-6 mb-2">
|
||||
<strong>{% trans "Title:" %}</strong> {{ complaint.title }}
|
||||
</div>
|
||||
{% if explanation.responded_at %}
|
||||
<div class="col-md-6 mb-2">
|
||||
<strong>{% trans "Submitted On:" %}</strong> {{ explanation.responded_at|date:"Y-m-d H:i" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if explanation.staff %}
|
||||
<div class="col-md-6 mb-2">
|
||||
<strong>{% trans "Submitted By:" %}</strong> {{ explanation.staff }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<strong>{% trans "What If You Need To Update?" %}</strong>
|
||||
<p class="mb-0 mt-2">
|
||||
{% trans "If you need to provide additional information or make changes to your explanation, please contact the PX team directly." %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="text-muted small">
|
||||
<p class="mb-2"><strong>{% trans "Explanation ID:" %}</strong> {{ explanation.id }}</p>
|
||||
<p class="mb-2"><strong>{% trans "Status:" %}</strong> {% trans "Already Submitted" %}</p>
|
||||
<p class="mb-0">{% trans "This link cannot be used again." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-muted text-center py-3">
|
||||
<small>{% trans "PX360 Complaint Management System" %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
||||
</body>
|
||||
</html>
|
||||
156
templates/complaints/explanation_form.html
Normal file
156
templates/complaints/explanation_form.html
Normal file
@ -0,0 +1,156 @@
|
||||
{% load i18n %}
|
||||
<!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>{% trans "Submit Explanation" %} - PX360</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
|
||||
}
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 15px 15px 0 0 !important;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||
}
|
||||
.complaint-details {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header py-4">
|
||||
<h3 class="mb-0 text-center">
|
||||
<i class="bi bi-chat-quote"></i>
|
||||
{% trans "Submit Your Explanation" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="complaint-details mb-4">
|
||||
<h5 class="mb-3">{% trans "Complaint Details" %}</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-2">
|
||||
<strong>{% trans "Reference:" %}</strong> #{{ complaint.id }}
|
||||
</div>
|
||||
<div class="col-md-6 mb-2">
|
||||
<strong>{% trans "Title:" %}</strong> {{ complaint.title }}
|
||||
</div>
|
||||
<div class="col-md-6 mb-2">
|
||||
<strong>{% trans "Severity:" %}</strong>
|
||||
<span class="badge bg-{{ complaint.get_severity_badge_class }}">
|
||||
{{ complaint.get_severity_display }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-6 mb-2">
|
||||
<strong>{% trans "Priority:" %}</strong>
|
||||
<span class="badge bg-{{ complaint.get_priority_badge_class }}">
|
||||
{{ complaint.get_priority_display }}
|
||||
</span>
|
||||
</div>
|
||||
{% if complaint.patient %}
|
||||
<div class="col-12 mb-2">
|
||||
<strong>{% trans "Patient:" %}</strong> {{ complaint.patient.get_full_name }} (MRN: {{ complaint.patient.mrn }})
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-12 mt-3">
|
||||
<strong>{% trans "Description:" %}</strong>
|
||||
<p class="mt-1 mb-0">{{ complaint.description|linebreaks }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="explanation" class="form-label">
|
||||
<strong>{% trans "Your Explanation" %} *</strong>
|
||||
</label>
|
||||
<p class="text-muted small">
|
||||
{% trans "Please provide your perspective about the complaint mentioned above. Your explanation will help us understand the situation better." %}
|
||||
</p>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="explanation"
|
||||
name="explanation"
|
||||
rows="8"
|
||||
required
|
||||
placeholder="{% trans 'Write your explanation here...' %}"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="attachments" class="form-label">
|
||||
<strong>{% trans "Attachments (Optional)" %}</strong>
|
||||
</label>
|
||||
<p class="text-muted small">
|
||||
{% trans "You can attach relevant documents, images, or other files to support your explanation." %}
|
||||
</p>
|
||||
<input
|
||||
class="form-control"
|
||||
type="file"
|
||||
id="attachments"
|
||||
name="attachments"
|
||||
multiple
|
||||
>
|
||||
<div class="form-text">
|
||||
{% trans "Accepted file types: PDF, DOC, DOCX, JPG, PNG, etc. Maximum file size: 10MB." %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<strong>{% trans "Important Note:" %}</strong>
|
||||
{% trans "This link can only be used once. After submitting your explanation, it will expire and cannot be used again." %}
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-send"></i>
|
||||
{% trans "Submit Explanation" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer text-muted text-center py-3">
|
||||
<small>{% trans "PX360 Complaint Management System" %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
||||
</body>
|
||||
</html>
|
||||
111
templates/complaints/explanation_success.html
Normal file
111
templates/complaints/explanation_success.html
Normal file
@ -0,0 +1,111 @@
|
||||
{% load i18n %}
|
||||
<!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>{% trans "Explanation Submitted" %} - PX360</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
|
||||
}
|
||||
.success-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
.success-circle {
|
||||
fill: #28a745;
|
||||
}
|
||||
.checkmark {
|
||||
fill: none;
|
||||
stroke: white;
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.complaint-summary {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body p-5 text-center">
|
||||
<!-- Success Icon -->
|
||||
<svg class="success-icon" viewBox="0 0 100 100">
|
||||
<circle class="success-circle" cx="50" cy="50" r="50"/>
|
||||
<path class="checkmark" d="M30 50 L45 65 L70 35"/>
|
||||
</svg>
|
||||
|
||||
<h2 class="mb-3 text-success">{% trans "Explanation Submitted Successfully!" %}</h2>
|
||||
|
||||
<p class="text-muted mb-4">
|
||||
{% trans "Thank you for providing your explanation. It has been received and will be reviewed by the PX team." %}
|
||||
</p>
|
||||
|
||||
<div class="complaint-summary text-start mb-4">
|
||||
<h5 class="mb-3">{% trans "Complaint Summary" %}</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-2">
|
||||
<strong>{% trans "Reference:" %}</strong> #{{ complaint.id }}
|
||||
</div>
|
||||
<div class="col-md-6 mb-2">
|
||||
<strong>{% trans "Title:" %}</strong> {{ complaint.title }}
|
||||
</div>
|
||||
<div class="col-md-6 mb-2">
|
||||
<strong>{% trans "Submitted On:" %}</strong> {{ explanation.responded_at|date:"Y-m-d H:i" }}
|
||||
</div>
|
||||
{% if attachment_count > 0 %}
|
||||
<div class="col-md-6 mb-2">
|
||||
<strong>{% trans "Attachments:" %}</strong> {{ attachment_count }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<strong>{% trans "What Happens Next?" %}</strong>
|
||||
<ul class="mb-0 mt-2" style="text-align: {% if LANGUAGE_CODE == 'ar' %}right{% else %}left{% endif %}; padding-inline-start: 20px;">
|
||||
<li>{% trans "Your explanation will be reviewed by the complaint assignee" %}</li>
|
||||
<li>{% trans "The PX team may contact you if additional information is needed" %}</li>
|
||||
<li>{% trans "Your explanation will be considered during the complaint investigation" %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="text-muted small">
|
||||
<p class="mb-2"><strong>{% trans "Explanation ID:" %}</strong> {{ explanation.id }}</p>
|
||||
<p class="mb-2"><strong>{% trans "Submission Time:" %}</strong> {{ explanation.responded_at|date:"Y-m-d H:i:s" }}</p>
|
||||
<p class="mb-0">{% trans "A confirmation email has been sent to the complaint assignee." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-muted text-center py-3">
|
||||
<small>{% trans "PX360 Complaint Management System" %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
||||
</body>
|
||||
</html>
|
||||
193
templates/emails/explanation_request.html
Normal file
193
templates/emails/explanation_request.html
Normal file
@ -0,0 +1,193 @@
|
||||
{% load i18n %}
|
||||
<!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>{% trans "Explanation Request" %}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
.content {
|
||||
padding: 30px;
|
||||
}
|
||||
.complaint-box {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #667eea;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.complaint-box h3 {
|
||||
margin-top: 0;
|
||||
color: #667eea;
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
min-width: 100px;
|
||||
color: #555;
|
||||
}
|
||||
.info-value {
|
||||
flex: 1;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.note {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.footer {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
.custom-message {
|
||||
background: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.attachment-info {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.attachment-info i {
|
||||
color: #667eea;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>{% trans "Explanation Request" %}</h1>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>{% trans "Dear" %} {{ staff_name }},</p>
|
||||
|
||||
<p>{% trans "You have been assigned to provide an explanation for the following patient complaint. Please review the details and submit your response using the link below." %}</p>
|
||||
|
||||
{% if custom_message %}
|
||||
<div class="custom-message">
|
||||
<strong>{% trans "Note from PX Team:" %}</strong>
|
||||
<p>{{ custom_message }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="complaint-box">
|
||||
<h3>{% trans "Complaint Details" %}</h3>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{% trans "Reference:" %}</div>
|
||||
<div class="info-value">#{{ complaint_id }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{% trans "Title:" %}</div>
|
||||
<div class="info-value">{{ complaint_title }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{% trans "Patient:" %}</div>
|
||||
<div class="info-value">{{ patient_name }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{% trans "Hospital:" %}</div>
|
||||
<div class="info-value">{{ hospital_name }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{% trans "Department:" %}</div>
|
||||
<div class="info-value">{{ department_name }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{% trans "Category:" %}</div>
|
||||
<div class="info-value">{{ category }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{% trans "Status:" %}</div>
|
||||
<div class="info-value">{{ status }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{% trans "Date:" %}</div>
|
||||
<div class="info-value">{{ created_date }}</div>
|
||||
</div>
|
||||
|
||||
{% if description %}
|
||||
<div class="info-row" style="display: block;">
|
||||
<div class="info-label">{% trans "Description:" %}</div>
|
||||
<div class="info-value" style="margin-top: 5px;">{{ description }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="{{ explanation_url }}" class="button">{% trans "Submit Your Explanation" %}</a>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<strong>{% trans "Important Information:" %}</strong>
|
||||
<ul style="margin-top: 10px; padding-left: 20px;">
|
||||
<li>{% trans "This link is unique and can only be used once" %}</li>
|
||||
<li>{% trans "You can attach supporting documents to your explanation" %}</li>
|
||||
<li>{% trans "Your response will be reviewed by the PX team" %}</li>
|
||||
<li>{% trans "Please submit your explanation at your earliest convenience" %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>{% trans "If you have any questions or concerns, please contact the PX team directly." %}</p>
|
||||
|
||||
<p>{% trans "Thank you for your cooperation." %}</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>PX360 Complaint Management System</strong></p>
|
||||
<p>{% trans "This is an automated email. Please do not reply directly to this message." %}</p>
|
||||
<p>{% trans "If you need assistance, contact your PX administrator." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user