Compare commits

...

1 Commits

Author SHA1 Message Date
d8577e44f7 temp commit 2026-01-11 01:17:24 +03:00
15 changed files with 2395 additions and 65 deletions

View File

@ -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'),
),
]

View File

@ -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}"

View File

@ -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)),
]

View File

@ -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)

View File

@ -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',)}),

View 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),
),
]

View File

@ -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

View 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

View File

@ -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",

View File

@ -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;

View 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>

View 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>

View 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>

View 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>

View 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>