Merge remote-tracking branch 'origin/main'

This commit is contained in:
Marwan Alwali 2026-02-25 21:20:10 +03:00
commit fa966a3574
85 changed files with 2564761 additions and 4888525 deletions

View File

@ -0,0 +1 @@
,ismail,ismail-Latitude-5500,25.02.2026 04:28,/home/ismail/.local/share/onlyoffice;

5001
CALLS..csv Normal file

File diff suppressed because it is too large Load Diff

View File

@ -160,6 +160,18 @@ class KPIReport(UUIDModel, TimeStampedModel):
# Error tracking # Error tracking
error_message = models.TextField(blank=True) error_message = models.TextField(blank=True)
# AI-generated analysis
ai_analysis = models.JSONField(
null=True,
blank=True,
help_text=_("AI-generated analysis and recommendations for this report")
)
ai_analysis_generated_at = models.DateTimeField(
null=True,
blank=True,
help_text=_("When the AI analysis was generated")
)
class Meta: class Meta:
ordering = ["-year", "-month", "report_type"] ordering = ["-year", "-month", "report_type"]
unique_together = [["report_type", "hospital", "year", "month"]] unique_together = [["report_type", "hospital", "year", "month"]]

File diff suppressed because it is too large Load Diff

View File

@ -5,13 +5,17 @@ Views for listing, viewing, and generating KPI reports.
Follows the PX360 UI patterns with Tailwind, Lucide icons, and HTMX. Follows the PX360 UI patterns with Tailwind, Lucide icons, and HTMX.
""" """
import json import json
import logging
from datetime import datetime from datetime import datetime
from django.contrib import messages from django.contrib import messages
logger = logging.getLogger(__name__)
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
@ -21,6 +25,7 @@ from .kpi_models import KPIReport, KPIReportStatus, KPIReportType
from .kpi_service import KPICalculationService from .kpi_service import KPICalculationService
@login_required @login_required
def kpi_report_list(request): def kpi_report_list(request):
""" """
@ -442,3 +447,129 @@ def kpi_report_api_data(request, report_id):
} }
return JsonResponse(data) return JsonResponse(data)
@login_required
def kpi_report_ai_analysis(request, report_id):
"""
Generate or retrieve AI analysis for a KPI report.
GET: Retrieve existing AI analysis
POST: Generate new AI analysis
"""
from django.http import JsonResponse
from .kpi_service import KPICalculationService
user = request.user
report = get_object_or_404(
KPIReport.objects.select_related('hospital', 'generated_by'),
id=report_id
)
# Check permissions
if not user.is_px_admin() and user.hospital != report.hospital:
return JsonResponse({'error': 'Permission denied'}, status=403)
if request.method == 'GET':
# Return existing analysis
if report.ai_analysis:
return JsonResponse({
'success': True,
'analysis': report.ai_analysis,
'generated_at': report.ai_analysis_generated_at.isoformat() if report.ai_analysis_generated_at else None
})
else:
return JsonResponse({
'success': False,
'message': 'No AI analysis available. Use POST to generate.'
}, status=404)
elif request.method == 'POST':
# Generate new analysis
try:
analysis = KPICalculationService.generate_ai_analysis(report)
if 'error' in analysis:
return JsonResponse({
'success': False,
'error': analysis['error']
}, status=500)
return JsonResponse({
'success': True,
'analysis': analysis,
'generated_at': report.ai_analysis_generated_at.isoformat() if report.ai_analysis_generated_at else None
})
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
return JsonResponse({'error': 'Method not allowed'}, status=405)
@login_required
def kpi_report_save_analysis(request, report_id):
"""
Save edited AI analysis for a KPI report.
POST: Save edited analysis JSON
"""
import json
from django.http import JsonResponse
user = request.user
report = get_object_or_404(KPIReport, id=report_id)
# Check permissions - only PX admins and hospital admins can edit
if not user.is_px_admin() and user.hospital != report.hospital:
return JsonResponse({'error': 'Permission denied'}, status=403)
if request.method != 'POST':
return JsonResponse({'error': 'Method not allowed'}, status=405)
try:
# Parse the edited analysis from request body
body = json.loads(request.body)
edited_analysis = body.get('analysis')
if not edited_analysis:
return JsonResponse({'error': 'No analysis data provided'}, status=400)
# Preserve metadata if it exists
if report.ai_analysis and '_metadata' in report.ai_analysis:
edited_analysis['_metadata'] = report.ai_analysis['_metadata']
edited_analysis['_metadata']['last_edited_at'] = timezone.now().isoformat()
edited_analysis['_metadata']['last_edited_by'] = user.get_full_name() or user.email
else:
edited_analysis['_metadata'] = {
'generated_at': timezone.now().isoformat(),
'report_id': str(report.id),
'report_type': report.report_type,
'hospital': report.hospital.name,
'year': report.year,
'month': report.month,
'last_edited_at': timezone.now().isoformat(),
'last_edited_by': user.get_full_name() or user.email
}
# Save to report
report.ai_analysis = edited_analysis
report.save(update_fields=['ai_analysis'])
logger.info(f"AI analysis edited for KPI report {report.id} by user {user.id}")
return JsonResponse({
'success': True,
'message': 'Analysis saved successfully'
})
except json.JSONDecodeError:
return JsonResponse({'error': 'Invalid JSON data'}, status=400)
except Exception as e:
logger.exception(f"Error saving AI analysis for report {report.id}: {e}")
return JsonResponse({'error': str(e)}, status=500)

View File

@ -21,4 +21,6 @@ urlpatterns = [
path('kpi-reports/<uuid:report_id>/pdf/', kpi_views.kpi_report_pdf, name='kpi_report_pdf'), path('kpi-reports/<uuid:report_id>/pdf/', kpi_views.kpi_report_pdf, name='kpi_report_pdf'),
path('kpi-reports/<uuid:report_id>/regenerate/', kpi_views.kpi_report_regenerate, name='kpi_report_regenerate'), path('kpi-reports/<uuid:report_id>/regenerate/', kpi_views.kpi_report_regenerate, name='kpi_report_regenerate'),
path('api/kpi-reports/<uuid:report_id>/data/', kpi_views.kpi_report_api_data, name='kpi_report_api_data'), path('api/kpi-reports/<uuid:report_id>/data/', kpi_views.kpi_report_api_data, name='kpi_report_api_data'),
path('api/kpi-reports/<uuid:report_id>/ai-analysis/', kpi_views.kpi_report_ai_analysis, name='kpi_report_ai_analysis'),
path('api/kpi-reports/<uuid:report_id>/ai-analysis/save/', kpi_views.kpi_report_save_analysis, name='kpi_report_save_analysis'),
] ]

View File

@ -132,3 +132,98 @@ class CallCenterInteraction(UUIDModel, TimeStampedModel):
if self.satisfaction_rating and self.satisfaction_rating < 3: if self.satisfaction_rating and self.satisfaction_rating < 3:
self.is_low_rating = True self.is_low_rating = True
super().save(*args, **kwargs) super().save(*args, **kwargs)
class CallRecord(UUIDModel, TimeStampedModel):
"""
Call Record - Tracks call center recordings imported from CSV.
Stores all call metadata from the call recording system including:
- Call details (start, end, duration)
- Caller information
- Department and extension
- Recording file information
- Inbound/Outbound call details
"""
# Core identifiers
media_id = models.UUIDField(unique=True, db_index=True, help_text="Unique media ID from recording system")
media_type = models.CharField(max_length=50, default="Calls", help_text="Type of media (e.g., Calls)")
chain = models.CharField(max_length=255, blank=True, help_text="Chain identifier")
evaluated = models.BooleanField(default=False, help_text="Whether the call has been evaluated")
# Call timing
call_start = models.DateTimeField(help_text="Call start time")
call_end = models.DateTimeField(null=True, blank=True, help_text="Call end time")
call_length = models.CharField(max_length=20, blank=True, help_text="Call length as HH:MM:SS")
call_duration_seconds = models.IntegerField(null=True, blank=True, help_text="Call duration in seconds")
# Caller information
first_name = models.CharField(max_length=100, blank=True, help_text="Caller first name")
last_name = models.CharField(max_length=100, blank=True, help_text="Caller last name")
# Department information
extension = models.CharField(max_length=20, blank=True, help_text="Extension number")
department = models.CharField(max_length=255, blank=True, help_text="Department name")
location = models.CharField(max_length=255, blank=True, help_text="Location")
# Inbound call details
inbound_id = models.CharField(max_length=50, blank=True, help_text="Inbound call ID")
inbound_name = models.CharField(max_length=255, blank=True, help_text="Inbound caller name/number")
dnis = models.CharField(max_length=50, blank=True, help_text="Dialed Number Identification Service")
# Outbound call details
outbound_id = models.CharField(max_length=50, blank=True, help_text="Outbound call ID")
outbound_name = models.CharField(max_length=255, blank=True, help_text="Outbound caller name/number")
# Flag information
flag_name = models.CharField(max_length=100, blank=True, help_text="Flag name")
flag_value = models.CharField(max_length=100, blank=True, help_text="Flag value")
# Recording file information
file_location = models.CharField(max_length=500, blank=True, help_text="File system location")
file_name = models.CharField(max_length=500, blank=True, help_text="Recording file name")
file_hash = models.CharField(max_length=64, blank=True, help_text="File hash for integrity")
# Additional metadata
external_ref = models.CharField(max_length=100, blank=True, help_text="External reference number")
transfer_from = models.CharField(max_length=255, blank=True, help_text="Transfer source")
recorded_by = models.CharField(max_length=255, blank=True, help_text="Recording system/user")
time_zone = models.CharField(max_length=50, default="03:00:00", help_text="Time zone offset")
recording_server_name = models.CharField(max_length=100, blank=True, help_text="Recording server name")
# Hospital link (for multi-hospital systems)
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='call_records'
)
class Meta:
ordering = ['-call_start']
indexes = [
models.Index(fields=['-call_start']),
models.Index(fields=['media_id']),
models.Index(fields=['department']),
models.Index(fields=['evaluated']),
models.Index(fields=['hospital', '-call_start']),
]
def __str__(self):
return f"{self.first_name} {self.last_name} - {self.call_start.strftime('%Y-%m-%d %H:%M')}"
@property
def caller_full_name(self):
"""Get full caller name"""
return f"{self.first_name} {self.last_name}".strip()
@property
def is_inbound(self):
"""Check if call is inbound"""
return bool(self.inbound_id or self.inbound_name)
@property
def is_outbound(self):
"""Check if call is outbound"""
return bool(self.outbound_id or self.outbound_name)

View File

@ -9,13 +9,14 @@ from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from uuid import UUID
from apps.complaints.models import Complaint, Inquiry from apps.complaints.models import Complaint, Inquiry
from apps.px_sources.models import PXSource from apps.px_sources.models import PXSource
from apps.core.services import AuditService from apps.core.services import AuditService
from apps.organizations.models import Department, Hospital, Patient, Staff from apps.organizations.models import Department, Hospital, Patient, Staff
from .models import CallCenterInteraction from .models import CallCenterInteraction, CallRecord
@login_required @login_required
@ -587,3 +588,366 @@ def search_patients(request):
return JsonResponse({'patients': results}) return JsonResponse({'patients': results})
# ============================================================================
# CALL RECORDS (CSV IMPORT) VIEWS
# ============================================================================
@login_required
def call_records_list(request):
"""
Call records list view with stats cards.
Shows all imported call records with filtering and search.
"""
queryset = CallRecord.objects.select_related('hospital')
# Apply RBAC filters
user = request.user
if user.is_px_admin():
pass
elif user.hospital:
queryset = queryset.filter(hospital=user.hospital)
else:
queryset = queryset.none()
# Apply filters
evaluated_filter = request.GET.get('evaluated')
if evaluated_filter:
queryset = queryset.filter(evaluated=evaluated_filter == 'true')
call_type_filter = request.GET.get('call_type')
if call_type_filter == 'inbound':
queryset = queryset.filter(Q(inbound_id__isnull=False) | Q(inbound_name__isnull=False))
queryset = queryset.exclude(Q(inbound_id='') & Q(inbound_name=''))
elif call_type_filter == 'outbound':
queryset = queryset.filter(Q(outbound_id__isnull=False) | Q(outbound_name__isnull=False))
queryset = queryset.exclude(Q(outbound_id='') & Q(outbound_name=''))
department_filter = request.GET.get('department')
if department_filter:
queryset = queryset.filter(department__icontains=department_filter)
hospital_filter = request.GET.get('hospital')
if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter)
# Search
search_query = request.GET.get('search')
if search_query:
queryset = queryset.filter(
Q(first_name__icontains=search_query) |
Q(last_name__icontains=search_query) |
Q(department__icontains=search_query) |
Q(extension__icontains=search_query) |
Q(inbound_name__icontains=search_query) |
Q(outbound_name__icontains=search_query)
)
# Date range
date_from = request.GET.get('date_from')
if date_from:
queryset = queryset.filter(call_start__gte=date_from)
date_to = request.GET.get('date_to')
if date_to:
queryset = queryset.filter(call_start__lte=date_to)
# Ordering
queryset = queryset.order_by('-call_start')
# Pagination
page_size = int(request.GET.get('page_size', 25))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get('page', 1)
page_obj = paginator.get_page(page_number)
# Get filter options
hospitals = Hospital.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
# Statistics for cards
stats = {
'total_calls': queryset.count(),
'total_duration': sum(
(r.call_duration_seconds or 0) for r in queryset
),
'inbound_calls': queryset.filter(
Q(inbound_id__isnull=False) | Q(inbound_name__isnull=False)
).exclude(Q(inbound_id='') & Q(inbound_name='')).count(),
'outbound_calls': queryset.filter(
Q(outbound_id__isnull=False) | Q(outbound_name__isnull=False)
).exclude(Q(outbound_id='') & Q(outbound_name='')).count(),
'evaluated_calls': queryset.filter(evaluated=True).count(),
'not_evaluated_calls': queryset.filter(evaluated=False).count(),
'avg_duration': queryset.filter(
call_duration_seconds__isnull=False
).aggregate(avg=Avg('call_duration_seconds'))['avg'] or 0,
}
# Format duration for display
def format_duration(seconds):
if not seconds:
return "0:00"
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
if hours > 0:
return f"{hours}:{minutes:02d}:{secs:02d}"
return f"{minutes}:{secs:02d}"
stats['total_duration_formatted'] = format_duration(stats['total_duration'])
stats['avg_duration_formatted'] = format_duration(stats['avg_duration'])
# Get unique departments for filter dropdown
departments = CallRecord.objects.values_list('department', flat=True).distinct()
departments = [d for d in departments if d]
context = {
'page_obj': page_obj,
'call_records': page_obj.object_list,
'stats': stats,
'hospitals': hospitals,
'departments': departments,
'filters': request.GET,
}
return render(request, 'callcenter/call_records_list.html', context)
@login_required
@require_http_methods(["GET", "POST"])
def import_call_records(request):
"""
Import call records from CSV file.
CSV must have the same headers as the export format.
"""
if request.method == 'POST':
try:
csv_file = request.FILES.get('csv_file')
if not csv_file:
messages.error(request, "Please select a CSV file to upload.")
return redirect('callcenter:import_call_records')
# Check file extension
if not csv_file.name.endswith('.csv'):
messages.error(request, "Please upload a valid CSV file.")
return redirect('callcenter:import_call_records')
import csv
from datetime import datetime
import hashlib
import codecs
# Decode the file and remove BOM if present
decoded_file = csv_file.read().decode('utf-8-sig') # utf-8-sig removes BOM
reader = csv.DictReader(decoded_file.splitlines())
# Required headers
required_headers = [
'Media ID', 'Media Type', 'Call Start', 'First Name', 'Last Name',
'Extension', 'Department', 'Call End', 'Length', 'File Name'
]
# Validate headers
if reader.fieldnames is None:
messages.error(request, "Invalid CSV file format.")
return redirect('callcenter:import_call_records')
# Clean headers (remove any remaining BOM or whitespace)
cleaned_fieldnames = [f.strip() if f else f for f in reader.fieldnames]
reader.fieldnames = cleaned_fieldnames
missing_headers = [h for h in required_headers if h not in reader.fieldnames]
if missing_headers:
messages.error(request, f"Missing required headers: {', '.join(missing_headers)}")
return redirect('callcenter:import_call_records')
# Parse CSV and create records
imported_count = 0
skipped_count = 0
error_count = 0
for row_num, row in enumerate(reader, start=2): # Start at 2 (header is row 1)
try:
# Parse Media ID
media_id_str = row.get('Media ID', '').strip()
if not media_id_str:
skipped_count += 1
continue
try:
media_id = UUID(media_id_str)
except ValueError:
skipped_count += 1
continue
# Check if record already exists
if CallRecord.objects.filter(media_id=media_id).exists():
skipped_count += 1
continue
# Parse call start time
call_start_str = row.get('Call Start', '').strip()
if not call_start_str:
skipped_count += 1
continue
# Try multiple datetime formats
call_start = None
for fmt in [
'%m/%d/%y %H:%M', # 10/30/25 19:57
'%m/%d/%Y %H:%M', # 10/30/2025 19:57
'%m/%d/%y %I:%M:%S %p', # 10/30/25 7:57:48 PM
'%m/%d/%Y %I:%M:%S %p', # 10/30/2025 7:57:48 PM
'%m/%d/%y %I:%M %p', # 10/30/25 7:57 PM
'%m/%d/%Y %I:%M %p', # 10/30/2025 7:57 PM
]:
try:
call_start = datetime.strptime(call_start_str, fmt)
break
except ValueError:
continue
if not call_start:
skipped_count += 1
continue
# Parse call end time
call_end = None
call_end_str = row.get('Call End', '').strip()
if call_end_str:
for fmt in [
'%m/%d/%y %H:%M',
'%m/%d/%Y %H:%M',
'%m/%d/%y %I:%M:%S %p',
'%m/%d/%Y %I:%M:%S %p',
'%m/%d/%y %I:%M %p',
'%m/%d/%Y %I:%M %p',
]:
try:
call_end = datetime.strptime(call_end_str, fmt)
break
except ValueError:
continue
# Parse call duration
call_duration_seconds = None
length_str = row.get('Length', '').strip()
if length_str:
try:
parts = length_str.split(':')
if len(parts) == 3:
# HH:MM:SS format
call_duration_seconds = int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
elif len(parts) == 2:
# M:SS or MM:SS format
call_duration_seconds = int(parts[0]) * 60 + int(parts[1])
elif len(parts) == 1:
# Just seconds
call_duration_seconds = int(parts[0])
except ValueError:
pass
# Get or create hospital
hospital = None
if request.user.hospital:
hospital = request.user.hospital
# Create the record
CallRecord.objects.create(
media_id=media_id,
media_type=row.get('Media Type', 'Calls').strip(),
chain=row.get('Chain', '').strip(),
evaluated=row.get('Evaluated', '').strip().lower() == 'true',
call_start=call_start,
call_end=call_end,
call_length=length_str,
call_duration_seconds=call_duration_seconds,
first_name=row.get('First Name', '').strip(),
last_name=row.get('Last Name', '').strip(),
extension=row.get('Extension', '').strip(),
department=row.get('Department', '').strip(),
location=row.get('Location', '').strip(),
inbound_id=row.get('Inbound ID', '').strip(),
inbound_name=row.get('Inbound Name', '').strip(),
dnis=row.get('DNIS', '').strip(),
outbound_id=row.get('Outbound ID', '').strip(),
outbound_name=row.get('Outbound Name', '').strip(),
flag_name=row.get('Flag Name', '').strip(),
flag_value=row.get('Flag Value', '').strip(),
file_location=row.get('File Location', '').strip(),
file_name=row.get('File Name', '').strip(),
file_hash=row.get('FileHash', '').strip(),
external_ref=row.get('External Ref', '').strip(),
transfer_from=row.get('Transfer From', '').strip(),
recorded_by=row.get('Recorded By', '').strip(),
time_zone=row.get('Time Zone', '03:00:00').strip(),
recording_server_name=row.get('Recording Server Name', '').strip(),
hospital=hospital,
)
imported_count += 1
except Exception as e:
error_count += 1
continue
messages.success(
request,
f"Import completed: {imported_count} records imported, {skipped_count} skipped (duplicates/invalid), {error_count} errors."
)
return redirect('callcenter:call_records_list')
except Exception as e:
messages.error(request, f"Error importing CSV: {str(e)}")
return redirect('callcenter:import_call_records')
# GET request - show upload form
context = {
'sample_headers': [
'Media ID', 'Media Type', 'Chain', 'Evaluated', 'Call Start',
'First Name', 'Last Name', 'Extension', 'Department', 'Location',
'Inbound ID', 'Inbound Name', 'DNIS', 'Outbound ID', 'Outbound Name',
'Length', 'Call End', 'Flag Name', 'Flag Value', 'File Location',
'File Name', 'External Ref', 'FileHash', 'Transfer From', 'Recorded By',
'Time Zone', 'Recording Server Name'
],
}
return render(request, 'callcenter/import_call_records.html', context)
@login_required
def export_call_records_template(request):
"""
Export a sample CSV template for importing call records.
"""
import csv
from django.http import HttpResponse
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="call_records_template.csv"'
writer = csv.writer(response)
writer.writerow([
'Media ID', 'Media Type', 'Chain', 'Evaluated', 'Call Start',
'First Name', 'Last Name', 'Extension', 'Department', 'Location',
'Inbound ID', 'Inbound Name', 'DNIS', 'Outbound ID', 'Outbound Name',
'Length', 'Call End', 'Flag Name', 'Flag Value', 'File Location',
'File Name', 'External Ref', 'FileHash', 'Transfer From', 'Recorded By',
'Time Zone', 'Recording Server Name'
])
# Add one sample row
writer.writerow([
'aade2430-2eb0-4e05-93eb-9567e2be07ae', 'Calls', '', 'False',
'10/30/2025 7:57:48 PM', 'Patient', 'Relation', '1379', 'Patient Relation', '',
'597979769', '', '', '', '', '00:01:11', '10/30/2025 7:59:00 PM',
'', '', 'E:\\Calls', '2025-10-30\\x1379 19.57.48.467 10-30-2025.mp3',
'12946311', '', '0', '', '03:00:00', 'ahnuzdcnqms02'
])
return response

View File

@ -18,6 +18,11 @@ urlpatterns = [
path('inquiries/create/', ui_views.create_inquiry, name='create_inquiry'), path('inquiries/create/', ui_views.create_inquiry, name='create_inquiry'),
path('inquiries/<uuid:pk>/success/', ui_views.inquiry_success, name='inquiry_success'), path('inquiries/<uuid:pk>/success/', ui_views.inquiry_success, name='inquiry_success'),
# Call Records (CSV Import)
path('records/', ui_views.call_records_list, name='call_records_list'),
path('records/import/', ui_views.import_call_records, name='import_call_records'),
path('records/export-template/', ui_views.export_call_records_template, name='export_call_records_template'),
# AJAX Helpers # AJAX Helpers
path('ajax/departments/', ui_views.get_departments_by_hospital, name='ajax_departments'), path('ajax/departments/', ui_views.get_departments_by_hospital, name='ajax_departments'),
path('ajax/physicians/', ui_views.get_staff_by_hospital, name='ajax_physicians'), path('ajax/physicians/', ui_views.get_staff_by_hospital, name='ajax_physicians'),

View File

@ -40,6 +40,14 @@ class ResolutionCategory(models.TextChoices):
PATIENT_WITHDRAWN = "patient_withdrawn", "Patient Withdrawn" PATIENT_WITHDRAWN = "patient_withdrawn", "Patient Withdrawn"
class ResolutionOutcome(models.TextChoices):
"""Resolution outcome - who was in wrong/right"""
PATIENT = "patient", "Patient"
HOSPITAL = "hospital", "Hospital"
OTHER = "other", "Other — please specify"
class ComplaintType(models.TextChoices): class ComplaintType(models.TextChoices):
"""Complaint type choices - distinguish between complaints and appreciations""" """Complaint type choices - distinguish between complaints and appreciations"""
@ -378,6 +386,17 @@ class Complaint(UUIDModel, TimeStampedModel):
db_index=True, db_index=True,
help_text="Category of resolution" help_text="Category of resolution"
) )
resolution_outcome = models.CharField(
max_length=20,
choices=ResolutionOutcome.choices,
blank=True,
db_index=True,
help_text="Who was in wrong/right (Patient / Hospital / Other)"
)
resolution_outcome_other = models.TextField(
blank=True,
help_text="Specify if Other was selected for resolution outcome"
)
resolved_at = models.DateTimeField(null=True, blank=True) resolved_at = models.DateTimeField(null=True, blank=True)
resolved_by = models.ForeignKey( resolved_by = models.ForeignKey(
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="resolved_complaints" "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="resolved_complaints"
@ -2293,4 +2312,415 @@ class ComplaintAdverseActionAttachment(UUIDModel, TimeStampedModel):
verbose_name_plural = _("Adverse Action Attachments") verbose_name_plural = _("Adverse Action Attachments")
def __str__(self): def __str__(self):
return f"{self.adverse_action} - {self.filename}" return f"{self.adverse_action.reference_number} - {self.filename}"
# ============================================================================
# COMPLAINT TEMPLATES
# ============================================================================
class ComplaintTemplate(UUIDModel, TimeStampedModel):
"""
Pre-defined templates for common complaints.
Allows quick selection of common complaint types with pre-filled
description, category, severity, and auto-assignment.
"""
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
related_name='complaint_templates',
help_text=_("Hospital this template belongs to")
)
name = models.CharField(
max_length=200,
help_text=_("Template name (e.g., 'Long Wait Time', 'Rude Staff')")
)
description = models.TextField(
help_text=_("Default description template with placeholders")
)
# Pre-set classification
category = models.ForeignKey(
ComplaintCategory,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='templates',
help_text=_("Default category for this template")
)
# Default severity/priority
default_severity = models.CharField(
max_length=20,
choices=SeverityChoices.choices,
default=SeverityChoices.MEDIUM,
help_text=_("Default severity level")
)
default_priority = models.CharField(
max_length=20,
choices=PriorityChoices.choices,
default=PriorityChoices.MEDIUM,
help_text=_("Default priority level")
)
# Auto-assignment
auto_assign_department = models.ForeignKey(
'organizations.Department',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='template_assignments',
help_text=_("Auto-assign to this department when template is used")
)
# Usage tracking
usage_count = models.IntegerField(
default=0,
editable=False,
help_text=_("Number of times this template has been used")
)
# Placeholders that can be used in description
# e.g., "Patient waited for {{wait_time}} minutes"
placeholders = models.JSONField(
default=list,
blank=True,
help_text=_("List of placeholder names used in description")
)
is_active = models.BooleanField(
default=True,
db_index=True,
help_text=_("Whether this template is available for selection")
)
class Meta:
ordering = ['-usage_count', 'name']
verbose_name = _('Complaint Template')
verbose_name_plural = _('Complaint Templates')
unique_together = [['hospital', 'name']]
indexes = [
models.Index(fields=['hospital', 'is_active']),
models.Index(fields=['-usage_count']),
]
def __str__(self):
return f"{self.hospital.name} - {self.name} ({self.usage_count} uses)"
def use_template(self):
"""Increment usage count"""
self.usage_count += 1
self.save(update_fields=['usage_count'])
def render_description(self, placeholder_values):
"""
Render description with placeholder values.
Args:
placeholder_values: Dict of placeholder name -> value
Returns:
Rendered description string
"""
description = self.description
for key, value in placeholder_values.items():
description = description.replace(f'{{{{{key}}}}}', str(value))
return description
# ============================================================================
# COMMUNICATION LOG
# ============================================================================
class ComplaintCommunicationType(models.TextChoices):
"""Types of communication"""
PHONE_CALL = 'phone_call', 'Phone Call'
EMAIL = 'email', 'Email'
SMS = 'sms', 'SMS'
MEETING = 'meeting', 'Meeting'
LETTER = 'letter', 'Letter'
OTHER = 'other', 'Other'
class ComplaintCommunication(UUIDModel, TimeStampedModel):
"""
Tracks all communications related to a complaint.
Records phone calls, emails, meetings, and other communications
with complainants, involved staff, or other stakeholders.
"""
complaint = models.ForeignKey(
Complaint,
on_delete=models.CASCADE,
related_name='communications',
help_text=_("Related complaint")
)
# Communication details
communication_type = models.CharField(
max_length=20,
choices=ComplaintCommunicationType.choices,
help_text=_("Type of communication")
)
direction = models.CharField(
max_length=20,
choices=[
('inbound', 'Inbound'),
('outbound', 'Outbound'),
],
help_text=_("Direction of communication")
)
# Participants
contacted_person = models.CharField(
max_length=200,
help_text=_("Name of person contacted")
)
contacted_role = models.CharField(
max_length=100,
blank=True,
help_text=_("Role/relation (e.g., Complainant, Patient, Staff)")
)
contacted_phone = models.CharField(
max_length=20,
blank=True,
help_text=_("Phone number")
)
contacted_email = models.EmailField(
blank=True,
help_text=_("Email address")
)
# Communication content
subject = models.CharField(
max_length=500,
blank=True,
help_text=_("Subject/summary of communication")
)
notes = models.TextField(
help_text=_("Details of what was discussed")
)
# Follow-up
requires_followup = models.BooleanField(
default=False,
help_text=_("Whether this communication requires follow-up")
)
followup_date = models.DateField(
null=True,
blank=True,
help_text=_("Date when follow-up is needed")
)
followup_notes = models.TextField(
blank=True,
help_text=_("Notes from follow-up")
)
# Attachments (emails, letters, etc.)
attachment = models.FileField(
upload_to='complaints/communications/%Y/%m/%d/',
null=True,
blank=True,
help_text=_("Attached document (email export, letter, etc.)")
)
# Created by
created_by = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
related_name='complaint_communications',
help_text=_("User who logged this communication")
)
class Meta:
ordering = ['-created_at']
verbose_name = _('Complaint Communication')
verbose_name_plural = _('Complaint Communications')
indexes = [
models.Index(fields=['complaint', '-created_at']),
models.Index(fields=['communication_type']),
models.Index(fields=['requires_followup', 'followup_date']),
]
def __str__(self):
return f"{self.complaint.reference_number} - {self.get_communication_type_display()} - {self.contacted_person}"
# ============================================================================
# ROOT CAUSE ANALYSIS (RCA)
# ============================================================================
class RootCauseCategory(models.TextChoices):
"""Root cause categories for RCA"""
PEOPLE = 'people', 'People (Training, Staffing)'
PROCESS = 'process', 'Process/Procedure'
EQUIPMENT = 'equipment', 'Equipment/Technology'
ENVIRONMENT = 'environment', 'Environment/Facility'
COMMUNICATION = 'communication', 'Communication'
POLICY = 'policy', 'Policy/Protocol'
PATIENT_FACTOR = 'patient_factor', 'Patient-Related Factor'
OTHER = 'other', 'Other'
class ComplaintRootCauseAnalysis(UUIDModel, TimeStampedModel):
"""
Root Cause Analysis (RCA) for complaints.
Structured analysis to identify underlying causes and prevent recurrence.
Linked to complaints that require formal investigation.
"""
complaint = models.OneToOneField(
Complaint,
on_delete=models.CASCADE,
related_name='root_cause_analysis',
help_text=_("Related complaint")
)
# RCA Team
team_leader = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='led_rcas',
help_text=_("RCA team leader")
)
team_members = models.TextField(
blank=True,
help_text=_("List of RCA team members (one per line)")
)
# Problem statement
problem_statement = models.TextField(
help_text=_("Clear description of what happened")
)
impact_description = models.TextField(
help_text=_("Impact on patient, organization, etc.")
)
# Root cause categories (can select multiple)
root_cause_categories = models.JSONField(
default=list,
help_text=_("Selected root cause categories")
)
# 5 Whys analysis
why_1 = models.TextField(blank=True, help_text=_("Why did this happen? (Level 1)"))
why_2 = models.TextField(blank=True, help_text=_("Why? (Level 2)"))
why_3 = models.TextField(blank=True, help_text=_("Why? (Level 3)"))
why_4 = models.TextField(blank=True, help_text=_("Why? (Level 4)"))
why_5 = models.TextField(blank=True, help_text=_("Why? (Level 5)"))
# Root cause summary
root_cause_summary = models.TextField(
help_text=_("Summary of identified root causes")
)
# Contributing factors
contributing_factors = models.TextField(
blank=True,
help_text=_("Factors that contributed to the incident")
)
# Corrective and Preventive Actions (CAPA)
corrective_actions = models.TextField(
help_text=_("Actions to correct the immediate issue")
)
preventive_actions = models.TextField(
help_text=_("Actions to prevent recurrence")
)
# Action tracking
action_owner = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='owned_rca_actions',
help_text=_("Person responsible for implementing actions")
)
action_due_date = models.DateField(
null=True,
blank=True,
help_text=_("Due date for implementing actions")
)
action_status = models.CharField(
max_length=20,
choices=[
('not_started', 'Not Started'),
('in_progress', 'In Progress'),
('completed', 'Completed'),
('verified', 'Verified Effective'),
],
default='not_started',
help_text=_("Status of corrective actions")
)
# Effectiveness verification
effectiveness_verified = models.BooleanField(
default=False,
help_text=_("Whether the effectiveness of actions has been verified")
)
effectiveness_date = models.DateField(
null=True,
blank=True,
help_text=_("Date when effectiveness was verified")
)
effectiveness_notes = models.TextField(
blank=True,
help_text=_("Notes on effectiveness verification")
)
# RCA Status
status = models.CharField(
max_length=20,
choices=[
('draft', 'Draft'),
('in_review', 'In Review'),
('approved', 'Approved'),
('closed', 'Closed'),
],
default='draft',
help_text=_("RCA status")
)
# Approval
approved_by = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='approved_rcas',
help_text=_("User who approved the RCA")
)
approved_at = models.DateTimeField(
null=True,
blank=True,
help_text=_("Date when RCA was approved")
)
class Meta:
verbose_name = _('Root Cause Analysis')
verbose_name_plural = _('Root Cause Analyses')
indexes = [
models.Index(fields=['action_status', 'action_due_date']),
models.Index(fields=['status']),
]
def __str__(self):
return f"RCA for {self.complaint.reference_number}"
@property
def is_overdue(self):
"""Check if action is overdue"""
from django.utils import timezone
if self.action_due_date and self.action_status not in ['completed', 'verified']:
return timezone.now().date() > self.action_due_date
return False

View File

View File

@ -0,0 +1,243 @@
"""
Complaint duplicate detection service
Identifies potential duplicate complaints based on:
- Patient name similarity
- Incident date proximity
- Description text similarity
- Category match
"""
from difflib import SequenceMatcher
from django.utils import timezone
from datetime import timedelta
from django.db.models import Q
class ComplaintDuplicateDetector:
"""
Detect potential duplicate complaints.
Uses fuzzy matching to identify complaints that may be duplicates
of each other, helping prevent fragmentation and redundant work.
"""
def __init__(self, complaint_data, threshold=0.75):
"""
Initialize detector with complaint data.
Args:
complaint_data: Dict with complaint fields (patient_name, description, etc.)
threshold: Similarity threshold (0.0-1.0) for considering duplicates
"""
self.data = complaint_data
self.threshold = threshold
def find_duplicates(self, hospital_id, days=30, exclude_id=None):
"""
Find potential duplicate complaints from last N days.
Args:
hospital_id: Hospital to search within
days: Number of days to look back
exclude_id: Complaint ID to exclude (useful when editing)
Returns:
List of dicts with complaint, score, and match reasons
"""
from apps.complaints.models import Complaint
cutoff = timezone.now() - timedelta(days=days)
# Get candidate complaints (exclude closed/cancelled)
candidates = Complaint.objects.filter(
hospital_id=hospital_id,
created_at__gte=cutoff,
status__in=['open', 'in_progress', 'partially_resolved']
)
if exclude_id:
candidates = candidates.exclude(id=exclude_id)
# Select only needed fields for performance
candidates = candidates.select_related('patient', 'category').only(
'id', 'patient_name', 'description', 'created_at',
'incident_date', 'category_id'
)
duplicates = []
for complaint in candidates:
score, reasons = self._calculate_similarity(complaint)
if score >= self.threshold:
duplicates.append({
'complaint': complaint,
'score': score,
'score_percentage': int(score * 100),
'reasons': reasons,
'is_likely_duplicate': score >= 0.85 # 85%+ is very likely duplicate
})
# Sort by score descending
return sorted(duplicates, key=lambda x: x['score'], reverse=True)
def _calculate_similarity(self, complaint):
"""
Calculate similarity score between new complaint and existing one.
Returns:
Tuple of (score, list of match reasons)
"""
score = 0.0
reasons = []
# Weights for different factors
weights = {
'patient': 0.30, # Patient name match
'date': 0.20, # Incident date match
'description': 0.35, # Description text similarity
'category': 0.15, # Category match
}
# Patient name match (30%)
patient_match, patient_reason = self._match_patient(complaint)
if patient_match:
score += weights['patient']
reasons.append(patient_reason)
# Date match (20%)
date_match, date_reason = self._match_date(complaint)
if date_match:
score += weights['date']
reasons.append(date_reason)
# Description similarity (35%)
desc_score, desc_reason = self._text_similarity(complaint)
score += desc_score * weights['description']
if desc_score > 0.5:
reasons.append(desc_reason)
# Category match (15%)
category_match, category_reason = self._match_category(complaint)
if category_match:
score += weights['category']
reasons.append(category_reason)
return score, reasons
def _match_patient(self, complaint):
"""Check if patient names match"""
new_name = self._normalize_name(self.data.get('patient_name', ''))
existing_name = self._normalize_name(getattr(complaint, 'patient_name', ''))
if not new_name or not existing_name:
return False, None
# Exact match
if new_name == existing_name:
return True, f"Patient name matches: {complaint.patient_name}"
# Fuzzy match (80%+ similar)
similarity = SequenceMatcher(None, new_name, existing_name).ratio()
if similarity >= 0.80:
return True, f"Patient name similar ({int(similarity*100)}% match)"
return False, None
def _normalize_name(self, name):
"""Normalize name for comparison"""
if not name:
return ''
# Convert to uppercase, remove extra spaces
name = ' '.join(str(name).upper().split())
# Remove common prefixes/suffixes
for prefix in ['MR.', 'MRS.', 'MS.', 'DR.', 'MR ', 'MRS ', 'MS ', 'DR ']:
name = name.replace(prefix, '')
return name.strip()
def _match_date(self, complaint):
"""Check if incident dates are within 3 days"""
new_date = self.data.get('incident_date')
existing_date = getattr(complaint, 'incident_date', None)
if not new_date or not existing_date:
return False, None
# Check if dates are the same or within 3 days
date_diff = abs((new_date - existing_date).days)
if date_diff == 0:
return True, f"Same incident date: {existing_date}"
elif date_diff <= 3:
return True, f"Incident date within 3 days ({existing_date})"
return False, None
def _text_similarity(self, complaint):
"""Calculate text similarity between descriptions"""
new_desc = self._normalize_text(self.data.get('description', ''))
existing_desc = self._normalize_text(getattr(complaint, 'description', ''))
if not new_desc or not existing_desc:
return 0.0, None
# Use SequenceMatcher for text similarity
similarity = SequenceMatcher(None, new_desc, existing_desc).ratio()
# Also check for keyword overlap
new_words = set(new_desc.split())
existing_words = set(existing_desc.split())
if new_words and existing_words:
word_overlap = len(new_words & existing_words) / min(len(new_words), len(existing_words))
# Boost score if there's significant word overlap
if word_overlap > 0.5:
similarity = max(similarity, word_overlap * 0.8)
reason = None
if similarity > 0.7:
reason = f"Description {int(similarity*100)}% similar"
return similarity, reason
def _normalize_text(self, text):
"""Normalize text for comparison"""
if not text:
return ''
# Lowercase, remove extra spaces
text = ' '.join(str(text).lower().split())
# Remove common words
stop_words = {'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will',
'would', 'could', 'should', 'may', 'might', 'must', 'shall'}
words = [w for w in text.split() if w not in stop_words and len(w) > 2]
return ' '.join(words)
def _match_category(self, complaint):
"""Check if categories match"""
new_category_id = self.data.get('category_id')
existing_category_id = getattr(complaint, 'category_id', None)
if not new_category_id or not existing_category_id:
return False, None
if new_category_id == existing_category_id:
# Get category name for display
category_name = getattr(complaint, 'category', None)
if category_name:
return True, f"Same category: {category_name.name_en}"
return True, "Same category"
return False, None
def check_for_duplicates(complaint_data, hospital_id, exclude_id=None):
"""
Convenience function to check for duplicate complaints.
Args:
complaint_data: Dict with complaint fields
hospital_id: Hospital ID to search within
exclude_id: Optional complaint ID to exclude
Returns:
List of potential duplicates with scores
"""
detector = ComplaintDuplicateDetector(complaint_data)
return detector.find_duplicates(hospital_id, exclude_id=exclude_id)

View File

@ -661,6 +661,8 @@ def complaint_change_status(request, pk):
new_status = request.POST.get("status") new_status = request.POST.get("status")
note = request.POST.get("note", "") note = request.POST.get("note", "")
resolution = request.POST.get("resolution", "") resolution = request.POST.get("resolution", "")
resolution_outcome = request.POST.get("resolution_outcome", "")
resolution_outcome_other = request.POST.get("resolution_outcome_other", "")
if not new_status: if not new_status:
messages.error(request, "Please select a status.") messages.error(request, "Please select a status.")
@ -677,6 +679,11 @@ def complaint_change_status(request, pk):
if resolution: if resolution:
complaint.resolution = resolution complaint.resolution = resolution
complaint.resolution_sent_at = timezone.now() complaint.resolution_sent_at = timezone.now()
# Save resolution outcome
if resolution_outcome:
complaint.resolution_outcome = resolution_outcome
if resolution_outcome == "other" and resolution_outcome_other:
complaint.resolution_outcome_other = resolution_outcome_other
elif new_status == ComplaintStatus.CLOSED: elif new_status == ComplaintStatus.CLOSED:
complaint.closed_at = timezone.now() complaint.closed_at = timezone.now()
complaint.closed_by = request.user complaint.closed_by = request.user

View File

@ -111,6 +111,28 @@ def request_explanation_form(request, pk):
selected_manager_ids, request_message selected_manager_ids, request_message
) )
# Check results and show appropriate message
if results['staff_count'] == 0 and results['manager_count'] == 0:
if results['skipped_no_email'] > 0:
messages.warning(
request,
_("No explanation requests were sent. {} staff member(s) do not have email addresses. Please update staff records with email addresses before sending explanation requests.").format(
results['skipped_no_email']
)
)
else:
messages.warning(
request,
_("No explanation requests were sent. Please check staff email configuration.")
)
elif results['staff_count'] == 0 and results['manager_count'] > 0:
messages.warning(
request,
_("Only manager notifications were sent ({}). Staff explanation requests could not be sent due to missing email addresses.").format(
results['manager_count']
)
)
else:
messages.success( messages.success(
request, request,
_("Explanation requests sent successfully! Staff: {}, Managers notified: {}.").format( _("Explanation requests sent successfully! Staff: {}, Managers notified: {}.").format(
@ -142,6 +164,14 @@ def _send_explanation_requests(request, complaint, recipients, selected_staff_id
staff_count = 0 staff_count = 0
manager_count = 0 manager_count = 0
skipped_no_email = 0
# Debug logging
import logging
logger = logging.getLogger(__name__)
logger.info(f"Sending explanation requests. Selected staff IDs: {selected_staff_ids}")
logger.info(f"Selected manager IDs: {selected_manager_ids}")
logger.info(f"Total recipients: {len(recipients)}")
# Track which managers we've already notified # Track which managers we've already notified
notified_managers = set() notified_managers = set()
@ -150,13 +180,19 @@ def _send_explanation_requests(request, complaint, recipients, selected_staff_id
staff = recipient['staff'] staff = recipient['staff']
staff_id = recipient['staff_id'] staff_id = recipient['staff_id']
logger.info(f"Processing staff: {staff.get_full_name()} (ID: {staff_id})")
# Skip if staff not selected # Skip if staff not selected
if staff_id not in selected_staff_ids: if staff_id not in selected_staff_ids:
logger.info(f" Skipping - staff_id {staff_id} not in selected_staff_ids: {selected_staff_ids}")
continue continue
# Check if staff has email # Check if staff has email
staff_email = recipient['staff_email'] staff_email = recipient['staff_email']
logger.info(f" Staff email: {staff_email}")
if not staff_email: if not staff_email:
logger.warning(f" Skipping - no email for staff {staff.get_full_name()}")
skipped_no_email += 1
continue continue
# Generate unique token # Generate unique token
@ -230,6 +266,7 @@ This is an automated message from PX360 Complaint Management System.
# Send email to staff # Send email to staff
try: try:
logger.info(f" Sending email to: {staff_email}")
NotificationService.send_email( NotificationService.send_email(
email=staff_email, email=staff_email,
subject=staff_subject, subject=staff_subject,
@ -242,10 +279,9 @@ This is an automated message from PX360 Complaint Management System.
} }
) )
staff_count += 1 staff_count += 1
logger.info(f" Email sent successfully to {staff_email}")
except Exception as e: except Exception as e:
import logging logger.error(f" Failed to send explanation request to staff {staff.id}: {e}")
logger = logging.getLogger(__name__)
logger.error(f"Failed to send explanation request to staff {staff.id}: {e}")
# Send notification to manager if selected and not already notified # Send notification to manager if selected and not already notified
manager = recipient['manager'] manager = recipient['manager']
@ -302,15 +338,16 @@ This is an automated message from PX360 Complaint Management System.
# Log audit event # Log audit event
AuditService.log_event( AuditService.log_event(
event_type='explanation_request', event_type='explanation_request',
description=f'Explanation requests sent to {staff_count} staff and {manager_count} managers', description=f'Explanation requests sent to {staff_count} staff and {manager_count} managers ({skipped_no_email} skipped due to no email)',
user=user, user=user,
content_object=complaint, content_object=complaint,
metadata={ metadata={
'staff_count': staff_count, 'staff_count': staff_count,
'manager_count': manager_count, 'manager_count': manager_count,
'skipped_no_email': skipped_no_email,
'selected_staff_ids': selected_staff_ids, 'selected_staff_ids': selected_staff_ids,
'selected_manager_ids': selected_manager_ids, 'selected_manager_ids': selected_manager_ids,
} }
) )
return {'staff_count': staff_count, 'manager_count': manager_count} return {'staff_count': staff_count, 'manager_count': manager_count, 'skipped_no_email': skipped_no_email}

View File

@ -0,0 +1,265 @@
"""
Complaint Templates UI views
"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext_lazy as _
from apps.complaints.models import ComplaintTemplate, ComplaintCategory
from apps.organizations.models import Hospital, Department
@login_required
def template_list(request):
"""
List all complaint templates
"""
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
messages.error(request, _("You don't have permission to manage templates."))
return redirect('complaints:complaint_list')
templates = ComplaintTemplate.objects.select_related('hospital', 'category', 'auto_assign_department').all()
# Filter by hospital
hospital_filter = request.GET.get('hospital')
if hospital_filter:
templates = templates.filter(hospital_id=hospital_filter)
# Filter by active status
is_active = request.GET.get('is_active')
if is_active:
templates = templates.filter(is_active=is_active == 'true')
# Search
search = request.GET.get('search')
if search:
templates = templates.filter(name_en__icontains=search)
templates = templates.order_by('-usage_count', 'name_en')
# Get hospitals for filter
if request.user.is_px_admin():
hospitals = Hospital.objects.filter(status='active').order_by('name')
else:
hospitals = Hospital.objects.filter(id=request.user.hospital_id) if request.user.hospital else []
context = {
'templates': templates,
'hospitals': hospitals,
'hospital_filter': hospital_filter,
'is_active_filter': is_active,
'search': search,
}
return render(request, 'complaints/templates/template_list.html', context)
@login_required
def template_create(request):
"""
Create a new complaint template
"""
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
messages.error(request, _("You don't have permission to create templates."))
return redirect('complaints:template_list')
if request.method == 'POST':
try:
# Get hospital
hospital_id = request.POST.get('hospital')
hospital = get_object_or_404(Hospital, id=hospital_id)
# Get category if provided
category_id = request.POST.get('category')
category = None
if category_id:
category = get_object_or_404(ComplaintCategory, id=category_id)
# Get auto-assign department if provided
auto_assign_dept_id = request.POST.get('auto_assign_department')
auto_assign_department = None
if auto_assign_dept_id:
auto_assign_department = get_object_or_404(Department, id=auto_assign_dept_id)
# Parse placeholders from JSON or empty list
import json
try:
placeholders = json.loads(request.POST.get('placeholders', '[]'))
except:
placeholders = []
template = ComplaintTemplate.objects.create(
hospital=hospital,
name=request.POST.get('name'),
description=request.POST.get('description'),
category=category,
default_severity=request.POST.get('default_severity', 'medium'),
default_priority=request.POST.get('default_priority', 'medium'),
auto_assign_department=auto_assign_department,
placeholders=placeholders,
is_active=request.POST.get('is_active') == 'on',
)
messages.success(request, _("Template created successfully!"))
return redirect('complaints:template_detail', pk=template.pk)
except Exception as e:
messages.error(request, _("Error creating template: {}").format(str(e)))
# Get hospitals and categories for form
if request.user.is_px_admin():
hospitals = Hospital.objects.filter(status='active').order_by('name')
else:
hospitals = Hospital.objects.filter(id=request.user.hospital_id) if request.user.hospital else []
categories = ComplaintCategory.objects.filter(level=2, is_active=True).order_by('name_en')[:50]
context = {
'hospitals': hospitals,
'categories': categories,
'template': None,
'action': 'create',
}
return render(request, 'complaints/templates/template_form.html', context)
@login_required
def template_detail(request, pk):
"""
View template details
"""
template = get_object_or_404(
ComplaintTemplate.objects.select_related('hospital', 'category', 'auto_assign_department'),
pk=pk
)
context = {
'template': template,
}
return render(request, 'complaints/templates/template_detail.html', context)
@login_required
def template_edit(request, pk):
"""
Edit an existing template
"""
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
messages.error(request, _("You don't have permission to edit templates."))
return redirect('complaints:template_detail', pk=pk)
template = get_object_or_404(
ComplaintTemplate.objects.select_related('hospital', 'category', 'auto_assign_department'),
pk=pk
)
if request.method == 'POST':
try:
# Get hospital
hospital_id = request.POST.get('hospital')
template.hospital = get_object_or_404(Hospital, id=hospital_id)
# Get category if provided
category_id = request.POST.get('category')
if category_id:
template.category = get_object_or_404(ComplaintCategory, id=category_id)
else:
template.category = None
# Get auto-assign department if provided
auto_assign_dept_id = request.POST.get('auto_assign_department')
if auto_assign_dept_id:
template.auto_assign_department = get_object_or_404(Department, id=auto_assign_dept_id)
else:
template.auto_assign_department = None
# Parse placeholders
import json
try:
template.placeholders = json.loads(request.POST.get('placeholders', '[]'))
except:
template.placeholders = []
template.name = request.POST.get('name')
template.description = request.POST.get('description')
template.default_severity = request.POST.get('default_severity', 'medium')
template.default_priority = request.POST.get('default_priority', 'medium')
template.is_active = request.POST.get('is_active') == 'on'
template.save()
messages.success(request, _("Template updated successfully!"))
return redirect('complaints:template_detail', pk=template.pk)
except Exception as e:
messages.error(request, _("Error updating template: {}").format(str(e)))
# Get hospitals and categories for form
if request.user.is_px_admin():
hospitals = Hospital.objects.filter(status='active').order_by('name')
else:
hospitals = Hospital.objects.filter(id=request.user.hospital_id) if request.user.hospital else []
categories = ComplaintCategory.objects.filter(level=2, is_active=True).order_by('name_en')[:50]
context = {
'hospitals': hospitals,
'categories': categories,
'template': template,
'action': 'edit',
}
return render(request, 'complaints/templates/template_form.html', context)
@login_required
def template_delete(request, pk):
"""
Delete a template
"""
if not request.user.is_px_admin():
messages.error(request, _("You don't have permission to delete templates."))
return redirect('complaints:template_detail', pk=pk)
template = get_object_or_404(ComplaintTemplate, pk=pk)
if request.method == 'POST':
template_name = template.name
template.delete()
messages.success(request, _("Template '{}' deleted successfully!").format(template_name))
return redirect('complaints:template_list')
context = {
'template': template,
}
return render(request, 'complaints/templates/template_confirm_delete.html', context)
@login_required
def template_toggle_status(request, pk):
"""
Toggle template active status (AJAX)
"""
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
from django.http import JsonResponse
return JsonResponse({'error': 'Permission denied'}, status=403)
from django.http import JsonResponse
if request.method != 'POST':
return JsonResponse({'error': 'Method not allowed'}, status=405)
template = get_object_or_404(ComplaintTemplate, pk=pk)
template.is_active = not template.is_active
template.save()
return JsonResponse({
'success': True,
'is_active': template.is_active,
'message': 'Template {} successfully'.format(
'activated' if template.is_active else 'deactivated'
)
})

View File

@ -14,7 +14,7 @@ from .views import (
api_subsections, api_subsections,
api_departments, api_departments,
) )
from . import ui_views, ui_views_explanation, ui_views_oncall from . import ui_views, ui_views_explanation, ui_views_oncall, ui_views_templates
app_name = "complaints" app_name = "complaints"
@ -69,6 +69,15 @@ urlpatterns = [
path("settings/thresholds/new/", ui_views.complaint_threshold_create, name="complaint_threshold_create"), path("settings/thresholds/new/", ui_views.complaint_threshold_create, name="complaint_threshold_create"),
path("settings/thresholds/<uuid:pk>/edit/", ui_views.complaint_threshold_edit, name="complaint_threshold_edit"), path("settings/thresholds/<uuid:pk>/edit/", ui_views.complaint_threshold_edit, name="complaint_threshold_edit"),
path("settings/thresholds/<uuid:pk>/delete/", ui_views.complaint_threshold_delete, name="complaint_threshold_delete"), path("settings/thresholds/<uuid:pk>/delete/", ui_views.complaint_threshold_delete, name="complaint_threshold_delete"),
# Complaint Templates Management
path("templates/", ui_views_templates.template_list, name="template_list"),
path("templates/new/", ui_views_templates.template_create, name="template_create"),
path("templates/<uuid:pk>/", ui_views_templates.template_detail, name="template_detail"),
path("templates/<uuid:pk>/edit/", ui_views_templates.template_edit, name="template_edit"),
path("templates/<uuid:pk>/delete/", ui_views_templates.template_delete, name="template_delete"),
path("templates/<uuid:pk>/toggle/", ui_views_templates.template_toggle_status, name="template_toggle_status"),
# AJAX Helpers # AJAX Helpers
path("ajax/departments/", ui_views.get_departments_by_hospital, name="get_departments_by_hospital"), path("ajax/departments/", ui_views.get_departments_by_hospital, name="get_departments_by_hospital"),
path("ajax/physicians/", ui_views.get_staff_by_department, name="get_physicians_by_department"), path("ajax/physicians/", ui_views.get_staff_by_department, name="get_physicians_by_department"),

View File

@ -7,6 +7,8 @@ from django.shortcuts import render
from apps.organizations.models import Hospital from apps.organizations.models import Hospital
from apps.px_action_center.models import PXActionSLAConfig, RoutingRule from apps.px_action_center.models import PXActionSLAConfig, RoutingRule
from apps.complaints.models import OnCallAdminSchedule
from apps.callcenter.models import CallRecord
@login_required @login_required
@ -23,11 +25,15 @@ def config_dashboard(request):
sla_configs_count = PXActionSLAConfig.objects.filter(is_active=True).count() sla_configs_count = PXActionSLAConfig.objects.filter(is_active=True).count()
routing_rules_count = RoutingRule.objects.filter(is_active=True).count() routing_rules_count = RoutingRule.objects.filter(is_active=True).count()
hospitals_count = Hospital.objects.filter(status='active').count() hospitals_count = Hospital.objects.filter(status='active').count()
oncall_schedules_count = OnCallAdminSchedule.objects.filter(is_active=True).count()
call_records_count = CallRecord.objects.count()
context = { context = {
'sla_configs_count': sla_configs_count, 'sla_configs_count': sla_configs_count,
'routing_rules_count': routing_rules_count, 'routing_rules_count': routing_rules_count,
'hospitals_count': hospitals_count, 'hospitals_count': hospitals_count,
'oncall_schedules_count': oncall_schedules_count,
'call_records_count': call_records_count,
} }
return render(request, 'config/dashboard.html', context) return render(request, 'config/dashboard.html', context)

View File

@ -0,0 +1,169 @@
"""
Management command to export database data to Django fixture format and compress.
Usage:
python manage.py export_data --compress
"""
import json
import os
import zipfile
from django.core.management.base import BaseCommand, CommandError
from django.apps import apps
from django.core import serializers
from datetime import datetime
class Command(BaseCommand):
help = 'Export database data to compressed JSON fixtures'
def add_arguments(self, parser):
parser.add_argument(
'--output-dir',
type=str,
default='./database_export',
help='Directory to export JSON files to'
)
parser.add_argument(
'--compress',
action='store_true',
help='Create a compressed ZIP file of all exports'
)
parser.add_argument(
'--apps',
type=str,
nargs='+',
help='Specific apps to export (e.g., complaints organizations accounts)'
)
parser.add_argument(
'--exclude-apps',
type=str,
nargs='+',
default=['contenttypes', 'auth', 'admin', 'sessions', 'django_celery_beat'],
help='Apps to exclude from export'
)
def handle(self, *args, **options):
output_dir = options['output_dir']
include_apps = options['apps']
exclude_apps = options['exclude_apps']
do_compress = options['compress']
# Create output directory
os.makedirs(output_dir, exist_ok=True)
# Get all models
all_models = apps.get_models()
# Group models by app
models_by_app = {}
for model in all_models:
app_label = model._meta.app_label
# Skip excluded apps
if app_label in exclude_apps:
continue
# Skip if specific apps requested and this isn't one of them
if include_apps and app_label not in include_apps:
continue
if app_label not in models_by_app:
models_by_app[app_label] = []
models_by_app[app_label].append(model)
# Export data
total_exported = 0
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
fixture_files = []
self.stdout.write(self.style.SUCCESS(f'Exporting data to: {output_dir}'))
self.stdout.write(f'Found {len(models_by_app)} apps to export\n')
for app_label, models in models_by_app.items():
app_dir = os.path.join(output_dir, app_label)
os.makedirs(app_dir, exist_ok=True)
app_count = 0
all_objects = []
for model in models:
model_name = model._meta.model_name
# Get all objects
queryset = model.objects.all()
count = queryset.count()
if count == 0:
continue
try:
# Serialize to Django fixture format
serialized = serializers.serialize('json', queryset)
data = json.loads(serialized)
all_objects.extend(data)
app_count += count
total_exported += count
self.stdout.write(f'{app_label}.{model_name}: {count} records')
except Exception as e:
self.stdout.write(self.style.WARNING(f'{app_label}.{model_name}: Error - {str(e)}'))
# Write app fixture file (Django format)
if all_objects:
output_file = os.path.join(app_dir, f'{app_label}_fixture.json')
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(all_objects, f, indent=2, ensure_ascii=False)
fixture_files.append(output_file)
self.stdout.write(self.style.SUCCESS(f' → Exported to: {output_file} ({app_count} records)\n'))
# Create compressed ZIP file
if do_compress:
zip_filename = os.path.join(output_dir, f'database_export_{timestamp}.zip')
self.stdout.write(self.style.SUCCESS(f'\nCreating compressed archive: {zip_filename}'))
with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(output_dir):
for file in files:
if file.endswith('.zip'):
continue # Skip the zip file itself
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, output_dir)
zipf.write(file_path, arcname)
# Print progress for large files
file_size = os.path.getsize(file_path)
if file_size > 1024 * 1024: # > 1MB
self.stdout.write(f' ✓ Added: {arcname} ({file_size / (1024*1024):.1f} MB)')
zip_size = os.path.getsize(zip_filename)
self.stdout.write(self.style.SUCCESS(f' → ZIP created: {zip_filename} ({zip_size / (1024*1024):.1f} MB)'))
# Create summary file
summary_file = os.path.join(output_dir, 'export_summary.json')
with open(summary_file, 'w', encoding='utf-8') as f:
json.dump({
'exported_at': timestamp,
'total_records': total_exported,
'apps_exported': list(models_by_app.keys()),
'output_directory': output_dir,
'compressed': do_compress,
'zip_file': zip_filename if do_compress else None,
'fixture_files': fixture_files
}, f, indent=2)
self.stdout.write(self.style.SUCCESS('\n' + '=' * 60))
self.stdout.write(self.style.SUCCESS(f'EXPORT COMPLETE'))
self.stdout.write(self.style.SUCCESS(f'Total records exported: {total_exported}'))
self.stdout.write(self.style.SUCCESS(f'Output directory: {output_dir}'))
if do_compress:
self.stdout.write(self.style.SUCCESS(f'Compressed file: {zip_filename}'))
self.stdout.write(self.style.SUCCESS(f'Summary file: {summary_file}'))
self.stdout.write(self.style.SUCCESS('=' * 60))
# Print import instructions
self.stdout.write(self.style.SUCCESS('\n📥 TO IMPORT DATA:'))
self.stdout.write(self.style.SUCCESS(f' python manage.py loaddata {output_dir}/*/\\*_fixture.json'))
self.stdout.write(self.style.SUCCESS('=' * 60))

View File

@ -61,7 +61,7 @@ class Command(BaseCommand):
survey = SurveyTemplate.objects.create( survey = SurveyTemplate.objects.create(
name=name, name=name,
description=f'{name} for patient feedback collection', name_ar=name,
hospital=hospital, hospital=hospital,
survey_type='general', survey_type='general',
is_active=True is_active=True

View File

@ -212,8 +212,11 @@ class Command(BaseCommand):
subsections_to_create = [] subsections_to_create = []
# Clear existing data to prevent old ID conflicts # Clear existing data to prevent old ID conflicts (skip if referenced)
try:
SubSection.objects.all().delete() SubSection.objects.all().delete()
except Exception:
self.stdout.write(self.style.WARNING('Skipping SubSection deletion - some are referenced by complaints'))
for item in subsections_data: for item in subsections_data:
subsections_to_create.append( subsections_to_create.append(

View File

@ -258,6 +258,10 @@ class Staff(UUIDModel, TimeStampedModel):
# Head of department/section/subsection indicator # Head of department/section/subsection indicator
is_head = models.BooleanField(default=False, verbose_name="Is Head") is_head = models.BooleanField(default=False, verbose_name="Is Head")
# Physician indicator - set to True when staff comes from physician rating import
physician = models.BooleanField(default=False, verbose_name="Is Physician",
help_text="Set to True when staff record comes from physician rating import")
status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE) status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE)
def __str__(self): def __str__(self):

View File

@ -282,6 +282,11 @@ class DoctorRatingAdapter:
doctor_id, hospital, doctor_name doctor_id, hospital, doctor_name
) )
# If staff found, mark as physician
if staff and not staff.physician:
staff.physician = True
staff.save(update_fields=['physician'])
# Extract patient info # Extract patient info
uhid = data.get('uhid', '').strip() uhid = data.get('uhid', '').strip()
patient_name = data.get('patient_name', '').strip() patient_name = data.get('patient_name', '').strip()

View File

@ -322,9 +322,18 @@ def doctor_rating_job_status(request, job_id):
messages.error(request, "You don't have permission to view this job.") messages.error(request, "You don't have permission to view this job.")
return redirect('physicians:physician_list') return redirect('physicians:physician_list')
# Calculate progress circle stroke-dashoffset
# Circle circumference is 326.73 (2 * pi * r, where r=52)
# When progress is 0%, offset should be 326.73 (empty)
# When progress is 100%, offset should be 0 (full)
circumference = 2 * 3.14159 * 52 # ~326.73
progress = job.progress_percentage
stroke_dashoffset = circumference * (1 - progress / 100)
context = { context = {
'job': job, 'job': job,
'progress': job.progress_percentage, 'progress': progress,
'stroke_dashoffset': stroke_dashoffset,
'is_complete': job.is_complete, 'is_complete': job.is_complete,
'results': job.results, 'results': job.results,
} }

View File

View File

@ -0,0 +1,81 @@
"""
Management command to mark staff as physicians based on existing ratings.
This command scans all PhysicianIndividualRating records and marks
the associated Staff records as physicians.
Usage:
python manage.py mark_physicians_from_ratings [--hospital_id <uuid>]
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.organizations.models import Staff
from apps.physicians.models import PhysicianIndividualRating
class Command(BaseCommand):
help = 'Mark staff as physicians based on existing physician ratings'
def add_arguments(self, parser):
parser.add_argument(
'--hospital_id',
type=str,
help='Optional hospital ID to limit the update to a specific hospital',
required=False
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be updated without making changes',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
hospital_id = options.get('hospital_id')
# Get all staff IDs that have physician ratings
ratings_query = PhysicianIndividualRating.objects.filter(
staff__isnull=False
)
if hospital_id:
ratings_query = ratings_query.filter(hospital_id=hospital_id)
self.stdout.write(f"Filtering by hospital_id: {hospital_id}")
# Get unique staff IDs from ratings
staff_ids = list(ratings_query.values_list('staff_id', flat=True).distinct())
self.stdout.write(f"Found {len(staff_ids)} staff members with physician ratings")
if not staff_ids:
self.stdout.write(self.style.WARNING("No staff with physician ratings found"))
return
# Find staff that are not yet marked as physicians
staff_to_update = Staff.objects.filter(
id__in=staff_ids,
physician=False
)
count = staff_to_update.count()
if count == 0:
self.stdout.write(self.style.SUCCESS("All staff with physician ratings are already marked as physicians"))
return
self.stdout.write(f"Will update {count} staff records to mark as physicians")
if dry_run:
self.stdout.write(self.style.WARNING("DRY RUN - No changes made"))
for staff in staff_to_update[:10]:
self.stdout.write(f" Would update: {staff.get_full_name()} (ID: {staff.id})")
if count > 10:
self.stdout.write(f" ... and {count - 10} more")
return
# Update the staff records
with transaction.atomic():
updated = staff_to_update.update(physician=True)
self.stdout.write(self.style.SUCCESS(f"Successfully updated {updated} staff records as physicians"))

View File

@ -24,8 +24,11 @@ def physician_list(request):
- Search by name or license number - Search by name or license number
- Current month rating display - Current month rating display
""" """
# Base queryset with optimizations # Base queryset with optimizations - only show staff marked as physicians
queryset = Staff.objects.select_related('hospital', 'department') # Include both: staff with physician=True (from rating imports) OR staff_type='physician'
queryset = Staff.objects.filter(
Q(physician=True) | Q(staff_type=Staff.StaffType.PHYSICIAN)
).select_related('hospital', 'department').distinct()
# Apply RBAC filters # Apply RBAC filters
user = request.user user = request.user
@ -98,8 +101,10 @@ def physician_list(request):
if not user.is_px_admin() and user.hospital: if not user.is_px_admin() and user.hospital:
departments = departments.filter(hospital=user.hospital) departments = departments.filter(hospital=user.hospital)
# Get unique specializations # Get unique specializations (only from physicians)
specializations = Staff.objects.values_list('specialization', flat=True).distinct().order_by('specialization') specializations = Staff.objects.filter(
Q(physician=True) | Q(staff_type=Staff.StaffType.PHYSICIAN)
).values_list('specialization', flat=True).distinct().order_by('specialization')
# Statistics # Statistics
stats = { stats = {
@ -237,10 +242,11 @@ def leaderboard(request):
department_filter = request.GET.get('department') department_filter = request.GET.get('department')
limit = int(request.GET.get('limit', 20)) limit = int(request.GET.get('limit', 20))
# Build queryset # Build queryset - only include staff marked as physicians
queryset = PhysicianMonthlyRating.objects.filter( queryset = PhysicianMonthlyRating.objects.filter(
year=year, year=year,
month=month month=month,
staff__physician=True
).select_related('staff', 'staff__hospital', 'staff__department') ).select_related('staff', 'staff__hospital', 'staff__department')
# Apply RBAC filters # Apply RBAC filters
@ -393,10 +399,10 @@ def physician_ratings_dashboard_api(request):
hospital_filter = request.GET.get('hospital') hospital_filter = request.GET.get('hospital')
department_filter = request.GET.get('department') department_filter = request.GET.get('department')
# Base queryset # Base queryset - only include staff marked as physicians
queryset = PhysicianMonthlyRating.objects.select_related( queryset = PhysicianMonthlyRating.objects.filter(
'staff', 'staff__hospital', 'staff__department' staff__physician=True
) ).select_related('staff', 'staff__hospital', 'staff__department')
# Apply RBAC filters # Apply RBAC filters
user = request.user user = request.user
@ -539,10 +545,10 @@ def ratings_list(request):
- Search by physician name - Search by physician name
- Pagination - Pagination
""" """
# Base queryset # Base queryset - only include staff marked as physicians
queryset = PhysicianMonthlyRating.objects.select_related( queryset = PhysicianMonthlyRating.objects.filter(
'staff', 'staff__hospital', 'staff__department' staff__physician=True
) ).select_related('staff', 'staff__hospital', 'staff__department')
# Apply RBAC filters # Apply RBAC filters
user = request.user user = request.user
@ -630,10 +636,11 @@ def specialization_overview(request):
month = int(request.GET.get('month', now.month)) month = int(request.GET.get('month', now.month))
hospital_filter = request.GET.get('hospital') hospital_filter = request.GET.get('hospital')
# Base queryset # Base queryset - only include staff marked as physicians
queryset = PhysicianMonthlyRating.objects.filter( queryset = PhysicianMonthlyRating.objects.filter(
year=year, year=year,
month=month month=month,
staff__physician=True
).select_related('staff', 'staff__hospital', 'staff__department') ).select_related('staff', 'staff__hospital', 'staff__department')
# Apply RBAC filters # Apply RBAC filters
@ -721,10 +728,11 @@ def department_overview(request):
month = int(request.GET.get('month', now.month)) month = int(request.GET.get('month', now.month))
hospital_filter = request.GET.get('hospital') hospital_filter = request.GET.get('hospital')
# Base queryset # Base queryset - only include staff marked as physicians
queryset = PhysicianMonthlyRating.objects.filter( queryset = PhysicianMonthlyRating.objects.filter(
year=year, year=year,
month=month month=month,
staff__physician=True
).select_related('staff', 'staff__hospital', 'staff__department') ).select_related('staff', 'staff__hospital', 'staff__department')
# Apply RBAC filters # Apply RBAC filters

View File

@ -19,6 +19,16 @@ class PXSource(UUIDModel, TimeStampedModel):
Simple model with bilingual naming and active status management. Simple model with bilingual naming and active status management.
""" """
# Code for API references
code = models.CharField(
max_length=50,
unique=True,
db_index=True,
help_text="Unique code for API references",
blank=True,
default=''
)
# Bilingual names # Bilingual names
name_en = models.CharField( name_en = models.CharField(
max_length=200, max_length=200,
@ -36,6 +46,33 @@ class PXSource(UUIDModel, TimeStampedModel):
help_text="Detailed description" help_text="Detailed description"
) )
# Source type
SOURCE_TYPE_CHOICES = [
('internal', 'Internal'),
('external', 'External'),
('partner', 'Partner'),
('government', 'Government'),
('other', 'Other'),
]
source_type = models.CharField(
max_length=50,
choices=SOURCE_TYPE_CHOICES,
default='internal',
db_index=True,
help_text="Type of source"
)
# Contact information for external sources
contact_email = models.EmailField(
blank=True,
help_text="Contact email for external sources"
)
contact_phone = models.CharField(
max_length=20,
blank=True,
help_text="Contact phone for external sources"
)
# Status # Status
is_active = models.BooleanField( is_active = models.BooleanField(
default=True, default=True,
@ -43,16 +80,55 @@ class PXSource(UUIDModel, TimeStampedModel):
help_text="Whether this source is active for selection" help_text="Whether this source is active for selection"
) )
# Metadata
metadata = models.JSONField(
default=dict,
blank=True,
help_text="Additional metadata"
)
# Cached usage stats
total_complaints = models.IntegerField(
default=0,
editable=False,
help_text="Cached total complaints count"
)
total_inquiries = models.IntegerField(
default=0,
editable=False,
help_text="Cached total inquiries count"
)
class Meta: class Meta:
ordering = ['name_en'] ordering = ['name_en']
verbose_name = 'PX Source' verbose_name = 'PX Source'
verbose_name_plural = 'PX Sources' verbose_name_plural = 'PX Sources'
indexes = [ indexes = [
models.Index(fields=['is_active', 'name_en']), models.Index(fields=['is_active', 'name_en']),
models.Index(fields=['code']),
models.Index(fields=['source_type']),
] ]
def __str__(self): def __str__(self):
return self.name_en return f"{self.code} - {self.name_en}"
def save(self, *args, **kwargs):
# Auto-generate code if not provided
if not self.code and self.name_en:
# Create code from name (e.g., "Hospital A" -> "HOSP-A")
words = self.name_en.upper().split()
if len(words) >= 2:
self.code = '-'.join(word[:4] for word in words[:3])
else:
self.code = self.name_en[:10].upper().replace(' ', '-')
# Ensure uniqueness
from django.db.models import Count
base_code = self.code
counter = 1
while PXSource.objects.filter(code=self.code).exclude(pk=self.pk).exists():
self.code = f"{base_code}-{counter}"
counter += 1
super().save(*args, **kwargs)
def get_localized_name(self, language='en'): def get_localized_name(self, language='en'):
"""Get localized name based on language""" """Get localized name based on language"""
@ -86,6 +162,30 @@ class PXSource(UUIDModel, TimeStampedModel):
""" """
return cls.objects.filter(is_active=True).order_by('name_en') return cls.objects.filter(is_active=True).order_by('name_en')
def update_usage_stats(self):
"""Update cached usage statistics"""
from apps.complaints.models import Complaint, Inquiry
self.total_complaints = Complaint.objects.filter(source=self).count()
self.total_inquiries = Inquiry.objects.filter(source=self).count()
self.save(update_fields=['total_complaints', 'total_inquiries'])
def get_usage_stats(self, days=30):
"""Get usage statistics for the last N days"""
from django.utils import timezone
from datetime import timedelta
cutoff = timezone.now() - timedelta(days=days)
return {
'total_usage': self.usage_records.filter(created_at__gte=cutoff).count(),
'complaints': self.usage_records.filter(
created_at__gte=cutoff,
content_type__model='complaint'
).count(),
'inquiries': self.usage_records.filter(
created_at__gte=cutoff,
content_type__model='inquiry'
).count(),
}
class SourceUser(UUIDModel, TimeStampedModel): class SourceUser(UUIDModel, TimeStampedModel):
""" """

View File

@ -11,11 +11,14 @@ class PXSourceSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = PXSource model = PXSource
fields = [ fields = [
'id', 'name_en', 'name_ar', 'id', 'code', 'name_en', 'name_ar',
'description', 'is_active', 'description', 'source_type',
'contact_email', 'contact_phone',
'is_active', 'metadata',
'total_complaints', 'total_inquiries',
'created_at', 'updated_at' 'created_at', 'updated_at'
] ]
read_only_fields = ['id', 'created_at', 'updated_at'] read_only_fields = ['id', 'created_at', 'updated_at', 'total_complaints', 'total_inquiries']
class PXSourceListSerializer(serializers.ModelSerializer): class PXSourceListSerializer(serializers.ModelSerializer):
@ -23,22 +26,28 @@ class PXSourceListSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = PXSource model = PXSource
fields = [ fields = [
'id', 'name_en', 'name_ar', 'id', 'code', 'name_en', 'name_ar',
'is_active' 'source_type', 'is_active',
'total_complaints', 'total_inquiries'
] ]
class PXSourceDetailSerializer(PXSourceSerializer): class PXSourceDetailSerializer(PXSourceSerializer):
"""Detailed serializer including usage statistics""" """Detailed serializer including usage statistics"""
usage_count = serializers.SerializerMethodField() usage_count = serializers.SerializerMethodField()
recent_usage = serializers.SerializerMethodField()
class Meta(PXSourceSerializer.Meta): class Meta(PXSourceSerializer.Meta):
fields = PXSourceSerializer.Meta.fields + ['usage_count'] fields = PXSourceSerializer.Meta.fields + ['usage_count', 'recent_usage']
def get_usage_count(self, obj): def get_usage_count(self, obj):
"""Get total usage count for this source""" """Get total usage count for this source"""
return obj.usage_records.count() return obj.usage_records.count()
def get_recent_usage(self, obj):
"""Get recent usage stats (last 30 days)"""
return obj.get_usage_stats(days=30)
class SourceUserSerializer(serializers.ModelSerializer): class SourceUserSerializer(serializers.ModelSerializer):
"""Serializer for SourceUser model""" """Serializer for SourceUser model"""
@ -46,6 +55,7 @@ class SourceUserSerializer(serializers.ModelSerializer):
user_full_name = serializers.CharField(source='user.get_full_name', read_only=True) user_full_name = serializers.CharField(source='user.get_full_name', read_only=True)
source_name = serializers.CharField(source='source.name_en', read_only=True) source_name = serializers.CharField(source='source.name_en', read_only=True)
source_name_ar = serializers.CharField(source='source.name_ar', read_only=True) source_name_ar = serializers.CharField(source='source.name_ar', read_only=True)
source_code = serializers.CharField(source='source.code', read_only=True)
class Meta: class Meta:
model = SourceUser model = SourceUser
@ -55,6 +65,7 @@ class SourceUserSerializer(serializers.ModelSerializer):
'user_email', 'user_email',
'user_full_name', 'user_full_name',
'source', 'source',
'source_code',
'source_name', 'source_name',
'source_name_ar', 'source_name_ar',
'is_active', 'is_active',
@ -71,6 +82,7 @@ class SourceUserListSerializer(serializers.ModelSerializer):
user_email = serializers.EmailField(source='user.email', read_only=True) user_email = serializers.EmailField(source='user.email', read_only=True)
user_full_name = serializers.CharField(source='user.get_full_name', read_only=True) user_full_name = serializers.CharField(source='user.get_full_name', read_only=True)
source_name = serializers.CharField(source='source.name_en', read_only=True) source_name = serializers.CharField(source='source.name_en', read_only=True)
source_code = serializers.CharField(source='source.code', read_only=True)
class Meta: class Meta:
model = SourceUser model = SourceUser
@ -78,6 +90,7 @@ class SourceUserListSerializer(serializers.ModelSerializer):
'id', 'id',
'user_email', 'user_email',
'user_full_name', 'user_full_name',
'source_code',
'source_name', 'source_name',
'is_active', 'is_active',
'can_create_complaints', 'can_create_complaints',
@ -88,14 +101,17 @@ class SourceUserListSerializer(serializers.ModelSerializer):
class SourceUsageSerializer(serializers.ModelSerializer): class SourceUsageSerializer(serializers.ModelSerializer):
"""Serializer for SourceUsage model""" """Serializer for SourceUsage model"""
source_name = serializers.CharField(source='source.name_en', read_only=True) source_name = serializers.CharField(source='source.name_en', read_only=True)
source_code = serializers.CharField(source='source.code', read_only=True)
content_type_name = serializers.CharField(source='content_type.model', read_only=True) content_type_name = serializers.CharField(source='content_type.model', read_only=True)
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
user_email = serializers.EmailField(source='user.email', read_only=True)
class Meta: class Meta:
model = PXSource model = PXSource
fields = [ fields = [
'id', 'source', 'source_name', 'id', 'source', 'source_code', 'source_name',
'content_type', 'content_type_name', 'object_id', 'content_type', 'content_type_name', 'object_id',
'hospital', 'user', 'created_at' 'hospital', 'hospital_name', 'user', 'user_email', 'created_at'
] ]
read_only_fields = ['id', 'created_at'] read_only_fields = ['id', 'created_at']
@ -103,6 +119,7 @@ class SourceUsageSerializer(serializers.ModelSerializer):
class PXSourceChoiceSerializer(serializers.Serializer): class PXSourceChoiceSerializer(serializers.Serializer):
"""Simple serializer for dropdown choices""" """Simple serializer for dropdown choices"""
id = serializers.UUIDField() id = serializers.UUIDField()
code = serializers.CharField()
name = serializers.SerializerMethodField() name = serializers.SerializerMethodField()
def get_name(self, obj): def get_name(self, obj):

View File

@ -1,24 +1,37 @@
""" """
PX Sources signals PX Sources signals - Auto-create SourceUsage records
This module defines signals for the PX Sources app.
Currently, this is a placeholder for future signal implementations.
""" """
from django.db.models.signals import post_save, post_delete from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.contrib.contenttypes.models import ContentType
# Placeholder for future signal implementations @receiver(post_save, sender='complaints.Complaint')
# Example signals could include: @receiver(post_save, sender='complaints.Inquiry')
# - Logging when a source is created/updated/deleted def create_source_usage(sender, instance, created, **kwargs):
# - Invalidating caches when sources change
# - Sending notifications when sources are deactivated
@receiver(post_save)
def log_source_activity(sender, instance, created, **kwargs):
""" """
Log source activity for audit purposes. Auto-create SourceUsage when Complaint/Inquiry has a source.
This signal is handled in the views.py via AuditService.
This ensures all feedback from PX sources is tracked for analytics.
""" """
pass if created and hasattr(instance, 'source') and instance.source_id:
# Get content type for the instance
content_type = ContentType.objects.get_for_model(instance)
# Create or get the SourceUsage record
from .models import SourceUsage
SourceUsage.objects.get_or_create(
source_id=instance.source_id,
content_type=content_type,
object_id=instance.id,
defaults={
'hospital_id': getattr(instance, 'hospital_id', None),
'user_id': getattr(instance, 'created_by_id', None),
}
)
# Update cached stats on the source
if hasattr(instance.source, 'update_usage_stats'):
# Use transaction.on_commit to update stats after transaction completes
from django.db import transaction
transaction.on_commit(lambda: instance.source.update_usage_stats())

View File

@ -12,6 +12,11 @@ from .models import PXSource, SourceUser
from apps.accounts.models import User from apps.accounts.models import User
def check_source_permission(user):
"""Check if user has permission to manage sources"""
return user.is_px_admin() or user.is_hospital_admin()
@login_required @login_required
def source_list(request): def source_list(request):
""" """
@ -30,7 +35,8 @@ def source_list(request):
sources = sources.filter( sources = sources.filter(
models.Q(name_en__icontains=search) | models.Q(name_en__icontains=search) |
models.Q(name_ar__icontains=search) | models.Q(name_ar__icontains=search) |
models.Q(description__icontains=search) models.Q(description__icontains=search) |
models.Q(code__icontains=search)
) )
sources = sources.order_by('name_en') sources = sources.order_by('name_en')
@ -61,11 +67,15 @@ def source_detail(request, pk):
assigned_user_ids = source_users.values_list('user_id', flat=True) assigned_user_ids = source_users.values_list('user_id', flat=True)
available_users = User.objects.exclude(id__in=assigned_user_ids).order_by('email') available_users = User.objects.exclude(id__in=assigned_user_ids).order_by('email')
# Get usage stats
usage_stats = source.get_usage_stats(days=30)
context = { context = {
'source': source, 'source': source,
'usage_records': usage_records, 'usage_records': usage_records,
'source_users': source_users, 'source_users': source_users,
'available_users': available_users, 'available_users': available_users,
'usage_stats': usage_stats,
} }
return render(request, 'px_sources/source_detail.html', context) return render(request, 'px_sources/source_detail.html', context)
@ -76,16 +86,20 @@ def source_create(request):
""" """
Create a new PX source Create a new PX source
""" """
# if not (request.user.is_px_admin() or request.user.is_hospital_admin()): if not check_source_permission(request.user):
# messages.error(request, _("You don't have permission to create sources.")) messages.error(request, _("You don't have permission to create sources."))
# return redirect('px_sources:source_list') return redirect('px_sources:source_list')
if request.method == 'POST': if request.method == 'POST':
try: try:
source = PXSource( source = PXSource(
code=request.POST.get('code', ''),
name_en=request.POST.get('name_en'), name_en=request.POST.get('name_en'),
name_ar=request.POST.get('name_ar', ''), name_ar=request.POST.get('name_ar', ''),
description=request.POST.get('description', ''), description=request.POST.get('description', ''),
source_type=request.POST.get('source_type', 'internal'),
contact_email=request.POST.get('contact_email', ''),
contact_phone=request.POST.get('contact_phone', ''),
is_active=request.POST.get('is_active') == 'on', is_active=request.POST.get('is_active') == 'on',
) )
source.save() source.save()
@ -96,7 +110,9 @@ def source_create(request):
except Exception as e: except Exception as e:
messages.error(request, _("Error creating source: {}").format(str(e))) messages.error(request, _("Error creating source: {}").format(str(e)))
context = {} context = {
'source_types': PXSource.SOURCE_TYPE_CHOICES,
}
return render(request, 'px_sources/source_form.html', context) return render(request, 'px_sources/source_form.html', context)
@ -106,7 +122,7 @@ def source_edit(request, pk):
""" """
Edit an existing PX source Edit an existing PX source
""" """
if not (request.user.is_px_admin() or request.user.is_hospital_admin()): if not check_source_permission(request.user):
messages.error(request, _("You don't have permission to edit sources.")) messages.error(request, _("You don't have permission to edit sources."))
return redirect('px_sources:source_detail', pk=pk) return redirect('px_sources:source_detail', pk=pk)
@ -114,9 +130,13 @@ def source_edit(request, pk):
if request.method == 'POST': if request.method == 'POST':
try: try:
source.code = request.POST.get('code', source.code)
source.name_en = request.POST.get('name_en') source.name_en = request.POST.get('name_en')
source.name_ar = request.POST.get('name_ar', '') source.name_ar = request.POST.get('name_ar', '')
source.description = request.POST.get('description', '') source.description = request.POST.get('description', '')
source.source_type = request.POST.get('source_type', 'internal')
source.contact_email = request.POST.get('contact_email', '')
source.contact_phone = request.POST.get('contact_phone', '')
source.is_active = request.POST.get('is_active') == 'on' source.is_active = request.POST.get('is_active') == 'on'
source.save() source.save()
@ -128,6 +148,7 @@ def source_edit(request, pk):
context = { context = {
'source': source, 'source': source,
'source_types': PXSource.SOURCE_TYPE_CHOICES,
} }
return render(request, 'px_sources/source_form.html', context) return render(request, 'px_sources/source_form.html', context)

View File

@ -143,10 +143,32 @@ def survey_form(request, token):
elif question.question_type == 'multiple_choice': elif question.question_type == 'multiple_choice':
choice_value = request.POST.get(field_name) choice_value = request.POST.get(field_name)
if choice_value: if choice_value:
# Find the selected choice to get its label
selected_choice = None
for choice in question.choices_json:
if str(choice.get('value', '')) == str(choice_value):
selected_choice = choice
break
# Get the label based on language
language = request.POST.get('language', 'en')
if language == 'ar' and selected_choice and selected_choice.get('label_ar'):
text_value = selected_choice['label_ar']
elif selected_choice and selected_choice.get('label'):
text_value = selected_choice['label']
else:
text_value = choice_value
# Try to convert choice value to numeric for scoring
try:
numeric_value = float(choice_value)
except (ValueError, TypeError):
numeric_value = None
responses_data.append({ responses_data.append({
'question': question, 'question': question,
'numeric_value': None, 'numeric_value': numeric_value,
'text_value': '', 'text_value': text_value,
'choice_value': choice_value 'choice_value': choice_value
}) })

View File

@ -160,17 +160,48 @@ class SurveySubmissionSerializer(serializers.Serializer):
survey_instance = self.context['survey_instance'] survey_instance = self.context['survey_instance']
responses_data = validated_data['responses'] responses_data = validated_data['responses']
from apps.surveys.models import SurveyResponse from apps.surveys.models import SurveyResponse, SurveyQuestion
from django.utils import timezone from django.utils import timezone
# Create responses # Create responses
for response_data in responses_data: for response_data in responses_data:
question_id = response_data['question_id']
choice_value = response_data.get('choice_value', '')
numeric_value = response_data.get('numeric_value')
text_value = response_data.get('text_value', '')
# For multiple_choice with choice_value but missing numeric/text values,
# look up the choice details from the question
if choice_value and numeric_value is None:
try:
question = SurveyQuestion.objects.get(id=question_id)
if question.question_type == 'multiple_choice':
# Find the selected choice to get its label
selected_choice = None
for choice in question.choices_json:
if str(choice.get('value', '')) == str(choice_value):
selected_choice = choice
break
# Set text_value from choice label if not provided
if not text_value and selected_choice:
text_value = selected_choice.get('label', choice_value)
# Set numeric_value from choice value if it's numeric
if numeric_value is None:
try:
numeric_value = float(choice_value)
except (ValueError, TypeError):
numeric_value = None
except SurveyQuestion.DoesNotExist:
pass
SurveyResponse.objects.create( SurveyResponse.objects.create(
survey_instance=survey_instance, survey_instance=survey_instance,
question_id=response_data['question_id'], question_id=question_id,
numeric_value=response_data.get('numeric_value'), numeric_value=numeric_value,
text_value=response_data.get('text_value', ''), text_value=text_value,
choice_value=response_data.get('choice_value', ''), choice_value=choice_value,
response_time_seconds=response_data.get('response_time_seconds') response_time_seconds=response_data.get('response_time_seconds')
) )

View File

@ -329,8 +329,7 @@ def create_action_from_negative_survey(survey_instance_id):
survey = SurveyInstance.objects.select_related( survey = SurveyInstance.objects.select_related(
'survey_template', 'survey_template',
'patient', 'patient',
'hospital', 'hospital'
'department'
).get(id=survey_instance_id) ).get(id=survey_instance_id)
# Verify survey is negative # Verify survey is negative
@ -404,7 +403,7 @@ def create_action_from_negative_survey(survey_instance_id):
title=f"Negative Survey: {survey.survey_template.name} (Score: {score:.1f})", title=f"Negative Survey: {survey.survey_template.name} (Score: {score:.1f})",
description=description, description=description,
hospital=survey.hospital, hospital=survey.hospital,
department=survey.department, department=None,
category=category, category=category,
priority=priority, priority=priority,
severity=severity, severity=severity,

View File

@ -0,0 +1,36 @@
"""
Custom Celery Beat scheduler to fix Python 3.12 zoneinfo compatibility issue.
This patches the _default_now method to work with zoneinfo.ZoneInfo instead of pytz.
"""
from django_celery_beat.schedulers import DatabaseScheduler, ModelEntry
class PatchedModelEntry(ModelEntry):
"""
Custom model entry that fixes the zoneinfo.ZoneInfo compatibility issue.
"""
def _default_now(self):
"""
Return the current time in the configured timezone.
This fixes the AttributeError: 'zoneinfo.ZoneInfo' object has no attribute 'localize'
"""
from django.utils import timezone
return timezone.now()
class PatchedDatabaseScheduler(DatabaseScheduler):
"""
Custom scheduler that fixes the zoneinfo.ZoneInfo compatibility issue
in django-celery-beat 2.1.0 with Python 3.12.
"""
Entry = PatchedModelEntry
def _default_now(self):
"""
Return the current time in the configured timezone.
This fixes the AttributeError: 'zoneinfo.ZoneInfo' object has no attribute 'localize'
"""
from django.utils import timezone
return timezone.now()

View File

@ -251,7 +251,7 @@ CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes
# Celery Beat Schedule # Celery Beat Schedule
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' CELERY_BEAT_SCHEDULER = 'config.celery_scheduler:PatchedDatabaseScheduler'
# Logging Configuration # Logging Configuration
LOGGING = { LOGGING = {

View File

@ -25,8 +25,8 @@ urlpatterns = [
path('core/', include('apps.core.urls', namespace='core')), path('core/', include('apps.core.urls', namespace='core')),
# UI Pages # UI Pages
path('complaints/', include('apps.complaints.urls', namespace='complaints')), path('complaints/', include('apps.complaints.urls')),
path('physicians/', include('apps.physicians.urls')), path('physicians/', include('apps.physicians.urls', namespace='physicians')),
path('feedback/', include('apps.feedback.urls')), path('feedback/', include('apps.feedback.urls')),
path('actions/', include('apps.px_action_center.urls')), path('actions/', include('apps.px_action_center.urls')),
path('accounts/', include('apps.accounts.urls', namespace='accounts')), path('accounts/', include('apps.accounts.urls', namespace='accounts')),
@ -41,18 +41,18 @@ urlpatterns = [
path('ai-engine/', include('apps.ai_engine.urls')), path('ai-engine/', include('apps.ai_engine.urls')),
path('appreciation/', include('apps.appreciation.urls', namespace='appreciation')), path('appreciation/', include('apps.appreciation.urls', namespace='appreciation')),
path('notifications/', include('apps.notifications.urls', namespace='notifications')), path('notifications/', include('apps.notifications.urls', namespace='notifications')),
path('observations/', include('apps.observations.urls', namespace='observations')), path('observations/', include('apps.observations.urls')),
path('px-sources/', include('apps.px_sources.urls')), path('px-sources/', include('apps.px_sources.urls')),
path('references/', include('apps.references.urls', namespace='references')), path('references/', include('apps.references.urls', namespace='references')),
path('standards/', include('apps.standards.urls', namespace='standards')), path('standards/', include('apps.standards.urls')),
# API endpoints # API endpoints
path('api/auth/', include('apps.accounts.urls', namespace='api_auth')), path('api/auth/', include('apps.accounts.urls', namespace='api_auth')),
path('api/physicians/', include('apps.physicians.urls')), path('api/physicians/', include('apps.physicians.urls', namespace='api_physicians')),
path('api/integrations/', include('apps.integrations.urls')), path('api/integrations/', include('apps.integrations.urls')),
path('api/notifications/', include('apps.notifications.urls')), path('api/notifications/', include('apps.notifications.urls', namespace='api_notifications')),
path('api/v1/appreciation/', include('apps.appreciation.urls', namespace='api_appreciation')), path('api/v1/appreciation/', include('apps.appreciation.urls', namespace='api_appreciation')),
path('api/simulator/', include('apps.simulator.urls', namespace='simulator')), path('api/simulator/', include('apps.simulator.urls', namespace='api_simulator')),
# OpenAPI/Swagger documentation # OpenAPI/Swagger documentation
path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/', SpectacularAPIView.as_view(), name='schema'),

View File

@ -0,0 +1,195 @@
[
{
"model": "accounts.user",
"pk": "e4a79042-cd54-45ff-9bf5-d775bcb9fb92",
"fields": {
"password": "pbkdf2_sha256$1200000$jlJ1yPpFCioF510zF41nvd$LZeZlcG2wHPippD8ugCE6D00UbvwtlWniIfRFwY6gd8=",
"last_login": "2026-02-25T03:59:57.279Z",
"is_superuser": false,
"first_name": "",
"last_name": "",
"is_staff": false,
"date_joined": "2026-02-25T03:58:30.702Z",
"created_at": "2026-02-25T03:58:31.405Z",
"updated_at": "2026-02-25T03:59:32.986Z",
"email": "admin@hh.sa",
"username": "",
"phone": "",
"employee_id": "",
"hospital": "4ed13883-9632-4534-a007-4942258a5943",
"department": null,
"avatar": "",
"bio": "",
"language": "en",
"notification_email_enabled": true,
"notification_sms_enabled": false,
"preferred_notification_channel": "email",
"explanation_notification_channel": "email",
"is_active": true,
"is_provisional": false,
"invitation_token": null,
"invitation_expires_at": null,
"acknowledgement_completed": false,
"acknowledgement_completed_at": null,
"current_wizard_step": 0,
"wizard_completed_steps": [],
"groups": [
1,
2
],
"user_permissions": []
}
},
{
"model": "accounts.user",
"pk": "f6d00141-47c6-4c96-863e-e91c39e2da87",
"fields": {
"password": "pbkdf2_sha256$1200000$bDscjx2gPBbHeAPFwRIi8b$Hgj8Ce+H5TeCquyweT9MA7WlyJOCtwYdN26NmMJ3EEM=",
"last_login": "2026-02-25T04:12:09.315Z",
"is_superuser": true,
"first_name": "",
"last_name": "",
"is_staff": true,
"date_joined": "2026-02-25T03:57:07.977Z",
"created_at": "2026-02-25T03:57:08.653Z",
"updated_at": "2026-02-25T03:57:08.653Z",
"email": "ismail@tenhal.sa",
"username": "",
"phone": "",
"employee_id": "",
"hospital": null,
"department": null,
"avatar": "",
"bio": "",
"language": "en",
"notification_email_enabled": true,
"notification_sms_enabled": false,
"preferred_notification_channel": "email",
"explanation_notification_channel": "email",
"is_active": true,
"is_provisional": false,
"invitation_token": null,
"invitation_expires_at": null,
"acknowledgement_completed": false,
"acknowledgement_completed_at": null,
"current_wizard_step": 0,
"wizard_completed_steps": [],
"groups": [],
"user_permissions": []
}
},
{
"model": "accounts.user",
"pk": "a5d0077d-f2a7-4069-93fd-d35bdc20d314",
"fields": {
"password": "pbkdf2_sha256$1200000$aTXoF4T51QdQgp1QCw9apP$h5sEYLfp3Re9H2dWEc4aHjLPHwnlKgG9PFol0oE18Us=",
"last_login": null,
"is_superuser": false,
"first_name": "Amaal",
"last_name": "Al Otaibi",
"is_staff": true,
"date_joined": "2026-02-25T03:54:37.064Z",
"created_at": "2026-02-25T03:54:37.065Z",
"updated_at": "2026-02-25T03:54:37.742Z",
"email": "amaal@example.com",
"username": "amaal",
"phone": "",
"employee_id": "",
"hospital": null,
"department": null,
"avatar": "",
"bio": "",
"language": "en",
"notification_email_enabled": true,
"notification_sms_enabled": false,
"preferred_notification_channel": "email",
"explanation_notification_channel": "email",
"is_active": true,
"is_provisional": false,
"invitation_token": null,
"invitation_expires_at": null,
"acknowledgement_completed": false,
"acknowledgement_completed_at": null,
"current_wizard_step": 0,
"wizard_completed_steps": [],
"groups": [],
"user_permissions": []
}
},
{
"model": "accounts.user",
"pk": "d3919bb3-37a2-4cfb-9efa-5ca2b67ba91d",
"fields": {
"password": "pbkdf2_sha256$1200000$Sv7iDXpGVYzXw4BpFqXqjE$Ioq0F6Y2nly/eFwBI22KAk44qZqbhjkwSaN3hIhhxMQ=",
"last_login": null,
"is_superuser": false,
"first_name": "Abrar",
"last_name": "Al Qahtani",
"is_staff": true,
"date_joined": "2026-02-25T03:54:36.403Z",
"created_at": "2026-02-25T03:54:36.403Z",
"updated_at": "2026-02-25T03:54:37.056Z",
"email": "abrar@example.com",
"username": "abrar",
"phone": "",
"employee_id": "",
"hospital": null,
"department": null,
"avatar": "",
"bio": "",
"language": "en",
"notification_email_enabled": true,
"notification_sms_enabled": false,
"preferred_notification_channel": "email",
"explanation_notification_channel": "email",
"is_active": true,
"is_provisional": false,
"invitation_token": null,
"invitation_expires_at": null,
"acknowledgement_completed": false,
"acknowledgement_completed_at": null,
"current_wizard_step": 0,
"wizard_completed_steps": [],
"groups": [],
"user_permissions": []
}
},
{
"model": "accounts.user",
"pk": "09a84636-411a-4cd2-aa26-80abd29b1dfd",
"fields": {
"password": "pbkdf2_sha256$1200000$B6QHfxbMf2KC4kwdU4sVTf$3CLPDxTIhk6HujEaBJqTA9XdUWjqiywnrqXSsoi+ZJI=",
"last_login": null,
"is_superuser": false,
"first_name": "Rahaf",
"last_name": "Al Saud",
"is_staff": true,
"date_joined": "2026-02-25T03:54:35.738Z",
"created_at": "2026-02-25T03:54:35.738Z",
"updated_at": "2026-02-25T03:54:36.382Z",
"email": "rahaf@example.com",
"username": "rahaf",
"phone": "",
"employee_id": "",
"hospital": null,
"department": null,
"avatar": "",
"bio": "",
"language": "en",
"notification_email_enabled": true,
"notification_sms_enabled": false,
"preferred_notification_channel": "email",
"explanation_notification_channel": "email",
"is_active": true,
"is_provisional": false,
"invitation_token": null,
"invitation_expires_at": null,
"acknowledgement_completed": false,
"acknowledgement_completed_at": null,
"current_wizard_step": 0,
"wizard_completed_steps": [],
"groups": [],
"user_permissions": []
}
}
]

View File

@ -0,0 +1,776 @@
[
{
"model": "ai_engine.sentimentresult",
"pk": "ea4d89ac-33ee-4101-8f83-611c493ee679",
"fields": {
"created_at": "2026-02-25T05:09:40.733Z",
"updated_at": "2026-02-25T05:09:40.733Z",
"content_type": 57,
"object_id": "44e5ce09-a956-474a-a46e-9b79508c6633",
"text": "Complaint activated and assigned to ",
"language": "en",
"sentiment": "negative",
"sentiment_score": "-1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"complaint"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "fa5878fb-afc3-4ea0-9027-fd26bf53f9fd",
"fields": {
"created_at": "2026-02-25T05:09:40.722Z",
"updated_at": "2026-02-25T05:09:40.722Z",
"content_type": 42,
"object_id": "8bbf4733-34c8-4dc0-a440-50ac4ab90a22",
"text": "Despite pressing the call button multiple times, nurse Fahd Al-Shehri did not respond for over 30 minutes. When she finally arrived, she was annoyed and unhelpful. This level of neglect is unacceptable in a healthcare setting.",
"language": "en",
"sentiment": "positive",
"sentiment_score": "1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 1,
"keywords": [
"helpful"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "72c5e3cc-fd0f-4c6b-b78a-2a7685cba595",
"fields": {
"created_at": "2026-02-25T03:55:42.061Z",
"updated_at": "2026-02-25T03:55:42.061Z",
"content_type": 57,
"object_id": "cfdae30f-9ce2-4484-9363-b98cc19a1418",
"text": "Complaint created and registered",
"language": "en",
"sentiment": "negative",
"sentiment_score": "-1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"complaint"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "95322885-d654-4b2b-96b8-bebd006b6eda",
"fields": {
"created_at": "2026-02-25T03:55:42.059Z",
"updated_at": "2026-02-25T03:55:42.059Z",
"content_type": 42,
"object_id": "8bbf4733-34c8-4dc0-a440-50ac4ab90a22",
"text": "Despite pressing the call button multiple times, nurse Fahd Al-Shehri did not respond for over 30 minutes. When she finally arrived, she was annoyed and unhelpful. This level of neglect is unacceptable in a healthcare setting.",
"language": "en",
"sentiment": "positive",
"sentiment_score": "1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"helpful"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "d84a5a6c-f657-4d18-8e26-b77cda67284d",
"fields": {
"created_at": "2026-02-25T03:55:42.045Z",
"updated_at": "2026-02-25T03:55:42.045Z",
"content_type": 57,
"object_id": "7295f18f-83c8-4542-94ad-39efca68d7fe",
"text": "Complaint created and registered",
"language": "en",
"sentiment": "negative",
"sentiment_score": "-1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"complaint"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "382d0da3-1fd3-40b7-8b6d-7265f71795ec",
"fields": {
"created_at": "2026-02-25T03:55:42.042Z",
"updated_at": "2026-02-25T03:55:42.042Z",
"content_type": 42,
"object_id": "1f76952f-4fb6-4eaf-9fd8-2cc441e4ca07",
"text": "Dr. Noura Al-Harbi misdiagnosed my condition and prescribed wrong medication. I had to suffer for 3 more days before another doctor caught the error. This negligence is unacceptable and needs to be addressed immediately.",
"language": "en",
"sentiment": "neutral",
"sentiment_score": "0.0000",
"confidence": "0.5000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "b3ed5f77-0be3-437a-ac22-72c96a40a346",
"fields": {
"created_at": "2026-02-25T03:55:42.017Z",
"updated_at": "2026-02-25T03:55:42.017Z",
"content_type": 57,
"object_id": "56287023-2c03-4eda-b168-788a1a079cea",
"text": "Complaint created and registered",
"language": "en",
"sentiment": "negative",
"sentiment_score": "-1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"complaint"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "8337d5c0-5e53-4f51-86c0-f0a7954139ea",
"fields": {
"created_at": "2026-02-25T03:55:42.009Z",
"updated_at": "2026-02-25T03:55:42.009Z",
"content_type": 42,
"object_id": "f4096f2b-81ba-4eee-8ac8-3ad0bded1797",
"text": "Dr. Mishari Al-Fahad provided exceptional care and took the time to thoroughly explain my condition and treatment options. His expertise and bedside manner were outstanding.",
"language": "en",
"sentiment": "positive",
"sentiment_score": "1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"outstanding"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "71863e9f-5168-44e6-bc11-f2880645a301",
"fields": {
"created_at": "2026-02-25T03:55:42.006Z",
"updated_at": "2026-02-25T03:55:42.006Z",
"content_type": 57,
"object_id": "482902ef-7711-43a3-9ed9-a3b56369e591",
"text": "SMS notification sent to complainant: Your complaint has been received",
"language": "en",
"sentiment": "negative",
"sentiment_score": "-1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"complaint"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "f5ab6f53-6075-42c4-a08f-0274d33b8cb6",
"fields": {
"created_at": "2026-02-25T03:55:36.962Z",
"updated_at": "2026-02-25T03:55:36.962Z",
"content_type": 57,
"object_id": "0db0aac0-bbfb-4aaa-b648-e0aff5b3479d",
"text": "Complaint created and registered",
"language": "en",
"sentiment": "negative",
"sentiment_score": "-1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"complaint"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "adb28a27-56df-4ee2-a12b-01208399fb5d",
"fields": {
"created_at": "2026-02-25T03:55:36.957Z",
"updated_at": "2026-02-25T03:55:36.957Z",
"content_type": 42,
"object_id": "1fec51ec-ab1c-4c04-9cc4-7d3cf371940d",
"text": "هناك مواقف سيارات محدودة جداً للزوار. اضطررت للدوران عدة مرات لإيجاد مكان وتأخرت عن موعدي. هذا يجب معالجته.",
"language": "ar",
"sentiment": "neutral",
"sentiment_score": "0.0000",
"confidence": "0.5000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "4151d62a-1de3-47f7-bf32-e8bd29293853",
"fields": {
"created_at": "2026-02-25T03:55:36.941Z",
"updated_at": "2026-02-25T03:55:36.941Z",
"content_type": 57,
"object_id": "4fd1fc88-7e8e-4602-ae4c-bd7a6778438a",
"text": "Complaint created and registered",
"language": "en",
"sentiment": "negative",
"sentiment_score": "-1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"complaint"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "b0848bb7-2868-4706-b308-f6147ebba138",
"fields": {
"created_at": "2026-02-25T03:55:36.938Z",
"updated_at": "2026-02-25T03:55:36.938Z",
"content_type": 42,
"object_id": "9da3b845-c425-4bf1-b413-1d10fc1a150d",
"text": "أريد أن أعبر عن تقديري للممرضة جواهر الحربي التي بذلت ما هو أبعد من المتوقع لجعلي مرتاحاً خلال إقامتي. كلمتها اللطيفة والراعية جعلت الموقف الصعب أكثر قابلية للتحمل.",
"language": "ar",
"sentiment": "positive",
"sentiment_score": "1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"لطيف"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "88b78567-89a1-4513-a792-4796b892dde2",
"fields": {
"created_at": "2026-02-25T03:55:36.937Z",
"updated_at": "2026-02-25T03:55:36.937Z",
"content_type": 57,
"object_id": "7a094b69-80bf-4779-8dea-a0164186df14",
"text": "SMS notification sent to complainant: Your complaint has been received",
"language": "en",
"sentiment": "negative",
"sentiment_score": "-1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"complaint"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "4e4b9d1c-b790-4b44-9f2f-3b072f0495a9",
"fields": {
"created_at": "2026-02-25T03:55:31.892Z",
"updated_at": "2026-02-25T03:55:31.893Z",
"content_type": 57,
"object_id": "4512b975-e55c-4a6b-9d97-710e855ba791",
"text": "Complaint created and registered",
"language": "en",
"sentiment": "negative",
"sentiment_score": "-1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"complaint"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "19883722-8435-4e3a-9edf-b36eb65455a3",
"fields": {
"created_at": "2026-02-25T03:55:31.883Z",
"updated_at": "2026-02-25T03:55:31.883Z",
"content_type": 42,
"object_id": "c674b99c-06b3-40c2-9812-0fec40d43ea5",
"text": "جودة طعام المستشفى انخفضت بشكل كبير. الوجبات غالباً باردة وغير شهية ولا تلبي المتطلبات الغذائية. هذا يؤثر على رضا المرضى.",
"language": "ar",
"sentiment": "neutral",
"sentiment_score": "0.0000",
"confidence": "0.5000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "f47bbd0f-3f0b-400f-8b56-b8fb3bbb83e0",
"fields": {
"created_at": "2026-02-25T03:55:31.867Z",
"updated_at": "2026-02-25T03:55:31.867Z",
"content_type": 57,
"object_id": "7237e1e7-0a25-4057-98ec-b1ac5e218c4f",
"text": "Complaint created and registered",
"language": "en",
"sentiment": "negative",
"sentiment_score": "-1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"complaint"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "0f6095d8-0db3-4782-ab0e-50fb6da57077",
"fields": {
"created_at": "2026-02-25T03:55:31.863Z",
"updated_at": "2026-02-25T03:55:31.863Z",
"content_type": 42,
"object_id": "c020e66f-0f9a-42ac-9b86-a740d340cd2d",
"text": "عندما تم قبولي في غرفتي، لم تكن نظيفة بشكل صحيح. كان هناك غبار على الأسطح وحمام غير صحي. هذا مصدر قلق لسلامة المرضى.",
"language": "ar",
"sentiment": "positive",
"sentiment_score": "1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"نظيف"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.2,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "726b916a-7804-4d32-9fef-6cfcb9518599",
"fields": {
"created_at": "2026-02-25T03:55:31.851Z",
"updated_at": "2026-02-25T03:55:31.852Z",
"content_type": 57,
"object_id": "09cd1726-8993-4ae4-bae9-40702333d800",
"text": "Complaint created and registered",
"language": "en",
"sentiment": "negative",
"sentiment_score": "-1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"complaint"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "c5e1627d-825b-4ea0-b0b7-26bb0008c721",
"fields": {
"created_at": "2026-02-25T03:55:31.848Z",
"updated_at": "2026-02-25T03:55:31.848Z",
"content_type": 42,
"object_id": "312155d3-b384-4e6f-b2e7-f8bd21e7817b",
"text": "عندما تم قبولي في غرفتي، لم تكن نظيفة بشكل صحيح. كان هناك غبار على الأسطح وحمام غير صحي. هذا مصدر قلق لسلامة المرضى.",
"language": "ar",
"sentiment": "positive",
"sentiment_score": "1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"نظيف"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.2,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "f0c8d663-8f7b-40d4-8429-deb6b88e0556",
"fields": {
"created_at": "2026-02-25T03:55:31.846Z",
"updated_at": "2026-02-25T03:55:31.846Z",
"content_type": 57,
"object_id": "f226476d-c5e0-4726-8ece-1cfab6bfa0ff",
"text": "SMS notification sent to complainant: Your complaint has been received",
"language": "en",
"sentiment": "negative",
"sentiment_score": "-1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"complaint"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "cef2911a-be2c-46a7-9d2a-67d524e6d880",
"fields": {
"created_at": "2026-02-25T03:55:26.807Z",
"updated_at": "2026-02-25T03:55:26.807Z",
"content_type": 57,
"object_id": "d5fb457f-7d7b-4485-b0fe-e0b6ce52880c",
"text": "Complaint created and registered",
"language": "en",
"sentiment": "negative",
"sentiment_score": "-1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"complaint"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "66e6d7dd-0dbc-4467-8c41-bda4e6a20563",
"fields": {
"created_at": "2026-02-25T03:55:26.796Z",
"updated_at": "2026-02-25T03:55:26.796Z",
"content_type": 42,
"object_id": "4f11f759-8061-427d-899b-e0c360d11a20",
"text": "على الرغم من الضغط على زر الاستدعاء عدة مرات، لم تستجب الممرضة نورة الفالح لأكثر من 30 دقيقة. عندما وصلت أخيراً، كانت منزعجة وغير مفيدة. هذا مستوى من الإهمال غير مقبول في بيئة الرعاية الصحية.",
"language": "ar",
"sentiment": "positive",
"sentiment_score": "1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"مفيد"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "9a213f8f-02c7-4ad3-9b29-2fe35d09b40d",
"fields": {
"created_at": "2026-02-25T03:55:26.790Z",
"updated_at": "2026-02-25T03:55:26.791Z",
"content_type": 57,
"object_id": "51ec2c79-a504-437c-97a3-ddce7f1d65d8",
"text": "SMS notification sent to complainant: Your complaint has been received",
"language": "en",
"sentiment": "negative",
"sentiment_score": "-1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 2,
"keywords": [
"complaint"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "603917ee-a90b-4cd1-a8b6-06ee32e9f8ab",
"fields": {
"created_at": "2026-02-25T03:55:21.596Z",
"updated_at": "2026-02-25T03:55:21.596Z",
"content_type": 57,
"object_id": "dbb70f16-8f80-4a63-8207-cd55bf2e8226",
"text": "Complaint created and registered",
"language": "en",
"sentiment": "negative",
"sentiment_score": "-1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"complaint"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
},
{
"model": "ai_engine.sentimentresult",
"pk": "7b7440ee-5de5-46b3-81b4-37ac9d5d0680",
"fields": {
"created_at": "2026-02-25T03:55:21.592Z",
"updated_at": "2026-02-25T03:55:21.592Z",
"content_type": 42,
"object_id": "75699d55-58b8-42d2-94c3-ff479a005dad",
"text": "أريد أن أعبر عن تقديري للممرضة علي العنزي التي بذلت ما هو أبعد من المتوقع لجعلي مرتاحاً خلال إقامتي. كلمتها اللطيفة والراعية جعلت الموقف الصعب أكثر قابلية للتحمل.",
"language": "ar",
"sentiment": "positive",
"sentiment_score": "1.0000",
"confidence": "0.3000",
"ai_service": "stub",
"ai_model": "keyword_matching_v1",
"processing_time_ms": 0,
"keywords": [
"لطيف"
],
"entities": [],
"emotions": {
"joy": 0.0,
"anger": 0.0,
"sadness": 0.0,
"fear": 0.0,
"surprise": 0.0
},
"metadata": {}
}
}
]

View File

@ -0,0 +1,889 @@
[
{
"model": "analytics.kpireport",
"pk": "56af32e0-a5b2-4d38-8b18-bd0a06805237",
"fields": {
"created_at": "2026-02-25T05:06:53.918Z",
"updated_at": "2026-02-25T05:06:54.114Z",
"report_type": "activation_2h",
"hospital": "4ed13883-9632-4534-a007-4942258a5943",
"year": 2026,
"month": 2,
"report_date": "2026-02-25",
"status": "completed",
"generated_by": "e4a79042-cd54-45ff-9bf5-d775bcb9fb92",
"generated_at": "2026-02-25T05:06:54.114Z",
"target_percentage": "95.00",
"category": "Organizational",
"kpi_type": "Outcome",
"risk_level": "High",
"data_collection_method": "Retrospective",
"data_collection_frequency": "Monthly",
"reporting_frequency": "Monthly",
"dimension": "Efficiency",
"collector_name": "",
"analyzer_name": "",
"total_numerator": 0,
"total_denominator": 10,
"overall_result": "0.00",
"error_message": "",
"ai_analysis": null,
"ai_analysis_generated_at": null
}
},
{
"model": "analytics.kpireport",
"pk": "88795c74-4a05-4030-bd81-24b844564b3c",
"fields": {
"created_at": "2026-02-25T05:05:17.626Z",
"updated_at": "2026-02-25T05:05:17.892Z",
"report_type": "resolution_72h",
"hospital": "4ed13883-9632-4534-a007-4942258a5943",
"year": 2026,
"month": 2,
"report_date": "2026-02-25",
"status": "completed",
"generated_by": "e4a79042-cd54-45ff-9bf5-d775bcb9fb92",
"generated_at": "2026-02-25T05:05:17.892Z",
"target_percentage": "95.00",
"category": "Organizational",
"kpi_type": "Outcome",
"risk_level": "High",
"data_collection_method": "Retrospective",
"data_collection_frequency": "Monthly",
"reporting_frequency": "Monthly",
"dimension": "Efficiency",
"collector_name": "",
"analyzer_name": "",
"total_numerator": 0,
"total_denominator": 10,
"overall_result": "0.00",
"error_message": "",
"ai_analysis": {
"executive_summary": "The 72-Hour Complaint Resolution KPI for Olaya Hospital in February 2026 shows a complete failure to meet targets, with a 0.00% resolution rate against a 95.00% target (0 out of 10 complaints resolved within 72 hours). All 10 complaints (from sources including Social Media 40%, Call Center 30%, Family Member 20%, and Staff 10%) remain unresolved, with no resolution times recorded in the breakdown (0 in Within 24h, Within 48h, Within 72h, or After 72h). This indicates a systemic issue in complaint handling, likely due to process gaps, lack of escalation, or resource constraints. Immediate intervention is required to address the backlog and prevent further deterioration.",
"performance_analysis": {
"overall_resolution_rate": "0.00%",
"target_resolution_rate": "95.00%",
"variance": "-95.00%",
"status": "below target",
"total_complaints": 10,
"resolved_within_72h": 0,
"performance_trend": {
"current_month": "0.00% (0/10)",
"previous_months_data": "Not provided, but current month shows no improvement from baseline",
"improvement_needed": "100% resolution rate required to meet target next month"
}
},
"key_findings": [
"Zero resolutions: No complaints (0 out of 10) were closed within or after 72 hours, indicating a complete process breakdown.",
"Uniform delay: All resolution time buckets (Within 24h, 48h, 72h, After 72h) show 0.00%, with no partial progress.",
"Source distribution: Top sources are Social Media (4 complaints, 40%) and Call Center (3 complaints, 30%), suggesting high visibility complaints are piling up.",
"Department ambiguity: Despite categories, all departments show 0 complaints, 0 resolved; no 'slow departments' flagged, implying no active tracking or misclassification.",
"Escalation and closure gaps: 0 escalated and 0 closed cases highlight lack of follow-up mechanisms.",
"Month-over-month stagnation: February 2026 mirrors the 0.00% rate, with no resolved cases from prior periods shown."
],
"reasons_for_delays": [
"Process failure: No recorded resolutions suggest absent or ineffective complaint intake, assignment, or tracking workflows, potentially due to unclear ownership.",
"Resource constraints: With 10 total complaints and zero actions, likely understaffing or lack of dedicated complaint resolution teams across departments.",
"Escalation inaction: 0 escalated cases indicate no escalation protocols are being followed, leading to indefinite hold on complaints.",
"Source-specific challenges: High Social Media (40%) complaints may require PR/Communication involvement, which appears unaddressed; Call Center (30%) might overload front-line staff without backend support.",
"Departmental silos: Zero counts in all departments (Medical, Nursing, Admin, Support) suggest complaints are not routed correctly or are being ignored at the department level.",
"External factors: No data on holidays/overload, but the uniform 0% resolution points to internal inefficiencies rather than volume spikes."
],
"resolution_time_analysis": {
"within_24h": {
"count": 0,
"percentage": "0.00%"
},
"within_48h": {
"count": 0,
"percentage": "0.00%"
},
"within_72h": {
"count": 0,
"percentage": "0.00%"
},
"after_72h": {
"count": 0,
"percentage": "0.00%"
},
"insights": "No complaints have progressed through any time stage, resulting in a frozen backlog. Without interventions, this will lead to increasing complaint ages and potential regulatory non-compliance."
},
"department_analysis": {
"non_medical_admin": {
"complaints": 0,
"resolved": 0,
"status": "No activity"
},
"medical_department": {
"complaints": 0,
"resolved": 0,
"status": "No activity"
},
"nursing_department": {
"complaints": 0,
"resolved": 0,
"status": "No activity"
},
"support_services": {
"complaints": 0,
"resolved": 0,
"status": "No activity"
},
"slow_departments": "None identified, but all departments show zero complaints, suggesting underreporting or lack of integration with complaint system.",
"overall_department_insight": "Cross-departmental inaction indicates a hospital-wide issue in complaint capture and resolution, rather than isolated inefficiencies."
},
"source_analysis": {
"social_media": {
"count": 4,
"percentage": "40.00%",
"insight": "Highest volume; requires immediate social media monitoring and response team to mitigate reputational risk."
},
"call_center": {
"count": 3,
"percentage": "30.00%",
"insight": "Significant portion; likely tied to patient access or service issues, needing better call center-to-department handoffs."
},
"family_member": {
"count": 2,
"percentage": "20.00%",
"insight": "Indirect complaints; may involve communication breakdowns with families, suggesting needs for family liaison protocols."
},
"staff": {
"count": 1,
"percentage": "10.00%",
"insight": "Internal complaints; points to potential employee satisfaction issues, which could impact morale if unresolved."
},
"source_summary": "Social Media and Call Center dominate (70% combined), highlighting external visibility of unresolved issues. No sources resolved, indicating universal process gap."
},
"recommendations": [
"Immediate backlog clearance: Assign a dedicated cross-functional team to triage and resolve all 10 pending complaints within 7 days, starting with high-visibility Social Media (4) and Call Center (3) cases.",
"Process enhancement: Implement automated complaint tracking with clear SLA alerts (e.g., alerts at 24h/48h) and mandatory escalation after 48 hours; train staff on intake protocols to ensure 100% capture and routing.",
"Resource allocation: Audit staffing levels in complaint handling; add temporary resources or redistribute admin/Nursing staff to handle peak volumes, aiming for at least 50% resolution within 48h next month.",
"Department accountability: Assign complaint owners per department (e.g., Medical for clinical complaints, Admin for non-medical); integrate complaint data into department KPIs to ensure visibility and drive accountability.",
"Source-specific actions: For Social Media, establish a 24/7 monitoring team; for Call Center, improve handoff checklists; for Family Member/Staff, introduce feedback loops and response templates.",
"Monitoring and review: Conduct weekly reviews of resolution rates; set interim target of 50% for March 2026, with root cause analysis for any complaints exceeding 72h.",
"Technology upgrade: If not in place, deploy a centralized CRM system to consolidate sources and automate resolution tracking, reducing manual errors."
],
"_metadata": {
"generated_at": "2026-02-25T05:05:39.218450+00:00",
"report_id": "88795c74-4a05-4030-bd81-24b844564b3c",
"report_type": "resolution_72h",
"hospital": "Olaya",
"year": 2026,
"month": 2
}
},
"ai_analysis_generated_at": "2026-02-25T05:05:39.218Z"
}
},
{
"model": "analytics.kpireport",
"pk": "3f450a3f-036b-4be6-b368-fc1c3d2dc2ff",
"fields": {
"created_at": "2026-02-25T05:06:34.229Z",
"updated_at": "2026-02-25T05:06:34.442Z",
"report_type": "satisfaction_resolution",
"hospital": "4ed13883-9632-4534-a007-4942258a5943",
"year": 2026,
"month": 2,
"report_date": "2026-02-25",
"status": "completed",
"generated_by": "e4a79042-cd54-45ff-9bf5-d775bcb9fb92",
"generated_at": "2026-02-25T05:06:34.442Z",
"target_percentage": "95.00",
"category": "Organizational",
"kpi_type": "Outcome",
"risk_level": "High",
"data_collection_method": "Retrospective",
"data_collection_frequency": "Monthly",
"reporting_frequency": "Monthly",
"dimension": "Efficiency",
"collector_name": "",
"analyzer_name": "",
"total_numerator": 0,
"total_denominator": 0,
"overall_result": "0.00",
"error_message": "",
"ai_analysis": null,
"ai_analysis_generated_at": null
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "4d9202e0-cc4c-47ff-9014-ac36d6e761ca",
"fields": {
"created_at": "2026-02-25T05:05:17.640Z",
"updated_at": "2026-02-25T05:05:17.644Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"month": 1,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {
"source_breakdown": {}
}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "7cce81c6-a7a9-4004-becf-c3b830ecbacf",
"fields": {
"created_at": "2026-02-25T05:06:34.243Z",
"updated_at": "2026-02-25T05:06:34.246Z",
"kpi_report": "3f450a3f-036b-4be6-b368-fc1c3d2dc2ff",
"month": 1,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "394c89ab-56e3-4220-bfe8-5f7f070145c4",
"fields": {
"created_at": "2026-02-25T05:06:53.931Z",
"updated_at": "2026-02-25T05:06:53.934Z",
"kpi_report": "56af32e0-a5b2-4d38-8b18-bd0a06805237",
"month": 1,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "ec879001-3daf-4147-b1b8-98d2302309a2",
"fields": {
"created_at": "2026-02-25T05:05:17.659Z",
"updated_at": "2026-02-25T05:05:17.680Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"month": 2,
"numerator": 0,
"denominator": 10,
"percentage": "0.00",
"is_below_target": true,
"details": {
"source_breakdown": {
"Social Media": 4,
"Call Center": 3,
"Staff": 1,
"Family Member": 2
}
}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "156e3c95-9211-4d10-a4e1-725333e9576d",
"fields": {
"created_at": "2026-02-25T05:06:34.258Z",
"updated_at": "2026-02-25T05:06:34.265Z",
"kpi_report": "3f450a3f-036b-4be6-b368-fc1c3d2dc2ff",
"month": 2,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "4b5c87df-1245-477f-99c1-6f1ad450460f",
"fields": {
"created_at": "2026-02-25T05:06:53.949Z",
"updated_at": "2026-02-25T05:06:53.954Z",
"kpi_report": "56af32e0-a5b2-4d38-8b18-bd0a06805237",
"month": 2,
"numerator": 0,
"denominator": 10,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "34706e2e-01ae-4ae9-94ee-c91a2ffede86",
"fields": {
"created_at": "2026-02-25T05:05:17.694Z",
"updated_at": "2026-02-25T05:05:17.697Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"month": 3,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {
"source_breakdown": {}
}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "5a30b9a3-4a02-4762-b0bc-b16a6fbe9308",
"fields": {
"created_at": "2026-02-25T05:06:34.278Z",
"updated_at": "2026-02-25T05:06:34.281Z",
"kpi_report": "3f450a3f-036b-4be6-b368-fc1c3d2dc2ff",
"month": 3,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "7ebc65a9-7688-4bde-af71-da45eea94bf0",
"fields": {
"created_at": "2026-02-25T05:06:53.970Z",
"updated_at": "2026-02-25T05:06:53.974Z",
"kpi_report": "56af32e0-a5b2-4d38-8b18-bd0a06805237",
"month": 3,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "483dec68-8f6c-4ccb-9bb4-793daf28026f",
"fields": {
"created_at": "2026-02-25T05:05:17.711Z",
"updated_at": "2026-02-25T05:05:17.714Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"month": 4,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {
"source_breakdown": {}
}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "18e69243-abfb-4800-a7b4-18a09b44b188",
"fields": {
"created_at": "2026-02-25T05:06:34.293Z",
"updated_at": "2026-02-25T05:06:34.296Z",
"kpi_report": "3f450a3f-036b-4be6-b368-fc1c3d2dc2ff",
"month": 4,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "76edc093-a757-4da3-b083-dfd52c5f6f45",
"fields": {
"created_at": "2026-02-25T05:06:53.984Z",
"updated_at": "2026-02-25T05:06:53.990Z",
"kpi_report": "56af32e0-a5b2-4d38-8b18-bd0a06805237",
"month": 4,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "f18b64c2-40ea-4ee9-aef0-74af82cb4402",
"fields": {
"created_at": "2026-02-25T05:05:17.726Z",
"updated_at": "2026-02-25T05:05:17.730Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"month": 5,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {
"source_breakdown": {}
}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "48ad553d-bc29-48da-9f9d-ae6946724c28",
"fields": {
"created_at": "2026-02-25T05:06:34.308Z",
"updated_at": "2026-02-25T05:06:34.313Z",
"kpi_report": "3f450a3f-036b-4be6-b368-fc1c3d2dc2ff",
"month": 5,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "411a6f5f-dd61-4a98-88f2-b5908d666d22",
"fields": {
"created_at": "2026-02-25T05:06:53.998Z",
"updated_at": "2026-02-25T05:06:54.000Z",
"kpi_report": "56af32e0-a5b2-4d38-8b18-bd0a06805237",
"month": 5,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "dff20507-54c1-4f83-acf9-d81914de7a59",
"fields": {
"created_at": "2026-02-25T05:05:17.743Z",
"updated_at": "2026-02-25T05:05:17.745Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"month": 6,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {
"source_breakdown": {}
}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "5b010a15-818c-4183-b1a1-4fa39ed3ac8d",
"fields": {
"created_at": "2026-02-25T05:06:34.328Z",
"updated_at": "2026-02-25T05:06:34.339Z",
"kpi_report": "3f450a3f-036b-4be6-b368-fc1c3d2dc2ff",
"month": 6,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "776c9665-80b2-4091-b84f-49eebc577862",
"fields": {
"created_at": "2026-02-25T05:06:54.012Z",
"updated_at": "2026-02-25T05:06:54.015Z",
"kpi_report": "56af32e0-a5b2-4d38-8b18-bd0a06805237",
"month": 6,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "0a26b2d2-0270-474c-9e07-120cc330c7af",
"fields": {
"created_at": "2026-02-25T05:05:17.755Z",
"updated_at": "2026-02-25T05:05:17.761Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"month": 7,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {
"source_breakdown": {}
}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "4a14e2da-d727-49f2-a6e9-8d5e3ffb5f03",
"fields": {
"created_at": "2026-02-25T05:06:34.356Z",
"updated_at": "2026-02-25T05:06:34.362Z",
"kpi_report": "3f450a3f-036b-4be6-b368-fc1c3d2dc2ff",
"month": 7,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "4d623492-b260-4ee4-a478-5a43599a1b18",
"fields": {
"created_at": "2026-02-25T05:06:54.032Z",
"updated_at": "2026-02-25T05:06:54.035Z",
"kpi_report": "56af32e0-a5b2-4d38-8b18-bd0a06805237",
"month": 7,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "c35b57c4-43ba-482f-8000-44ac099435df",
"fields": {
"created_at": "2026-02-25T05:05:17.774Z",
"updated_at": "2026-02-25T05:05:17.778Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"month": 8,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {
"source_breakdown": {}
}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "0a1d71e1-758b-4f88-849b-32e6d73d079b",
"fields": {
"created_at": "2026-02-25T05:06:34.376Z",
"updated_at": "2026-02-25T05:06:34.380Z",
"kpi_report": "3f450a3f-036b-4be6-b368-fc1c3d2dc2ff",
"month": 8,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "32a42d17-8ac6-40d7-9285-bca7dded9faa",
"fields": {
"created_at": "2026-02-25T05:06:54.047Z",
"updated_at": "2026-02-25T05:06:54.050Z",
"kpi_report": "56af32e0-a5b2-4d38-8b18-bd0a06805237",
"month": 8,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "a24ca72c-6399-4e3a-9e78-a89cfc324411",
"fields": {
"created_at": "2026-02-25T05:05:17.789Z",
"updated_at": "2026-02-25T05:05:17.792Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"month": 9,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {
"source_breakdown": {}
}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "3963f4fc-6fdd-4a20-9be6-9d2da2bd8af1",
"fields": {
"created_at": "2026-02-25T05:06:34.394Z",
"updated_at": "2026-02-25T05:06:34.398Z",
"kpi_report": "3f450a3f-036b-4be6-b368-fc1c3d2dc2ff",
"month": 9,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "3f77f04e-7477-4328-9054-84b135b016f3",
"fields": {
"created_at": "2026-02-25T05:06:54.063Z",
"updated_at": "2026-02-25T05:06:54.065Z",
"kpi_report": "56af32e0-a5b2-4d38-8b18-bd0a06805237",
"month": 9,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "4f12ec3b-29ec-4664-99b1-44a9418bcbd1",
"fields": {
"created_at": "2026-02-25T05:05:17.798Z",
"updated_at": "2026-02-25T05:05:17.801Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"month": 10,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {
"source_breakdown": {}
}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "a4c33859-5031-4410-be37-340115739fc1",
"fields": {
"created_at": "2026-02-25T05:06:34.407Z",
"updated_at": "2026-02-25T05:06:34.411Z",
"kpi_report": "3f450a3f-036b-4be6-b368-fc1c3d2dc2ff",
"month": 10,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "b7fb063c-0f9f-4d4f-9948-ed77b0ddd486",
"fields": {
"created_at": "2026-02-25T05:06:54.077Z",
"updated_at": "2026-02-25T05:06:54.081Z",
"kpi_report": "56af32e0-a5b2-4d38-8b18-bd0a06805237",
"month": 10,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "0102b319-57e9-485e-9dd4-6a4b3e8aa01b",
"fields": {
"created_at": "2026-02-25T05:05:17.807Z",
"updated_at": "2026-02-25T05:05:17.810Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"month": 11,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {
"source_breakdown": {}
}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "98bb11e1-32d1-4fd1-bf0c-7c0bb1906488",
"fields": {
"created_at": "2026-02-25T05:06:34.418Z",
"updated_at": "2026-02-25T05:06:34.422Z",
"kpi_report": "3f450a3f-036b-4be6-b368-fc1c3d2dc2ff",
"month": 11,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "6435a7b2-1c24-4b75-9d92-44f7eb28e273",
"fields": {
"created_at": "2026-02-25T05:06:54.094Z",
"updated_at": "2026-02-25T05:06:54.096Z",
"kpi_report": "56af32e0-a5b2-4d38-8b18-bd0a06805237",
"month": 11,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "a616bf00-880d-4839-ad2c-db841c3196cb",
"fields": {
"created_at": "2026-02-25T05:05:17.816Z",
"updated_at": "2026-02-25T05:05:17.818Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"month": 12,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {
"source_breakdown": {}
}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "7488dc68-b2ae-42a6-a962-0cfe40346444",
"fields": {
"created_at": "2026-02-25T05:06:34.429Z",
"updated_at": "2026-02-25T05:06:34.434Z",
"kpi_report": "3f450a3f-036b-4be6-b368-fc1c3d2dc2ff",
"month": 12,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportmonthlydata",
"pk": "ddc6ad75-756a-4935-b335-55b86646b15f",
"fields": {
"created_at": "2026-02-25T05:06:54.106Z",
"updated_at": "2026-02-25T05:06:54.109Z",
"kpi_report": "56af32e0-a5b2-4d38-8b18-bd0a06805237",
"month": 12,
"numerator": 0,
"denominator": 0,
"percentage": "0.00",
"is_below_target": true,
"details": {}
}
},
{
"model": "analytics.kpireportdepartmentbreakdown",
"pk": "9137144c-1b44-4d6e-be4d-0430f154f718",
"fields": {
"created_at": "2026-02-25T05:05:17.876Z",
"updated_at": "2026-02-25T05:05:17.876Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"department_category": "admin",
"complaint_count": 0,
"resolved_count": 0,
"avg_resolution_days": null,
"top_areas": "",
"details": {}
}
},
{
"model": "analytics.kpireportdepartmentbreakdown",
"pk": "4d5eb559-6036-4185-9acd-6d3625922483",
"fields": {
"created_at": "2026-02-25T05:05:17.858Z",
"updated_at": "2026-02-25T05:05:17.858Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"department_category": "medical",
"complaint_count": 0,
"resolved_count": 0,
"avg_resolution_days": null,
"top_areas": "",
"details": {}
}
},
{
"model": "analytics.kpireportdepartmentbreakdown",
"pk": "456bf2ad-1c56-41ef-80f1-d719b70e53d2",
"fields": {
"created_at": "2026-02-25T05:05:17.868Z",
"updated_at": "2026-02-25T05:05:17.868Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"department_category": "nursing",
"complaint_count": 0,
"resolved_count": 0,
"avg_resolution_days": null,
"top_areas": "",
"details": {}
}
},
{
"model": "analytics.kpireportdepartmentbreakdown",
"pk": "cc391455-444a-4d92-92d9-4c9cb39763d4",
"fields": {
"created_at": "2026-02-25T05:05:17.890Z",
"updated_at": "2026-02-25T05:05:17.890Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"department_category": "support",
"complaint_count": 0,
"resolved_count": 0,
"avg_resolution_days": null,
"top_areas": "",
"details": {}
}
},
{
"model": "analytics.kpireportsourcebreakdown",
"pk": "02d33996-ba97-4efe-b6d3-37c6b2e818ff",
"fields": {
"created_at": "2026-02-25T05:05:17.835Z",
"updated_at": "2026-02-25T05:05:17.835Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"source_name": "Social Media",
"complaint_count": 4,
"percentage": "40.00"
}
},
{
"model": "analytics.kpireportsourcebreakdown",
"pk": "fc458559-803f-47bf-af84-d7a4ca922bb0",
"fields": {
"created_at": "2026-02-25T05:05:17.838Z",
"updated_at": "2026-02-25T05:05:17.838Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"source_name": "Call Center",
"complaint_count": 3,
"percentage": "30.00"
}
},
{
"model": "analytics.kpireportsourcebreakdown",
"pk": "00b2b0dc-1652-4eed-bd47-f954106345ca",
"fields": {
"created_at": "2026-02-25T05:05:17.842Z",
"updated_at": "2026-02-25T05:05:17.842Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"source_name": "Family Member",
"complaint_count": 2,
"percentage": "20.00"
}
},
{
"model": "analytics.kpireportsourcebreakdown",
"pk": "48a31227-46f2-4274-98bd-a112dc068682",
"fields": {
"created_at": "2026-02-25T05:05:17.840Z",
"updated_at": "2026-02-25T05:05:17.840Z",
"kpi_report": "88795c74-4a05-4030-bd81-24b844564b3c",
"source_name": "Staff",
"complaint_count": 1,
"percentage": "10.00"
}
}
]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
[
{
"model": "core.auditevent",
"pk": "cb0145b5-839a-4d42-b8b7-15d7c5a5f92b",
"fields": {
"created_at": "2026-02-25T05:09:40.739Z",
"updated_at": "2026-02-25T05:09:40.739Z",
"event_type": "activation",
"user": "e4a79042-cd54-45ff-9bf5-d775bcb9fb92",
"description": "Complaint activated and assigned to ",
"content_type": 42,
"object_id": "8bbf4733-34c8-4dc0-a440-50ac4ab90a22",
"metadata": {
"assigned_to_user_id": "e4a79042-cd54-45ff-9bf5-d775bcb9fb92",
"assigned_to_user_name": "",
"previous_assignee_id": null,
"previous_assignee_name": null
},
"ip_address": null,
"user_agent": ""
}
},
{
"model": "core.auditevent",
"pk": "5a4f4bf3-9447-46cf-b886-966ea694f297",
"fields": {
"created_at": "2026-02-25T04:17:40.060Z",
"updated_at": "2026-02-25T04:17:40.060Z",
"event_type": "doctor_rating_import_queued",
"user": "e4a79042-cd54-45ff-9bf5-d775bcb9fb92",
"description": "Queued 38082 doctor ratings for import",
"content_type": null,
"object_id": null,
"metadata": {
"job_id": "ac3ebc22-da1a-486a-96cc-c4146b6d3d25",
"hospital": "Olaya",
"total_records": 38082
},
"ip_address": null,
"user_agent": ""
}
},
{
"model": "core.auditevent",
"pk": "e44a60aa-2c8c-4b21-868f-9c609fabd7bf",
"fields": {
"created_at": "2026-02-25T04:14:05.305Z",
"updated_at": "2026-02-25T04:14:05.305Z",
"event_type": "doctor_rating_csv_import",
"user": "e4a79042-cd54-45ff-9bf5-d775bcb9fb92",
"description": "Parsed 38082 doctor ratings from CSV by ",
"content_type": null,
"object_id": null,
"metadata": {
"hospital": "Olaya",
"total_count": 38082,
"error_count": 0
},
"ip_address": null,
"user_agent": ""
}
}
]

Binary file not shown.

View File

@ -0,0 +1,45 @@
{
"exported_at": "20260225_082145",
"total_records": 58175,
"apps_exported": [
"core",
"accounts",
"organizations",
"journeys",
"surveys",
"complaints",
"feedback",
"callcenter",
"social",
"px_action_center",
"analytics",
"physicians",
"projects",
"integrations",
"notifications",
"ai_engine",
"appreciation",
"observations",
"px_sources",
"references",
"standards",
"simulator"
],
"output_directory": "./database_export",
"compressed": true,
"zip_file": "./database_export/database_export_20260225_082145.zip",
"fixture_files": [
"./database_export/core/core_fixture.json",
"./database_export/accounts/accounts_fixture.json",
"./database_export/organizations/organizations_fixture.json",
"./database_export/journeys/journeys_fixture.json",
"./database_export/surveys/surveys_fixture.json",
"./database_export/complaints/complaints_fixture.json",
"./database_export/analytics/analytics_fixture.json",
"./database_export/physicians/physicians_fixture.json",
"./database_export/integrations/integrations_fixture.json",
"./database_export/notifications/notifications_fixture.json",
"./database_export/ai_engine/ai_engine_fixture.json",
"./database_export/px_sources/px_sources_fixture.json"
]
}

View File

@ -0,0 +1,38 @@
[
{
"model": "integrations.surveytemplatemapping",
"pk": "6e7f6f8c-bd68-490b-b67f-0756e610c787",
"fields": {
"created_at": "2026-02-25T04:01:58.060Z",
"updated_at": "2026-02-25T04:01:58.060Z",
"patient_type": "APPOINTMENT",
"survey_template": "5e8f95fd-2e84-4640-8d67-e549507684a5",
"hospital": null,
"is_active": true
}
},
{
"model": "integrations.surveytemplatemapping",
"pk": "81074873-2bdd-44c6-ad41-4fb71a5c2058",
"fields": {
"created_at": "2026-02-25T04:01:58.052Z",
"updated_at": "2026-02-25T04:01:58.052Z",
"patient_type": "INPATIENT",
"survey_template": "6104fa70-0768-45b7-9f12-6816d91c0e18",
"hospital": null,
"is_active": true
}
},
{
"model": "integrations.surveytemplatemapping",
"pk": "032cc742-98e8-47de-b4aa-48506342665b",
"fields": {
"created_at": "2026-02-25T04:01:58.057Z",
"updated_at": "2026-02-25T04:01:58.057Z",
"patient_type": "OUTPATIENT",
"survey_template": "0e175a49-a502-4d46-9c5f-c98894e316cf",
"hospital": null,
"is_active": true
}
}
]

View File

@ -0,0 +1,274 @@
[
{
"model": "journeys.patientjourneytemplate",
"pk": "2b243c19-d7de-4313-917e-de33bea24031",
"fields": {
"created_at": "2026-02-25T04:00:58.509Z",
"updated_at": "2026-02-25T04:00:58.509Z",
"name": "EMS Patient Journey",
"name_ar": "رحلة المريض للطوارئ",
"journey_type": "ems",
"description": "Emergency medical services patient journey",
"hospital": "d7221fdb-a0f7-4460-8d69-14a3bfe627fb",
"is_active": true,
"is_default": true,
"send_post_discharge_survey": true,
"post_discharge_survey_delay_hours": 1
}
},
{
"model": "journeys.patientjourneytemplate",
"pk": "63f8256b-c463-4d63-b56e-90b585703b7d",
"fields": {
"created_at": "2026-02-25T04:00:58.535Z",
"updated_at": "2026-02-25T04:00:58.535Z",
"name": "Inpatient Patient Journey",
"name_ar": "رحلة المريض الداخلي",
"journey_type": "inpatient",
"description": "Inpatient journey from admission to discharge",
"hospital": "d7221fdb-a0f7-4460-8d69-14a3bfe627fb",
"is_active": true,
"is_default": true,
"send_post_discharge_survey": true,
"post_discharge_survey_delay_hours": 24
}
},
{
"model": "journeys.patientjourneytemplate",
"pk": "953c1fe5-1bb4-4583-a3b9-b2eeec1d278f",
"fields": {
"created_at": "2026-02-25T04:00:58.563Z",
"updated_at": "2026-02-25T04:00:58.563Z",
"name": "OPD Patient Journey",
"name_ar": "رحلة المريض للعيادات الخارجية",
"journey_type": "opd",
"description": "Outpatient department patient journey",
"hospital": "d7221fdb-a0f7-4460-8d69-14a3bfe627fb",
"is_active": true,
"is_default": true,
"send_post_discharge_survey": true,
"post_discharge_survey_delay_hours": 2
}
},
{
"model": "journeys.patientjourneystagetemplate",
"pk": "dacb47c5-fbeb-4239-96e7-8103827ed6d8",
"fields": {
"created_at": "2026-02-25T04:00:58.511Z",
"updated_at": "2026-02-25T04:00:58.511Z",
"journey_template": "2b243c19-d7de-4313-917e-de33bea24031",
"name": "Ambulance Dispatch",
"name_ar": "إرسال الإسعاف",
"code": "EMS_STAGE_1_DISPATCHED",
"order": 1,
"trigger_event_code": "EMS_STAGE_1_DISPATCHED",
"survey_template": "733b4d11-2a88-4875-bb33-fb3d7efc3734",
"is_optional": false,
"is_active": true
}
},
{
"model": "journeys.patientjourneystagetemplate",
"pk": "9128dcaa-ea20-4d1c-953f-08180e12e1d6",
"fields": {
"created_at": "2026-02-25T04:00:58.513Z",
"updated_at": "2026-02-25T04:00:58.513Z",
"journey_template": "2b243c19-d7de-4313-917e-de33bea24031",
"name": "On Scene Care",
"name_ar": "الرعاية في الموقع",
"code": "EMS_STAGE_2_ON_SCENE",
"order": 2,
"trigger_event_code": "EMS_STAGE_2_ON_SCENE",
"survey_template": "733b4d11-2a88-4875-bb33-fb3d7efc3734",
"is_optional": false,
"is_active": true
}
},
{
"model": "journeys.patientjourneystagetemplate",
"pk": "e59b6e21-caf9-4e20-9925-30693232bfb7",
"fields": {
"created_at": "2026-02-25T04:00:58.514Z",
"updated_at": "2026-02-25T04:00:58.514Z",
"journey_template": "2b243c19-d7de-4313-917e-de33bea24031",
"name": "Patient Transport",
"name_ar": "نقل المريض",
"code": "EMS_STAGE_3_TRANSPORT",
"order": 3,
"trigger_event_code": "EMS_STAGE_3_TRANSPORT",
"survey_template": "733b4d11-2a88-4875-bb33-fb3d7efc3734",
"is_optional": false,
"is_active": true
}
},
{
"model": "journeys.patientjourneystagetemplate",
"pk": "c86143de-01a4-4503-8c43-33bde911d2da",
"fields": {
"created_at": "2026-02-25T04:00:58.537Z",
"updated_at": "2026-02-25T04:00:58.537Z",
"journey_template": "63f8256b-c463-4d63-b56e-90b585703b7d",
"name": "Admission",
"name_ar": "القبول",
"code": "INPATIENT_STAGE_1_ADMISSION",
"order": 1,
"trigger_event_code": "INPATIENT_STAGE_1_ADMISSION",
"survey_template": "d5d337e2-c2ba-44f5-8f35-6aa7f7e89c02",
"is_optional": false,
"is_active": true
}
},
{
"model": "journeys.patientjourneystagetemplate",
"pk": "eabc55aa-9ece-4755-ae2b-abd75765dad0",
"fields": {
"created_at": "2026-02-25T04:00:58.539Z",
"updated_at": "2026-02-25T04:00:58.539Z",
"journey_template": "63f8256b-c463-4d63-b56e-90b585703b7d",
"name": "Treatment",
"name_ar": "العلاج",
"code": "INPATIENT_STAGE_2_TREATMENT",
"order": 2,
"trigger_event_code": "INPATIENT_STAGE_2_TREATMENT",
"survey_template": "d5d337e2-c2ba-44f5-8f35-6aa7f7e89c02",
"is_optional": false,
"is_active": true
}
},
{
"model": "journeys.patientjourneystagetemplate",
"pk": "372dd6df-7667-4a4a-be26-13dc8957ae52",
"fields": {
"created_at": "2026-02-25T04:00:58.541Z",
"updated_at": "2026-02-25T04:00:58.541Z",
"journey_template": "63f8256b-c463-4d63-b56e-90b585703b7d",
"name": "Nursing Care",
"name_ar": "الرعاية التمريضية",
"code": "INPATIENT_STAGE_3_NURSING",
"order": 3,
"trigger_event_code": "INPATIENT_STAGE_3_NURSING",
"survey_template": "d5d337e2-c2ba-44f5-8f35-6aa7f7e89c02",
"is_optional": false,
"is_active": true
}
},
{
"model": "journeys.patientjourneystagetemplate",
"pk": "94fd0498-ad0d-4452-a954-622d232a174a",
"fields": {
"created_at": "2026-02-25T04:00:58.543Z",
"updated_at": "2026-02-25T04:00:58.543Z",
"journey_template": "63f8256b-c463-4d63-b56e-90b585703b7d",
"name": "Lab Tests",
"name_ar": "الفحوصات المخبرية",
"code": "INPATIENT_STAGE_4_LAB",
"order": 4,
"trigger_event_code": "INPATIENT_STAGE_4_LAB",
"survey_template": "d5d337e2-c2ba-44f5-8f35-6aa7f7e89c02",
"is_optional": false,
"is_active": true
}
},
{
"model": "journeys.patientjourneystagetemplate",
"pk": "fc7b3fe7-8e47-4947-b082-8f9c384b7137",
"fields": {
"created_at": "2026-02-25T04:00:58.545Z",
"updated_at": "2026-02-25T04:00:58.545Z",
"journey_template": "63f8256b-c463-4d63-b56e-90b585703b7d",
"name": "Radiology",
"name_ar": "الأشعة",
"code": "INPATIENT_STAGE_5_RADIOLOGY",
"order": 5,
"trigger_event_code": "INPATIENT_STAGE_5_RADIOLOGY",
"survey_template": "d5d337e2-c2ba-44f5-8f35-6aa7f7e89c02",
"is_optional": false,
"is_active": true
}
},
{
"model": "journeys.patientjourneystagetemplate",
"pk": "eb293246-9d84-46f2-abda-461a21c7c60d",
"fields": {
"created_at": "2026-02-25T04:00:58.565Z",
"updated_at": "2026-02-25T04:00:58.565Z",
"journey_template": "953c1fe5-1bb4-4583-a3b9-b2eeec1d278f",
"name": "Registration",
"name_ar": "التسجيل",
"code": "OPD_STAGE_1_REGISTRATION",
"order": 1,
"trigger_event_code": "OPD_STAGE_1_REGISTRATION",
"survey_template": "4ac2ae6b-87bc-4519-85d5-afc27d7685f6",
"is_optional": false,
"is_active": true
}
},
{
"model": "journeys.patientjourneystagetemplate",
"pk": "2014d3dc-c38c-401e-a5ec-12312de05c1e",
"fields": {
"created_at": "2026-02-25T04:00:58.571Z",
"updated_at": "2026-02-25T04:00:58.571Z",
"journey_template": "953c1fe5-1bb4-4583-a3b9-b2eeec1d278f",
"name": "Consultation",
"name_ar": "الاستشارة",
"code": "OPD_STAGE_2_CONSULTATION",
"order": 2,
"trigger_event_code": "OPD_STAGE_2_CONSULTATION",
"survey_template": "4ac2ae6b-87bc-4519-85d5-afc27d7685f6",
"is_optional": false,
"is_active": true
}
},
{
"model": "journeys.patientjourneystagetemplate",
"pk": "3e0f67ea-18a9-460b-872a-6d47e0b2906c",
"fields": {
"created_at": "2026-02-25T04:00:58.574Z",
"updated_at": "2026-02-25T04:00:58.574Z",
"journey_template": "953c1fe5-1bb4-4583-a3b9-b2eeec1d278f",
"name": "Lab Tests",
"name_ar": "الفحوصات المخبرية",
"code": "OPD_STAGE_3_LAB",
"order": 3,
"trigger_event_code": "OPD_STAGE_3_LAB",
"survey_template": "4ac2ae6b-87bc-4519-85d5-afc27d7685f6",
"is_optional": false,
"is_active": true
}
},
{
"model": "journeys.patientjourneystagetemplate",
"pk": "75860c85-ab25-4bf1-aa88-442a8b520144",
"fields": {
"created_at": "2026-02-25T04:00:58.577Z",
"updated_at": "2026-02-25T04:00:58.577Z",
"journey_template": "953c1fe5-1bb4-4583-a3b9-b2eeec1d278f",
"name": "Radiology",
"name_ar": "الأشعة",
"code": "OPD_STAGE_4_RADIOLOGY",
"order": 4,
"trigger_event_code": "OPD_STAGE_4_RADIOLOGY",
"survey_template": "4ac2ae6b-87bc-4519-85d5-afc27d7685f6",
"is_optional": false,
"is_active": true
}
},
{
"model": "journeys.patientjourneystagetemplate",
"pk": "5546f87f-85ab-441a-9898-cbc9e8bb46f9",
"fields": {
"created_at": "2026-02-25T04:00:58.580Z",
"updated_at": "2026-02-25T04:00:58.580Z",
"journey_template": "953c1fe5-1bb4-4583-a3b9-b2eeec1d278f",
"name": "Pharmacy",
"name_ar": "الصيدلية",
"code": "OPD_STAGE_5_PHARMACY",
"order": 5,
"trigger_event_code": "OPD_STAGE_5_PHARMACY",
"survey_template": "4ac2ae6b-87bc-4519-85d5-afc27d7685f6",
"is_optional": false,
"is_active": true
}
}
]

View File

@ -0,0 +1,122 @@
[
{
"model": "notifications.notificationlog",
"pk": "60ff0efd-53d5-443f-b799-3ca311e3fd62",
"fields": {
"created_at": "2026-02-25T03:55:36.978Z",
"updated_at": "2026-02-25T03:55:36.978Z",
"channel": "sms",
"recipient": "+966537702040",
"subject": "",
"message": "PX360: Your complaint #CMP-HAM-RIY-2026-787134BD has been received. Track: https://localhost:8000/complaints/public/track/?reference=CMP-HAM-RIY-2026-787134BD",
"content_type": 42,
"object_id": "f4096f2b-81ba-4eee-8ac8-3ad0bded1797",
"status": "sent",
"sent_at": "2026-02-25T03:55:42.002Z",
"delivered_at": null,
"provider": "api",
"provider_message_id": "",
"provider_response": {},
"error": "",
"retry_count": 0,
"metadata": {
"api_url": "http://localhost:8000/api/simulator/send-sms/",
"auth_method": "bearer",
"notification_type": "complaint_created",
"reference_number": "CMP-HAM-RIY-2026-787134BD",
"tracking_url": "https://localhost:8000/complaints/public/track/?reference=CMP-HAM-RIY-2026-787134BD",
"language": "en"
}
}
},
{
"model": "notifications.notificationlog",
"pk": "eff41c4f-b6e7-4691-9faa-f1b707e517a4",
"fields": {
"created_at": "2026-02-25T03:55:31.907Z",
"updated_at": "2026-02-25T03:55:31.907Z",
"channel": "sms",
"recipient": "+966541424622",
"subject": "",
"message": "PX360: Your complaint #CMP-HAM-RIY-2026-C2897964 has been received. Track: https://localhost:8000/complaints/public/track/?reference=CMP-HAM-RIY-2026-C2897964",
"content_type": 42,
"object_id": "9da3b845-c425-4bf1-b413-1d10fc1a150d",
"status": "sent",
"sent_at": "2026-02-25T03:55:36.935Z",
"delivered_at": null,
"provider": "api",
"provider_message_id": "",
"provider_response": {},
"error": "",
"retry_count": 0,
"metadata": {
"api_url": "http://localhost:8000/api/simulator/send-sms/",
"auth_method": "bearer",
"notification_type": "complaint_created",
"reference_number": "CMP-HAM-RIY-2026-C2897964",
"tracking_url": "https://localhost:8000/complaints/public/track/?reference=CMP-HAM-RIY-2026-C2897964",
"language": "en"
}
}
},
{
"model": "notifications.notificationlog",
"pk": "de39ff56-dd07-400b-ac8f-717d4e9aa8cf",
"fields": {
"created_at": "2026-02-25T03:55:26.817Z",
"updated_at": "2026-02-25T03:55:26.817Z",
"channel": "sms",
"recipient": "+966526753309",
"subject": "",
"message": "PX360: Your complaint #CMP-HAM-RIY-2026-50004505 has been received. Track: https://localhost:8000/complaints/public/track/?reference=CMP-HAM-RIY-2026-50004505",
"content_type": 42,
"object_id": "312155d3-b384-4e6f-b2e7-f8bd21e7817b",
"status": "sent",
"sent_at": "2026-02-25T03:55:31.844Z",
"delivered_at": null,
"provider": "api",
"provider_message_id": "",
"provider_response": {},
"error": "",
"retry_count": 0,
"metadata": {
"api_url": "http://localhost:8000/api/simulator/send-sms/",
"auth_method": "bearer",
"notification_type": "complaint_created",
"reference_number": "CMP-HAM-RIY-2026-50004505",
"tracking_url": "https://localhost:8000/complaints/public/track/?reference=CMP-HAM-RIY-2026-50004505",
"language": "en"
}
}
},
{
"model": "notifications.notificationlog",
"pk": "1704e6bc-8c19-4d1b-9538-4091189495dd",
"fields": {
"created_at": "2026-02-25T03:55:21.759Z",
"updated_at": "2026-02-25T03:55:21.759Z",
"channel": "sms",
"recipient": "+966592722909",
"subject": "",
"message": "PX360: Your complaint #CMP-HAM-RIY-2026-5420F582 has been received. Track: https://localhost:8000/complaints/public/track/?reference=CMP-HAM-RIY-2026-5420F582",
"content_type": 42,
"object_id": "4f11f759-8061-427d-899b-e0c360d11a20",
"status": "sent",
"sent_at": "2026-02-25T03:55:26.781Z",
"delivered_at": null,
"provider": "api",
"provider_message_id": "",
"provider_response": {},
"error": "",
"retry_count": 0,
"metadata": {
"api_url": "http://localhost:8000/api/simulator/send-sms/",
"auth_method": "bearer",
"notification_type": "complaint_created",
"reference_number": "CMP-HAM-RIY-2026-5420F582",
"tracking_url": "https://localhost:8000/complaints/public/track/?reference=CMP-HAM-RIY-2026-5420F582",
"language": "en"
}
}
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,284 @@
[
{
"model": "px_sources.pxsource",
"pk": "9affe720-438b-43dd-b570-25a7f6e8a5aa",
"fields": {
"created_at": "2026-02-25T03:55:21.561Z",
"updated_at": "2026-02-25T03:55:21.561Z",
"code": "CALL-CENT",
"name_en": "Call Center",
"name_ar": "مركز الاتصال",
"description": "Call Center source for complaints and inquiries",
"source_type": "internal",
"contact_email": "",
"contact_phone": "",
"is_active": true,
"metadata": {},
"total_complaints": 3,
"total_inquiries": 0
}
},
{
"model": "px_sources.pxsource",
"pk": "88060ad8-c771-48a6-ae9d-31fdb993e2f9",
"fields": {
"created_at": "2026-02-25T03:55:21.557Z",
"updated_at": "2026-02-25T03:55:21.558Z",
"code": "FAMI-MEMB",
"name_en": "Family Member",
"name_ar": "عضو العائلة",
"description": "Family Member source for complaints and inquiries",
"source_type": "internal",
"contact_email": "",
"contact_phone": "",
"is_active": true,
"metadata": {},
"total_complaints": 2,
"total_inquiries": 0
}
},
{
"model": "px_sources.pxsource",
"pk": "642825ce-5e1f-4900-875d-6bb3c67e9f16",
"fields": {
"created_at": "2026-02-25T03:55:21.564Z",
"updated_at": "2026-02-25T03:55:21.564Z",
"code": "IN-PERS",
"name_en": "In Person",
"name_ar": "شخصياً",
"description": "In Person source for complaints and inquiries",
"source_type": "internal",
"contact_email": "",
"contact_phone": "",
"is_active": true,
"metadata": {},
"total_complaints": 0,
"total_inquiries": 0
}
},
{
"model": "px_sources.pxsource",
"pk": "e4b6d570-5166-42b5-be13-04774a65a0dc",
"fields": {
"created_at": "2026-02-25T03:55:21.563Z",
"updated_at": "2026-02-25T03:55:21.563Z",
"code": "ONLI-FORM",
"name_en": "Online Form",
"name_ar": "نموذج عبر الإنترنت",
"description": "Online Form source for complaints and inquiries",
"source_type": "internal",
"contact_email": "",
"contact_phone": "",
"is_active": true,
"metadata": {},
"total_complaints": 0,
"total_inquiries": 0
}
},
{
"model": "px_sources.pxsource",
"pk": "db13b2e7-5169-40b9-a588-f8570d390af6",
"fields": {
"created_at": "2026-02-25T03:55:21.551Z",
"updated_at": "2026-02-25T03:55:21.551Z",
"code": "PATIENT",
"name_en": "Patient",
"name_ar": "مريض",
"description": "Patient source for complaints and inquiries",
"source_type": "internal",
"contact_email": "",
"contact_phone": "",
"is_active": true,
"metadata": {},
"total_complaints": 0,
"total_inquiries": 0
}
},
{
"model": "px_sources.pxsource",
"pk": "31ff949d-4e1b-419d-a0de-f988c41fcc3a",
"fields": {
"created_at": "2026-02-25T03:55:21.569Z",
"updated_at": "2026-02-25T03:55:21.569Z",
"code": "SOCI-MEDI",
"name_en": "Social Media",
"name_ar": "وسائل التواصل الاجتماعي",
"description": "Social Media source for complaints and inquiries",
"source_type": "internal",
"contact_email": "",
"contact_phone": "",
"is_active": true,
"metadata": {},
"total_complaints": 4,
"total_inquiries": 0
}
},
{
"model": "px_sources.pxsource",
"pk": "0aacdcd8-7aa7-4351-ad52-73a394c0848e",
"fields": {
"created_at": "2026-02-25T03:55:21.559Z",
"updated_at": "2026-02-25T03:55:21.559Z",
"code": "STAFF",
"name_en": "Staff",
"name_ar": "موظف",
"description": "Staff source for complaints and inquiries",
"source_type": "internal",
"contact_email": "",
"contact_phone": "",
"is_active": true,
"metadata": {},
"total_complaints": 1,
"total_inquiries": 0
}
},
{
"model": "px_sources.pxsource",
"pk": "83f23608-2288-4d86-ae43-4db31a91c1bf",
"fields": {
"created_at": "2026-02-25T03:55:21.566Z",
"updated_at": "2026-02-25T03:55:21.567Z",
"code": "SURVEY",
"name_en": "Survey",
"name_ar": "استبيان",
"description": "Survey source for complaints and inquiries",
"source_type": "internal",
"contact_email": "",
"contact_phone": "",
"is_active": true,
"metadata": {},
"total_complaints": 0,
"total_inquiries": 0
}
},
{
"model": "px_sources.sourceusage",
"pk": "6ea35b90-42eb-4b5b-aab4-3ae29579feac",
"fields": {
"created_at": "2026-02-25T03:55:42.060Z",
"updated_at": "2026-02-25T03:55:42.060Z",
"source": "31ff949d-4e1b-419d-a0de-f988c41fcc3a",
"content_type": 42,
"object_id": "8bbf4733-34c8-4dc0-a440-50ac4ab90a22",
"hospital": "4ed13883-9632-4534-a007-4942258a5943",
"user": null
}
},
{
"model": "px_sources.sourceusage",
"pk": "9f01a1f8-7a07-4f4e-9309-8eb8b3655336",
"fields": {
"created_at": "2026-02-25T03:55:42.044Z",
"updated_at": "2026-02-25T03:55:42.044Z",
"source": "9affe720-438b-43dd-b570-25a7f6e8a5aa",
"content_type": 42,
"object_id": "1f76952f-4fb6-4eaf-9fd8-2cc441e4ca07",
"hospital": "4ed13883-9632-4534-a007-4942258a5943",
"user": null
}
},
{
"model": "px_sources.sourceusage",
"pk": "860a79b2-f7db-40ba-85d2-ebdc6bc92c9b",
"fields": {
"created_at": "2026-02-25T03:55:42.014Z",
"updated_at": "2026-02-25T03:55:42.014Z",
"source": "0aacdcd8-7aa7-4351-ad52-73a394c0848e",
"content_type": 42,
"object_id": "f4096f2b-81ba-4eee-8ac8-3ad0bded1797",
"hospital": "4ed13883-9632-4534-a007-4942258a5943",
"user": null
}
},
{
"model": "px_sources.sourceusage",
"pk": "9b253aae-b0ed-4d7c-bbb4-107529e8fa9a",
"fields": {
"created_at": "2026-02-25T03:55:36.960Z",
"updated_at": "2026-02-25T03:55:36.960Z",
"source": "88060ad8-c771-48a6-ae9d-31fdb993e2f9",
"content_type": 42,
"object_id": "1fec51ec-ab1c-4c04-9cc4-7d3cf371940d",
"hospital": "4ed13883-9632-4534-a007-4942258a5943",
"user": null
}
},
{
"model": "px_sources.sourceusage",
"pk": "0fba819d-6316-43b5-a9db-1e164bddff79",
"fields": {
"created_at": "2026-02-25T03:55:36.940Z",
"updated_at": "2026-02-25T03:55:36.940Z",
"source": "31ff949d-4e1b-419d-a0de-f988c41fcc3a",
"content_type": 42,
"object_id": "9da3b845-c425-4bf1-b413-1d10fc1a150d",
"hospital": "4ed13883-9632-4534-a007-4942258a5943",
"user": null
}
},
{
"model": "px_sources.sourceusage",
"pk": "35e36159-d08f-4af4-bc25-8672ba4e40dc",
"fields": {
"created_at": "2026-02-25T03:55:31.890Z",
"updated_at": "2026-02-25T03:55:31.890Z",
"source": "9affe720-438b-43dd-b570-25a7f6e8a5aa",
"content_type": 42,
"object_id": "c674b99c-06b3-40c2-9812-0fec40d43ea5",
"hospital": "4ed13883-9632-4534-a007-4942258a5943",
"user": null
}
},
{
"model": "px_sources.sourceusage",
"pk": "c0c9d0a9-a754-4500-8985-f487b7c8811c",
"fields": {
"created_at": "2026-02-25T03:55:31.865Z",
"updated_at": "2026-02-25T03:55:31.865Z",
"source": "9affe720-438b-43dd-b570-25a7f6e8a5aa",
"content_type": 42,
"object_id": "c020e66f-0f9a-42ac-9b86-a740d340cd2d",
"hospital": "4ed13883-9632-4534-a007-4942258a5943",
"user": null
}
},
{
"model": "px_sources.sourceusage",
"pk": "ed5a581f-3d4e-4500-a332-1f816bffaa43",
"fields": {
"created_at": "2026-02-25T03:55:31.850Z",
"updated_at": "2026-02-25T03:55:31.850Z",
"source": "31ff949d-4e1b-419d-a0de-f988c41fcc3a",
"content_type": 42,
"object_id": "312155d3-b384-4e6f-b2e7-f8bd21e7817b",
"hospital": "4ed13883-9632-4534-a007-4942258a5943",
"user": null
}
},
{
"model": "px_sources.sourceusage",
"pk": "0062eefc-e55c-40ff-8ab3-6ea56ca170d7",
"fields": {
"created_at": "2026-02-25T03:55:26.805Z",
"updated_at": "2026-02-25T03:55:26.805Z",
"source": "31ff949d-4e1b-419d-a0de-f988c41fcc3a",
"content_type": 42,
"object_id": "4f11f759-8061-427d-899b-e0c360d11a20",
"hospital": "4ed13883-9632-4534-a007-4942258a5943",
"user": null
}
},
{
"model": "px_sources.sourceusage",
"pk": "a6b5e484-28ef-4c65-8fd4-50505042ede7",
"fields": {
"created_at": "2026-02-25T03:55:21.594Z",
"updated_at": "2026-02-25T03:55:21.594Z",
"source": "88060ad8-c771-48a6-ae9d-31fdb993e2f9",
"content_type": 42,
"object_id": "75699d55-58b8-42d2-94c3-ff479a005dad",
"hospital": "4ed13883-9632-4534-a007-4942258a5943",
"user": null
}
}
]

File diff suppressed because it is too large Load Diff

BIN
db.sqlite311.tar.gz Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,63 @@
{% block title %}{{ report.indicator_title }} - {% trans "KPI Report" %}{% endblock %} {% block title %}{{ report.indicator_title }} - {% trans "KPI Report" %}{% endblock %}
{% block extra_css %}
<style>
/* Button Styles */
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: linear-gradient(to right, #005696, #007bbd);
color: white;
font-size: 0.875rem;
font-weight: 600;
border-radius: 0.75rem;
border: none;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 6px -1px rgba(0, 86, 150, 0.2);
}
.btn-primary:hover {
opacity: 0.9;
box-shadow: 0 6px 8px -1px rgba(0, 86, 150, 0.3);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: white;
color: #475569;
font-size: 0.875rem;
font-weight: 600;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover {
background: #f8fafc;
border-color: #cbd5e1;
}
/* Card styling */
.card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="p-6"> <div class="p-6">
<!-- Header --> <!-- Header -->
@ -227,6 +284,195 @@
</div> </div>
</div> </div>
<!-- AI Analysis Section -->
<div class="card mb-6" id="aiAnalysisSection">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-bold text-navy uppercase flex items-center gap-2">
<i data-lucide="brain" class="w-4 h-4"></i>
{% trans "AI-Generated Analysis" %}
{% if report.ai_analysis_generated_at %}
<span class="text-xs font-normal text-slate lowercase">
({% trans "Generated:" %} {{ report.ai_analysis_generated_at|date:"Y-m-d H:i" }})
</span>
{% endif %}
</h3>
<div class="flex gap-2">
{% if report.ai_analysis %}
<button id="editAiAnalysisBtn" class="btn-secondary flex items-center gap-2 text-xs"
data-report-id="{{ report.id }}">
<i data-lucide="edit" class="w-4 h-4"></i>
{% trans "Edit Analysis" %}
</button>
{% endif %}
<button id="generateAiAnalysisBtn" class="btn-primary flex items-center gap-2 text-xs"
data-report-id="{{ report.id }}">
<i data-lucide="sparkles" class="w-4 h-4"></i>
{% if report.ai_analysis %}{% trans "Regenerate" %}{% else %}{% trans "Generate" %}{% endif %}
</button>
</div>
</div>
<!-- View Mode -->
<div id="aiAnalysisContent">
{% if report.ai_analysis %}
{% with analysis=report.ai_analysis %}
<!-- Executive Summary -->
{% if analysis.executive_summary %}
<div class="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 class="text-sm font-bold text-blue mb-2">{% trans "Executive Summary" %}</h4>
<p class="text-sm text-navy">{{ analysis.executive_summary }}</p>
</div>
{% endif %}
<!-- Performance Analysis -->
{% if analysis.performance_analysis %}
<div class="mb-4">
<h4 class="text-sm font-bold text-navy mb-2">{% trans "Performance Analysis" %}</h4>
<p class="text-sm text-slate">{{ analysis.performance_analysis }}</p>
</div>
{% endif %}
<!-- Key Findings -->
{% if analysis.key_findings %}
<div class="mb-4">
<h4 class="text-sm font-bold text-navy mb-2">{% trans "Key Findings" %}</h4>
<ul class="list-disc list-inside text-sm text-slate space-y-1">
{% for finding in analysis.key_findings %}
<li>{{ finding }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- Reasons for Delays -->
{% if analysis.reasons_for_delays %}
<div class="mb-4">
<h4 class="text-sm font-bold text-navy mb-2">{% trans "Reasons for Delays" %}</h4>
<ul class="list-disc list-inside text-sm text-slate space-y-1">
{% for reason in analysis.reasons_for_delays %}
<li>{{ reason }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- Resolution Time Analysis -->
{% if analysis.resolution_time_analysis %}
<div class="mb-4">
<h4 class="text-sm font-bold text-navy mb-2">{% trans "Resolution Time Breakdown" %}</h4>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
{% if analysis.resolution_time_analysis.within_24h %}
<div class="p-3 bg-green-50 border border-green-200 rounded text-center">
<p class="text-xs text-slate">{% trans "Within 24h" %}</p>
<p class="text-lg font-bold text-green-600">{{ analysis.resolution_time_analysis.within_24h.count }}</p>
<p class="text-xs text-green-600">{{ analysis.resolution_time_analysis.within_24h.percentage }}</p>
</div>
{% endif %}
{% if analysis.resolution_time_analysis.within_48h %}
<div class="p-3 bg-blue-50 border border-blue-200 rounded text-center">
<p class="text-xs text-slate">{% trans "Within 48h" %}</p>
<p class="text-lg font-bold text-blue-600">{{ analysis.resolution_time_analysis.within_48h.count }}</p>
<p class="text-xs text-blue-600">{{ analysis.resolution_time_analysis.within_48h.percentage }}</p>
</div>
{% endif %}
{% if analysis.resolution_time_analysis.within_72h %}
<div class="p-3 bg-yellow-50 border border-yellow-200 rounded text-center">
<p class="text-xs text-slate">{% trans "Within 72h" %}</p>
<p class="text-lg font-bold text-yellow-600">{{ analysis.resolution_time_analysis.within_72h.count }}</p>
<p class="text-xs text-yellow-600">{{ analysis.resolution_time_analysis.within_72h.percentage }}</p>
</div>
{% endif %}
{% if analysis.resolution_time_analysis.over_72h %}
<div class="p-3 bg-red-50 border border-red-200 rounded text-center">
<p class="text-xs text-slate">{% trans "Over 72h" %}</p>
<p class="text-lg font-bold text-red-600">{{ analysis.resolution_time_analysis.over_72h.count }}</p>
<p class="text-xs text-red-600">{{ analysis.resolution_time_analysis.over_72h.percentage }}</p>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Recommendations -->
{% if analysis.recommendations %}
<div class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<h4 class="text-sm font-bold text-green-800 mb-2">{% trans "Recommendations" %}</h4>
<ul class="list-disc list-inside text-sm text-green-700 space-y-1">
{% for rec in analysis.recommendations %}
<li>{{ rec }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endwith %}
{% else %}
<div class="text-center py-8 text-slate" id="noAnalysisMessage">
<i data-lucide="sparkles" class="w-12 h-12 mx-auto mb-3 opacity-50"></i>
<p>{% trans "No AI analysis available yet. Click 'Generate Analysis' to create one." %}</p>
</div>
{% endif %}
</div>
<!-- Loading State -->
<div id="aiAnalysisLoading" class="hidden text-center py-8">
<div class="inline-block w-8 h-8 border-4 border-slate-200 border-t-blue rounded-full animate-spin mb-3"></div>
<p class="text-slate">{% trans "Generating AI analysis... This may take a moment." %}</p>
</div>
<!-- Edit Mode Form -->
<div id="aiAnalysisEditForm" class="hidden">
{% if report.ai_analysis %}
<div class="space-y-4">
<!-- Executive Summary -->
<div>
<label class="block text-sm font-bold text-navy mb-1">{% trans "Executive Summary" %}</label>
<textarea id="editExecutiveSummary" rows="3" class="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:border-blue">{{ report.ai_analysis.executive_summary|default:"" }}</textarea>
</div>
<!-- Performance Analysis -->
<div>
<label class="block text-sm font-bold text-navy mb-1">{% trans "Performance Analysis" %}</label>
<textarea id="editPerformanceAnalysis" rows="4" class="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:border-blue">{{ report.ai_analysis.performance_analysis|default:"" }}</textarea>
</div>
<!-- Key Findings -->
<div>
<label class="block text-sm font-bold text-navy mb-1">{% trans "Key Findings (one per line)" %}</label>
<textarea id="editKeyFindings" rows="4" class="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:border-blue">{% if report.ai_analysis.key_findings %}{% for finding in report.ai_analysis.key_findings %}{{ finding }}{% if not forloop.last %}
{% endif %}{% endfor %}{% endif %}</textarea>
</div>
<!-- Reasons for Delays -->
<div>
<label class="block text-sm font-bold text-navy mb-1">{% trans "Reasons for Delays (one per line)" %}</label>
<textarea id="editReasonsForDelays" rows="4" class="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:border-blue">{% if report.ai_analysis.reasons_for_delays %}{% for reason in report.ai_analysis.reasons_for_delays %}{{ reason }}{% if not forloop.last %}
{% endif %}{% endfor %}{% endif %}</textarea>
</div>
<!-- Recommendations -->
<div>
<label class="block text-sm font-bold text-navy mb-1">{% trans "Recommendations (one per line)" %}</label>
<textarea id="editRecommendations" rows="4" class="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:border-blue">{% if report.ai_analysis.recommendations %}{% for rec in report.ai_analysis.recommendations %}{{ rec }}{% if not forloop.last %}
{% endif %}{% endfor %}{% endif %}</textarea>
</div>
<!-- Action Buttons -->
<div class="flex gap-2 pt-4 border-t">
<button id="saveAiAnalysisBtn" class="btn-primary flex items-center gap-2 text-sm"
data-report-id="{{ report.id }}">
<i data-lucide="save" class="w-4 h-4"></i>
{% trans "Save Changes" %}
</button>
<button id="cancelEditBtn" class="btn-secondary flex items-center gap-2 text-sm">
<i data-lucide="x" class="w-4 h-4"></i>
{% trans "Cancel" %}
</button>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Report Info Footer --> <!-- Report Info Footer -->
<div class="text-xs text-slate text-center"> <div class="text-xs text-slate text-center">
<p> <p>
@ -361,6 +607,129 @@ document.addEventListener('DOMContentLoaded', function() {
console.error('Error rendering source chart:', e); console.error('Error rendering source chart:', e);
} }
} }
// AI Analysis Generation
const generateBtn = document.getElementById('generateAiAnalysisBtn');
if (generateBtn) {
generateBtn.addEventListener('click', async function() {
const reportId = this.dataset.reportId;
const contentDiv = document.getElementById('aiAnalysisContent');
const loadingDiv = document.getElementById('aiAnalysisLoading');
// Show loading, hide content
contentDiv.classList.add('hidden');
loadingDiv.classList.remove('hidden');
generateBtn.disabled = true;
try {
const response = await fetch(`/analytics/api/kpi-reports/${reportId}/ai-analysis/`, {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1]
}
});
const data = await response.json();
if (data.success) {
// Reload page to show new analysis
window.location.reload();
} else {
alert(data.error || 'Failed to generate analysis');
contentDiv.classList.remove('hidden');
loadingDiv.classList.add('hidden');
generateBtn.disabled = false;
}
} catch (error) {
console.error('Error:', error);
alert('Failed to generate analysis. Please try again.');
contentDiv.classList.remove('hidden');
loadingDiv.classList.add('hidden');
generateBtn.disabled = false;
}
});
}
// Edit AI Analysis
const editBtn = document.getElementById('editAiAnalysisBtn');
const cancelEditBtn = document.getElementById('cancelEditBtn');
const saveBtn = document.getElementById('saveAiAnalysisBtn');
const contentDiv = document.getElementById('aiAnalysisContent');
const editForm = document.getElementById('aiAnalysisEditForm');
if (editBtn && contentDiv && editForm) {
// Enter edit mode
editBtn.addEventListener('click', function() {
contentDiv.classList.add('hidden');
editForm.classList.remove('hidden');
editBtn.classList.add('hidden');
if (typeof lucide !== 'undefined') lucide.createIcons();
});
}
if (cancelEditBtn && contentDiv && editForm) {
// Cancel edit mode
cancelEditBtn.addEventListener('click', function() {
editForm.classList.add('hidden');
contentDiv.classList.remove('hidden');
editBtn.classList.remove('hidden');
});
}
if (saveBtn && editForm) {
// Save edited analysis
saveBtn.addEventListener('click', async function() {
const reportId = this.dataset.reportId;
// Build analysis object from form fields
const analysis = {
executive_summary: document.getElementById('editExecutiveSummary')?.value || '',
performance_analysis: document.getElementById('editPerformanceAnalysis')?.value || '',
key_findings: (document.getElementById('editKeyFindings')?.value || '').split('\n').filter(f => f.trim()),
reasons_for_delays: (document.getElementById('editReasonsForDelays')?.value || '').split('\n').filter(r => r.trim()),
recommendations: (document.getElementById('editRecommendations')?.value || '').split('\n').filter(r => r.trim()),
// Preserve existing resolution_time_analysis and other fields
resolution_time_analysis: {{ report.ai_analysis.resolution_time_analysis|safe|default:"null" }},
department_analysis: {{ report.ai_analysis.department_analysis|safe|default:"null" }},
source_analysis: {{ report.ai_analysis.source_analysis|safe|default:"null" }}
};
// Disable button during save
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="inline-block w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"></span>Saving...';
try {
const response = await fetch(`/analytics/api/kpi-reports/${reportId}/ai-analysis/save/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1]
},
body: JSON.stringify({ analysis: analysis })
});
const data = await response.json();
if (data.success) {
// Reload page to show updated analysis
window.location.reload();
} else {
alert(data.error || 'Failed to save analysis');
saveBtn.disabled = false;
saveBtn.innerHTML = '<i data-lucide="save" class="w-4 h-4"></i> Save Changes';
if (typeof lucide !== 'undefined') lucide.createIcons();
}
} catch (error) {
console.error('Error:', error);
alert('Failed to save analysis. Please try again.');
saveBtn.disabled = false;
saveBtn.innerHTML = '<i data-lucide="save" class="w-4 h-4"></i> Save Changes';
if (typeof lucide !== 'undefined') lucide.createIcons();
}
});
}
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -4,32 +4,35 @@
{% block title %}{% trans "Generate KPI Report" %} - PX360{% endblock %} {% block title %}{% trans "Generate KPI Report" %} - PX360{% endblock %}
{% block content %} {% block content %}
<div class="max-w-3xl mx-auto"> <div class="max-w-6xl mx-auto">
<!-- Header --> <!-- Header -->
<header class="mb-6"> <header class="mb-6">
<a href="{% url 'analytics:kpi_report_list' %}" <div class="flex items-center gap-2 text-sm text-slate mb-3">
class="inline-flex items-center gap-2 text-sm font-bold text-blue hover:text-navy mb-3 transition"> <a href="{% url 'analytics:kpi_report_list' %}" class="hover:text-navy">{% trans "KPI Reports" %}</a>
<i data-lucide="arrow-left" class="w-4 h-4"></i> <i data-lucide="chevron-right" class="w-4 h-4"></i>
{% trans "Back to Reports" %} <span class="font-bold text-navy">{% trans "Generate Report" %}</span>
</a> </div>
<h1 class="text-2xl font-bold text-navy flex items-center gap-3"> <h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<i data-lucide="file-plus" class="w-7 h-7"></i> <div class="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center">
<i data-lucide="file-plus" class="w-5 h-5 text-blue"></i>
</div>
{% trans "Generate KPI Report" %} {% trans "Generate KPI Report" %}
</h1> </h1>
<p class="text-sm text-slate mt-1">{% trans "Create a new monthly KPI report for a specific hospital and period." %}</p> <p class="text-slate text-sm mt-1">{% trans "Create a new monthly KPI report for a specific hospital and period." %}</p>
</header> </header>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Form --> <!-- Main Form -->
<div class="lg:col-span-2"> <div class="lg:col-span-2">
<div class="card"> <div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="card-header"> <div class="px-6 py-4 border-b border-slate-100 bg-slate-50">
<h2 class="card-title flex items-center gap-2"> <h2 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="settings-2" class="w-4 h-4"></i> <i data-lucide="settings" class="w-5 h-5 text-blue"></i>
{% trans "Report Configuration" %} {% trans "Report Configuration" %}
</h2> </h2>
</div> </div>
<div class="p-6">
<form method="post" action="{% url 'analytics:kpi_report_generate_submit' %}" <form method="post" action="{% url 'analytics:kpi_report_generate_submit' %}"
hx-post="{% url 'analytics:kpi_report_generate_submit' %}" hx-post="{% url 'analytics:kpi_report_generate_submit' %}"
hx-target="#form-result" hx-target="#form-result"
@ -43,7 +46,7 @@
{% trans "Report Type" %} <span class="text-red-500">*</span> {% trans "Report Type" %} <span class="text-red-500">*</span>
</label> </label>
<select name="report_type" id="report_type" required <select name="report_type" id="report_type" required
class="w-full px-4 py-2.5 bg-slate-100 border border-transparent rounded-lg text-sm focus:bg-white focus:border-navy focus:ring-2 focus:ring-navy/10 outline-none transition"> class="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:bg-white focus:border-navy focus:ring-2 focus:ring-navy/10 outline-none transition">
<option value="">{% trans "Select Report Type" %}</option> <option value="">{% trans "Select Report Type" %}</option>
{% for type_value, type_label in report_types %} {% for type_value, type_label in report_types %}
<option value="{{ type_value }}">{{ type_label }}</option> <option value="{{ type_value }}">{{ type_label }}</option>
@ -61,7 +64,7 @@
{% trans "Hospital" %} <span class="text-red-500">*</span> {% trans "Hospital" %} <span class="text-red-500">*</span>
</label> </label>
<select name="hospital" id="hospital" required <select name="hospital" id="hospital" required
class="w-full px-4 py-2.5 bg-slate-100 border border-transparent rounded-lg text-sm focus:bg-white focus:border-navy focus:ring-2 focus:ring-navy/10 outline-none transition"> class="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:bg-white focus:border-navy focus:ring-2 focus:ring-navy/10 outline-none transition">
<option value="">{% trans "Select Hospital" %}</option> <option value="">{% trans "Select Hospital" %}</option>
{% for hospital in hospitals %} {% for hospital in hospitals %}
<option value="{{ hospital.id }}">{{ hospital.name }}</option> <option value="{{ hospital.id }}">{{ hospital.name }}</option>
@ -76,7 +79,7 @@
{% trans "Year" %} <span class="text-red-500">*</span> {% trans "Year" %} <span class="text-red-500">*</span>
</label> </label>
<select name="year" id="year" required <select name="year" id="year" required
class="w-full px-4 py-2.5 bg-slate-100 border border-transparent rounded-lg text-sm focus:bg-white focus:border-navy focus:ring-2 focus:ring-navy/10 outline-none transition"> class="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:bg-white focus:border-navy focus:ring-2 focus:ring-navy/10 outline-none transition">
<option value="">{% trans "Select Year" %}</option> <option value="">{% trans "Select Year" %}</option>
{% for y in years %} {% for y in years %}
<option value="{{ y }}" {% if y == current_year %}selected{% endif %}>{{ y }}</option> <option value="{{ y }}" {% if y == current_year %}selected{% endif %}>{{ y }}</option>
@ -89,7 +92,7 @@
{% trans "Month" %} <span class="text-red-500">*</span> {% trans "Month" %} <span class="text-red-500">*</span>
</label> </label>
<select name="month" id="month" required <select name="month" id="month" required
class="w-full px-4 py-2.5 bg-slate-100 border border-transparent rounded-lg text-sm focus:bg-white focus:border-navy focus:ring-2 focus:ring-navy/10 outline-none transition"> class="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:bg-white focus:border-navy focus:ring-2 focus:ring-navy/10 outline-none transition">
<option value="">{% trans "Select Month" %}</option> <option value="">{% trans "Select Month" %}</option>
{% for m, m_label in months %} {% for m, m_label in months %}
<option value="{{ m }}" {% if m == current_month %}selected{% endif %}>{{ m_label }}</option> <option value="{{ m }}" {% if m == current_month %}selected{% endif %}>{{ m_label }}</option>
@ -99,14 +102,14 @@
</div> </div>
<!-- Info Box --> <!-- Info Box -->
<div class="bg-blue-50 border border-blue-100 rounded-xl p-4"> <div class="bg-blue-50 border border-blue-200 rounded-2xl p-4">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="p-2 bg-blue-100 rounded-lg flex-shrink-0"> <div class="w-10 h-10 bg-blue-100 rounded-xl flex items-center justify-center flex-shrink-0">
<i data-lucide="info" class="w-4 h-4 text-blue"></i> <i data-lucide="info" class="w-5 h-5 text-blue-600"></i>
</div> </div>
<div> <div>
<p class="text-sm font-bold text-navy">{% trans "Report Generation Information" %}</p> <p class="text-sm font-bold text-blue-800">{% trans "Report Generation Information" %}</p>
<p class="text-sm text-slate mt-1 leading-relaxed"> <p class="text-sm text-blue-700 mt-1 leading-relaxed">
{% trans "The report will be generated based on data from the selected month. This may take a few moments depending on the amount of data. If a report already exists for this period, it will be regenerated with the latest data." %} {% trans "The report will be generated based on data from the selected month. This may take a few moments depending on the amount of data. If a report already exists for this period, it will be regenerated with the latest data." %}
</p> </p>
</div> </div>
@ -117,12 +120,12 @@
<div id="form-result"></div> <div id="form-result"></div>
<!-- Buttons --> <!-- Buttons -->
<div class="flex gap-3 pt-4 border-t"> <div class="flex gap-3 pt-4 border-t border-slate-100">
<button type="submit" class="btn-primary flex-1 flex items-center justify-center gap-2 py-3"> <button type="submit" class="flex-1 px-6 py-3 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center justify-center gap-2 shadow-lg shadow-navy/20">
<i data-lucide="play" class="w-4 h-4"></i> <i data-lucide="play" class="w-5 h-5"></i>
{% trans "Generate Report" %} {% trans "Generate Report" %}
</button> </button>
<a href="{% url 'analytics:kpi_report_list' %}" class="btn-secondary px-6 py-3"> <a href="{% url 'analytics:kpi_report_list' %}" class="px-6 py-3 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-light transition">
{% trans "Cancel" %} {% trans "Cancel" %}
</a> </a>
</div> </div>
@ -130,102 +133,112 @@
</form> </form>
</div> </div>
</div> </div>
</div>
<!-- Sidebar - Available Reports --> <!-- Sidebar - Available Reports -->
<div class="lg:col-span-1"> <div class="lg:col-span-1 space-y-6">
<div class="card"> <!-- Report Types Card -->
<div class="card-header"> <div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<h3 class="text-sm font-bold text-navy flex items-center gap-2"> <div class="px-6 py-4 border-b border-slate-100 bg-slate-50">
<i data-lucide="list" class="w-4 h-4"></i> <h3 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="list" class="w-5 h-5 text-blue"></i>
{% trans "Available KPI Reports" %} {% trans "Available KPI Reports" %}
</h3> </h3>
</div> </div>
<div class="space-y-2"> <div class="p-6 space-y-4">
<!-- MOH Reports --> <!-- MOH Reports -->
<div class="p-2.5 bg-light rounded-lg"> <div>
<p class="text-[10px] font-bold text-slate uppercase tracking-wider mb-2"> <p class="text-[10px] font-bold text-slate uppercase tracking-wider mb-3 flex items-center gap-2">
<span class="w-2 h-2 bg-navy rounded-full"></span>
{% trans "Ministry of Health" %} {% trans "Ministry of Health" %}
</p> </p>
<div class="space-y-1.5"> <div class="space-y-2">
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-3 p-2.5 bg-slate-50 rounded-xl hover:bg-blue-50 transition">
<span class="px-1.5 py-0.5 text-xs font-bold bg-navy text-white rounded flex-shrink-0">MOH-2</span> <span class="px-2.5 py-1 text-xs font-bold bg-navy text-white rounded-lg flex-shrink-0">MOH-2</span>
<span class="text-slate truncate">{% trans "72-Hour Resolution" %}</span> <span class="text-sm text-slate flex-1">{% trans "72-Hour Resolution" %}</span>
</div> </div>
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-3 p-2.5 bg-slate-50 rounded-xl hover:bg-blue-50 transition">
<span class="px-1.5 py-0.5 text-xs font-bold bg-navy text-white rounded flex-shrink-0">MOH-1</span> <span class="px-2.5 py-1 text-xs font-bold bg-navy text-white rounded-lg flex-shrink-0">MOH-1</span>
<span class="text-slate truncate">{% trans "Patient Experience" %}</span> <span class="text-sm text-slate flex-1">{% trans "Patient Experience" %}</span>
</div> </div>
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-3 p-2.5 bg-slate-50 rounded-xl hover:bg-blue-50 transition">
<span class="px-1.5 py-0.5 text-xs font-bold bg-navy text-white rounded flex-shrink-0">MOH-3</span> <span class="px-2.5 py-1 text-xs font-bold bg-navy text-white rounded-lg flex-shrink-0">MOH-3</span>
<span class="text-slate truncate">{% trans "Resolution Satisfaction" %}</span> <span class="text-sm text-slate flex-1">{% trans "Resolution Satisfaction" %}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Department Reports --> <!-- Department Reports -->
<div class="p-2.5 bg-light rounded-lg"> <div>
<p class="text-[10px] font-bold text-slate uppercase tracking-wider mb-2"> <p class="text-[10px] font-bold text-slate uppercase tracking-wider mb-3 flex items-center gap-2">
<span class="w-2 h-2 bg-blue rounded-full"></span>
{% trans "Departmental" %} {% trans "Departmental" %}
</p> </p>
<div class="space-y-1.5"> <div class="space-y-2">
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-3 p-2.5 bg-slate-50 rounded-xl hover:bg-blue-50 transition">
<span class="px-1.5 py-0.5 text-xs font-bold bg-blue text-white rounded flex-shrink-0">Dep-KPI-4</span> <span class="px-2.5 py-1 text-xs font-bold bg-blue text-white rounded-lg flex-shrink-0">Dep-KPI-4</span>
<span class="text-slate truncate">{% trans "Response Rate" %}</span> <span class="text-sm text-slate flex-1">{% trans "Response Rate" %}</span>
</div> </div>
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-3 p-2.5 bg-slate-50 rounded-xl hover:bg-blue-50 transition">
<span class="px-1.5 py-0.5 text-xs font-bold bg-blue text-white rounded flex-shrink-0">KPI-6</span> <span class="px-2.5 py-1 text-xs font-bold bg-blue text-white rounded-lg flex-shrink-0">KPI-6</span>
<span class="text-slate truncate">{% trans "Activation (2h)" %}</span> <span class="text-sm text-slate flex-1">{% trans "Activation (2h)" %}</span>
</div> </div>
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-3 p-2.5 bg-slate-50 rounded-xl hover:bg-blue-50 transition">
<span class="px-1.5 py-0.5 text-xs font-bold bg-blue text-white rounded flex-shrink-0">KPI-7</span> <span class="px-2.5 py-1 text-xs font-bold bg-blue text-white rounded-lg flex-shrink-0">KPI-7</span>
<span class="text-slate truncate">{% trans "Unactivated" %}</span> <span class="text-sm text-slate flex-1">{% trans "Unactivated" %}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- N-PAD Reports --> <!-- N-PAD Reports -->
<div class="p-2.5 bg-light rounded-lg"> <div>
<p class="text-[10px] font-bold text-slate uppercase tracking-wider mb-2"> <p class="text-[10px] font-bold text-slate uppercase tracking-wider mb-3 flex items-center gap-2">
<span class="w-2 h-2 bg-navy rounded-full"></span>
{% trans "N-PAD Standards" %} {% trans "N-PAD Standards" %}
</p> </p>
<div class="space-y-1.5"> <div class="space-y-2">
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-3 p-2.5 bg-slate-50 rounded-xl hover:bg-blue-50 transition">
<span class="px-1.5 py-0.5 text-xs font-bold bg-navy text-white rounded flex-shrink-0">N-PAD-001</span> <span class="px-2.5 py-1 text-xs font-bold bg-navy text-white rounded-lg flex-shrink-0">N-PAD-001</span>
<span class="text-slate truncate">{% trans "Resolution" %}</span> <span class="text-sm text-slate flex-1">{% trans "Resolution" %}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Quick Tips --> <!-- Quick Tips Card -->
<div class="mt-4 pt-4 border-t"> <div class="bg-gradient-to-br from-green-50 to-emerald-50 rounded-2xl border border-green-200 p-6">
<p class="text-[10px] font-bold text-slate uppercase tracking-wider mb-2"> <div class="flex items-center gap-2 mb-4">
{% trans "Quick Tips" %} <div class="w-8 h-8 bg-green-100 rounded-xl flex items-center justify-center">
</p> <i data-lucide="lightbulb" class="w-4 h-4 text-green-600"></i>
<ul class="space-y-2 text-sm text-slate"> </div>
<li class="flex items-start gap-2"> <h4 class="font-bold text-green-800">{% trans "Quick Tips" %}</h4>
<i data-lucide="check-circle" class="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5"></i> </div>
<span>{% trans "Reports are generated automatically every month" %}</span> <ul class="space-y-3">
<li class="flex items-start gap-3">
<i data-lucide="check-circle-2" class="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5"></i>
<span class="text-sm text-green-700">{% trans "Reports are generated automatically every month" %}</span>
</li> </li>
<li class="flex items-start gap-2"> <li class="flex items-start gap-3">
<i data-lucide="check-circle" class="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5"></i> <i data-lucide="check-circle-2" class="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5"></i>
<span>{% trans "You can regenerate any report with latest data" %}</span> <span class="text-sm text-green-700">{% trans "You can regenerate any report with latest data" %}</span>
</li> </li>
<li class="flex items-start gap-2"> <li class="flex items-start gap-3">
<i data-lucide="check-circle" class="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5"></i> <i data-lucide="check-circle-2" class="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5"></i>
<span>{% trans "PDF export is available for all reports" %}</span> <span class="text-sm text-green-700">{% trans "PDF export is available for all reports" %}</span>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons(); lucide.createIcons();
});
</script> </script>
{% endblock %} {% endblock %}

View File

@ -42,7 +42,7 @@
<!-- Statistics Cards --> <!-- Statistics Cards -->
<div class="grid grid-cols-4 gap-6 mb-6"> <div class="grid grid-cols-4 gap-6 mb-6">
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4"> <div class="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm flex items-center gap-4 hover:border-blue/30 hover:shadow-md transition-all">
<div class="p-3 bg-blue/10 rounded-xl"> <div class="p-3 bg-blue/10 rounded-xl">
<i data-lucide="bar-chart-3" class="text-blue w-5 h-5"></i> <i data-lucide="bar-chart-3" class="text-blue w-5 h-5"></i>
</div> </div>
@ -51,7 +51,7 @@
<p class="text-xl font-black text-navy leading-tight">{{ stats.total }}</p> <p class="text-xl font-black text-navy leading-tight">{{ stats.total }}</p>
</div> </div>
</div> </div>
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4"> <div class="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm flex items-center gap-4 hover:border-green-400/50 hover:shadow-md transition-all">
<div class="p-3 bg-green-50 rounded-xl"> <div class="p-3 bg-green-50 rounded-xl">
<i data-lucide="check-circle" class="text-green-600 w-5 h-5"></i> <i data-lucide="check-circle" class="text-green-600 w-5 h-5"></i>
</div> </div>
@ -60,7 +60,7 @@
<p class="text-xl font-black text-navy leading-tight">{{ stats.completed }}</p> <p class="text-xl font-black text-navy leading-tight">{{ stats.completed }}</p>
</div> </div>
</div> </div>
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4"> <div class="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm flex items-center gap-4 hover:border-yellow-400/50 hover:shadow-md transition-all">
<div class="p-3 bg-yellow-50 rounded-xl"> <div class="p-3 bg-yellow-50 rounded-xl">
<i data-lucide="clock" class="text-yellow-600 w-5 h-5"></i> <i data-lucide="clock" class="text-yellow-600 w-5 h-5"></i>
</div> </div>
@ -69,7 +69,7 @@
<p class="text-xl font-black text-navy leading-tight">{{ stats.pending }}</p> <p class="text-xl font-black text-navy leading-tight">{{ stats.pending }}</p>
</div> </div>
</div> </div>
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4"> <div class="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm flex items-center gap-4 hover:border-red-400/50 hover:shadow-md transition-all">
<div class="p-3 bg-red-50 rounded-xl"> <div class="p-3 bg-red-50 rounded-xl">
<i data-lucide="alert-triangle" class="text-red-500 w-5 h-5"></i> <i data-lucide="alert-triangle" class="text-red-500 w-5 h-5"></i>
</div> </div>
@ -171,7 +171,7 @@
<div class="bg-white rounded-b-2xl shadow-sm border p-6"> <div class="bg-white rounded-b-2xl shadow-sm border p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for report in reports %} {% for report in reports %}
<div class="card hover:shadow-lg hover:-translate-y-1 transition-all duration-200 cursor-pointer group" <div class="card bg-white p-5 border border-slate-200 rounded-2xl hover:shadow-lg hover:-translate-y-1 transition-all duration-200 cursor-pointer group"
onclick="window.location.href='{% url 'analytics:kpi_report_detail' report.id %}'"> onclick="window.location.href='{% url 'analytics:kpi_report_detail' report.id %}'">
<!-- Header --> <!-- Header -->
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between mb-3">

View File

@ -1,3 +1,6 @@
{% load i18n %}
{% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -5,7 +8,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ report.indicator_title }} - {% trans "KPI Report" %}</title> <title>{{ report.indicator_title }} - {% trans "KPI Report" %}</title>
<!-- Tailwind CSS --> <!-- Tailwind CSS (for PDF preview only) -->
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<!-- Lucide Icons --> <!-- Lucide Icons -->
@ -33,6 +36,217 @@
</script> </script>
<style> <style>
/* Base styles that work without Tailwind */
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
background: white;
color: #334155;
line-height: 1.5;
}
/* Utility classes that match Tailwind */
.bg-white { background-color: white; }
.bg-navy { background-color: #005696; }
.bg-blue { background-color: #007bbd; }
.bg-light { background-color: #eef6fb; }
.text-white { color: white; }
.text-navy { color: #005696; }
.text-slate { color: #64748b; }
.text-slate-800 { color: #1e293b; }
.font-bold { font-weight: 700; }
.font-semibold { font-weight: 600; }
.text-2xl { font-size: 1.5rem; }
.text-3xl { font-size: 1.875rem; }
.text-sm { font-size: 0.875rem; }
.text-xs { font-size: 0.75rem; }
.text-xl { font-size: 1.25rem; }
.text-lg { font-size: 1.125rem; }
.font-black { font-weight: 900; }
.rounded-lg { border-radius: 0.5rem; }
.rounded-xl { border-radius: 0.75rem; }
.rounded-2xl { border-radius: 1rem; }
.rounded-full { border-radius: 9999px; }
.border { border: 1px solid #e2e8f0; }
.shadow-sm { box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
.shadow-lg { box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1); }
.p-2 { padding: 0.5rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.p-5 { padding: 1.25rem; }
.p-6 { padding: 1.5rem; }
.p-8 { padding: 2rem; }
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.px-5 { padding-left: 1.25rem; padding-right: 1.25rem; }
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
.py-1_5 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.py-2_5 { padding-top: 0.625rem; padding-bottom: 0.625rem; }
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
.m-0 { margin: 0; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mb-8 { margin-bottom: 2rem; }
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 0.75rem; }
.mt-4 { margin-top: 1rem; }
.mt-6 { margin-top: 1.5rem; }
.mt-8 { margin-top: 2rem; }
.mt-12 { margin-top: 3rem; }
.mr-1 { margin-right: 0.25rem; }
.mr-2 { margin-right: 0.5rem; }
.ml-1 { margin-left: 0.25rem; }
.ml-2 { margin-left: 0.5rem; }
.mx-auto { margin-left: auto; margin-right: auto; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
.flex { display: flex; }
.inline-flex { display: inline-flex; }
.inline { display: inline; }
.inline-block { display: inline-block; }
.block { display: block; }
.hidden { display: none; }
.grid { display: grid; }
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
.grid-cols-5 { grid-template-columns: repeat(5, 1fr); }
.w-full { width: 100%; }
.w-3 { width: 0.75rem; }
.w-4 { width: 1rem; }
.w-5 { width: 1.25rem; }
.w-8 { width: 2rem; }
.h-3 { height: 0.75rem; }
.h-4 { height: 1rem; }
.h-5 { height: 1.25rem; }
.h-8 { height: 2rem; }
.h-10 { height: 2.5rem; }
.min-h-220 { min-height: 220px; }
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.items-start { align-items: flex-start; }
.items-center { align-items: center; }
.flex-wrap { flex-wrap: wrap; }
.flex-1 { flex: 1; }
.flex-col { flex-direction: column; }
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.top-4 { top: 1rem; }
.right-4 { right: 1rem; }
.z-50 { z-index: 50; }
.uppercase { text-transform: uppercase; }
.italic { font-style: italic; }
.whitespace-pre-line { white-space: pre-line; }
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.border-collapse { border-collapse: collapse; }
.border-b { border-bottom: 1px solid #e2e8f0; }
.border-b-2 { border-bottom: 2px solid; }
.border-t { border-top: 1px solid #e2e8f0; }
.border-t-2 { border-top: 2px solid; }
.border-l { border-left: 1px solid #e2e8f0; }
.border-slate-200 { border-color: #e2e8f0; }
.border-slate-300 { border-color: #cbd5e1; }
.border-slate-400 { border-color: #94a3b8; }
.border-navy { border-color: #005696; }
.border-green-200 { border-color: #bbf7d0; }
.border-red-200 { border-color: #fecaca; }
.border-blue-200 { border-color: #bfdbfe; }
.border-yellow-200 { border-color: #fde68a; }
.bg-green-50 { background-color: #f0fdf4; }
.bg-green-100 { background-color: #dcfce7; }
.bg-green-200 { background-color: #bbf7d0; }
.bg-green-600 { background-color: #16a34a; }
.bg-green-700 { background-color: #15803d; }
.bg-green-800 { background-color: #166534; }
.bg-red-50 { background-color: #fef2f2; }
.bg-red-100 { background-color: #fee2e2; }
.bg-red-200 { background-color: #fecaca; }
.bg-red-500 { background-color: #ef4444; }
.bg-red-600 { background-color: #dc2626; }
.bg-yellow-50 { background-color: #fefce8; }
.bg-yellow-100 { background-color: #fef9c3; }
.bg-yellow-200 { background-color: #fde68a; }
.bg-yellow-600 { background-color: #ca8a04; }
.bg-yellow-800 { background-color: #854d0e; }
.bg-blue-50 { background-color: #eff6ff; }
.bg-blue-100 { background-color: #dbeafe; }
.bg-blue-200 { background-color: #bfdbfe; }
.bg-slate-50 { background-color: #f8fafc; }
.bg-slate-100 { background-color: #f1f5f9; }
.bg-slate-200 { background-color: #e2e8f0; }
.bg-slate-300 { background-color: #cbd5e1; }
.bg-slate-400 { background-color: #94a3b8; }
.bg-slate-600 { background-color: #475569; }
.text-green-600 { color: #16a34a; }
.text-green-700 { color: #15803d; }
.text-green-800 { color: #166534; }
.text-red-500 { color: #ef4444; }
.text-red-600 { color: #dc2626; }
.text-red-800 { color: #991b1b; }
.text-yellow-600 { color: #ca8a04; }
.text-yellow-800 { color: #854d0e; }
.text-blue { color: #007bbd; }
.text-blue-600 { color: #2563eb; }
.text-blue-700 { color: #1d4ed8; }
.text-blue-800 { color: #1e40af; }
.hover\:opacity-90:hover { opacity: 0.9; }
.hover\:bg-blue:hover { background-color: #007bbd; }
.hover\:bg-slate-600:hover { background-color: #475569; }
.hover\:bg-slate-50:hover { background-color: #f8fafc; }
.hover\:-translate-y-1:hover { transform: translateY(-0.25rem); }
.hover\:shadow-lg:hover { box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1); }
.hover\:underline:hover { text-decoration: underline; }
.transition { transition: all 0.2s; }
.transition-all { transition: all 0.2s; }
.transition-colors { transition: color 0.2s, background-color 0.2s, border-color 0.2s; }
.cursor-pointer { cursor: pointer; }
.cursor-not-allowed { cursor: not-allowed; }
.overflow-hidden { overflow: hidden; }
.overflow-x-auto { overflow-x: auto; }
.overflow-visible { overflow: visible; }
.list-disc { list-style-type: disc; }
.list-inside { list-style-position: inside; }
.space-y-1 > * + * { margin-top: 0.25rem; }
.space-y-2 > * + * { margin-top: 0.5rem; }
.space-y-4 > * + * { margin-top: 1rem; }
.pt-1 { padding-top: 0.25rem; }
.pt-3 { padding-top: 0.75rem; }
.pt-4 { padding-top: 1rem; }
.pb-1 { padding-bottom: 0.25rem; }
.pb-2 { padding-bottom: 0.5rem; }
.pb-4 { padding-bottom: 1rem; }
.pb-8 { padding-bottom: 2rem; }
.pl-4 { padding-left: 1rem; }
.pr-4 { padding-right: 1rem; }
.tracking-wider { letter-spacing: 0.05em; }
.tracking-tight { letter-spacing: -0.025em; }
.leading-tight { line-height: 1.25; }
/* Animation */
.animate-spin { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* Print styles */
@media print { @media print {
.no-print { display: none !important; } .no-print { display: none !important; }
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
@ -43,6 +257,46 @@
.page-break { page-break-after: always; } .page-break { page-break-after: always; }
.no-break { page-break-inside: avoid; } .no-break { page-break-inside: avoid; }
/* Chart container styles */
#trendChart, #sourceChart {
max-width: 100% !important;
overflow: visible !important;
position: relative;
}
/* Ensure charts fit within PDF */
.apexcharts-canvas {
max-width: 100% !important;
}
/* Static chart images for PDF */
.chart-static-image {
max-width: 100% !important;
height: auto !important;
display: block;
background: white;
}
/* Ensure proper sizing for PDF capture */
.apexcharts-svg {
max-width: 100% !important;
}
#trendChart, #sourceChart {
min-height: 220px;
background: white;
}
/* Prevent horizontal overflow */
#report-content {
overflow-x: hidden !important;
}
/* Ensure proper sizing for PDF capture */
.apexcharts-svg {
max-width: 100% !important;
}
</style> </style>
</head> </head>
<body class="bg-white text-slate-800"> <body class="bg-white text-slate-800">
@ -63,7 +317,7 @@
</div> </div>
<!-- Report Container --> <!-- Report Container -->
<div id="report-content" class="max-w-[1200px] mx-auto p-8"> <div id="report-content" class="w-full mx-auto p-8">
<!-- Header with Logo --> <!-- Header with Logo -->
<div class="flex justify-between items-start mb-8 pb-4 border-b-2 border-navy no-break"> <div class="flex justify-between items-start mb-8 pb-4 border-b-2 border-navy no-break">
@ -184,24 +438,24 @@
</div> </div>
<!-- Charts --> <!-- Charts -->
<div class="grid grid-cols-3 gap-6 mb-8 no-break"> <div class="mb-8">
<!-- Trend Chart --> <!-- Trend Chart -->
<div class="col-span-2 border excel-border p-4 rounded-lg bg-slate-50/50"> <div class="border excel-border p-4 rounded-lg bg-slate-50/50 mb-6">
<p class="text-xs font-bold text-navy uppercase mb-4 flex items-center gap-2"> <p class="text-xs font-bold text-navy uppercase mb-4 flex items-center gap-2">
<i data-lucide="trending-up" class="w-3 h-3"></i> <i data-lucide="trending-up" class="w-3 h-3"></i>
{% trans "Monthly Performance Trend (%)" %} {% trans "Monthly Performance Trend (%)" %}
<span class="text-slate font-normal">[{% trans "Target:" %} {{ report.target_percentage }}%]</span> <span class="text-slate font-normal">[{% trans "Target:" %} {{ report.target_percentage }}%]</span>
</p> </p>
<div id="trendChart" style="height: 250px;"></div> <div id="trendChart" style="height: 220px; width: 100%;"></div>
</div> </div>
<!-- Source Chart --> <!-- Source Chart -->
<div class="border excel-border p-4 rounded-lg bg-slate-50/50"> <div class="border excel-border p-4 rounded-lg bg-slate-50/50" style="max-width: 400px; margin: 0 auto;">
<p class="text-xs font-bold text-navy uppercase mb-4 flex items-center gap-2"> <p class="text-xs font-bold text-navy uppercase mb-4 flex items-center gap-2 text-center">
<i data-lucide="pie-chart" class="w-3 h-3"></i> <i data-lucide="pie-chart" class="w-3 h-3"></i>
{% trans "Complaints by Source" %} {% trans "Complaints by Source" %}
</p> </p>
<div id="sourceChart" style="height: 250px;"></div> <div id="sourceChart" style="height: 220px; width: 100%;"></div>
</div> </div>
</div> </div>
@ -241,6 +495,166 @@
</div> </div>
</div> </div>
<!-- AI Analysis Section -->
{% if report.ai_analysis %}
<div class="page-break"></div>
<div class="mt-8 no-break">
<h3 class="text-lg font-bold text-navy mb-4 pb-2 border-b-2 border-navy">
<i data-lucide="brain" class="w-5 h-5 inline mr-2"></i>
{% trans "AI-Generated Analysis" %}
</h3>
{% with analysis=report.ai_analysis %}
<!-- Executive Summary -->
{% if analysis.executive_summary %}
<div class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded">
<h4 class="text-sm font-bold text-blue mb-1">{% trans "Executive Summary" %}</h4>
<p class="text-sm text-navy">{{ analysis.executive_summary }}</p>
</div>
{% endif %}
<!-- Performance Analysis -->
{% if analysis.performance_analysis %}
<div class="mb-4">
<h4 class="text-sm font-bold text-navy mb-1">{% trans "Performance Analysis" %}</h4>
<p class="text-sm text-slate">{{ analysis.performance_analysis }}</p>
</div>
{% endif %}
<!-- Comparison to Target (for specific report types) -->
{% if analysis.comparison_to_target %}
<div class="mb-4">
<h4 class="text-sm font-bold text-navy mb-1">{% trans "Comparison to Target" %}</h4>
<p class="text-sm text-slate">{{ analysis.comparison_to_target }}</p>
</div>
{% endif %}
<!-- Key Findings -->
{% if analysis.key_findings %}
<div class="mb-4">
<h4 class="text-sm font-bold text-navy mb-1">{% trans "Key Findings" %}</h4>
<ul class="list-disc list-inside text-sm text-slate">
{% for finding in analysis.key_findings %}
<li>{{ finding }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- Reasons for Delays -->
{% if analysis.reasons_for_delays %}
<div class="mb-4">
<h4 class="text-sm font-bold text-navy mb-1">{% trans "Reasons for Delays" %}</h4>
<ul class="list-disc list-inside text-sm text-slate">
{% for reason in analysis.reasons_for_delays %}
<li>{{ reason }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- Delay Reasons (alternative format) -->
{% if analysis.delay_reasons %}
<div class="mb-4">
<h4 class="text-sm font-bold text-navy mb-1">{% trans "Reasons for Delays" %}</h4>
<ul class="list-disc list-inside text-sm text-slate">
{% for reason in analysis.delay_reasons %}
<li>{{ reason }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- Resolution Time Analysis -->
{% if analysis.resolution_time_analysis %}
<div class="mb-4">
<h4 class="text-sm font-bold text-navy mb-1">{% trans "Resolution Time Breakdown" %}</h4>
<div class="grid grid-cols-4 gap-2 text-center text-sm">
{% if analysis.resolution_time_analysis.within_24h %}
<div class="p-2 bg-green-50 border border-green-200 rounded">
<p class="text-xs text-slate">{% trans "Within 24h" %}</p>
<p class="font-bold text-green-600">{{ analysis.resolution_time_analysis.within_24h.count }}</p>
<p class="text-xs text-green-600">{{ analysis.resolution_time_analysis.within_24h.percentage }}</p>
</div>
{% endif %}
{% if analysis.resolution_time_analysis.within_48h %}
<div class="p-2 bg-blue-50 border border-blue-200 rounded">
<p class="text-xs text-slate">{% trans "Within 48h" %}</p>
<p class="font-bold text-blue-600">{{ analysis.resolution_time_analysis.within_48h.count }}</p>
<p class="text-xs text-blue-600">{{ analysis.resolution_time_analysis.within_48h.percentage }}</p>
</div>
{% endif %}
{% if analysis.resolution_time_analysis.within_72h %}
<div class="p-2 bg-yellow-50 border border-yellow-200 rounded">
<p class="text-xs text-slate">{% trans "Within 72h" %}</p>
<p class="font-bold text-yellow-600">{{ analysis.resolution_time_analysis.within_72h.count }}</p>
<p class="text-xs text-yellow-600">{{ analysis.resolution_time_analysis.within_72h.percentage }}</p>
</div>
{% endif %}
{% if analysis.resolution_time_analysis.over_72h %}
<div class="p-2 bg-red-50 border border-red-200 rounded">
<p class="text-xs text-slate">{% trans "Over 72h" %}</p>
<p class="font-bold text-red-600">{{ analysis.resolution_time_analysis.over_72h.count }}</p>
<p class="text-xs text-red-600">{{ analysis.resolution_time_analysis.over_72h.percentage }}</p>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Recommendations -->
{% if analysis.recommendations %}
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded">
<h4 class="text-sm font-bold text-green-800 mb-1">{% trans "Recommendations" %}</h4>
<ul class="list-disc list-inside text-sm text-green-700">
{% for rec in analysis.recommendations %}
<li>{{ rec }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endwith %}
</div>
{% endif %}
<!-- Review and Approval Section -->
<div class="page-break"></div>
<div class="mt-12 pt-8 border-t-2 border-slate-300">
<h3 class="text-lg font-bold text-navy mb-6">{% trans "Review and Approval" %}</h3>
<div class="grid grid-cols-3 gap-6">
<!-- Reviewed By -->
<div class="border-t border-slate-400 pt-4">
<p class="text-sm font-bold text-navy mb-8">{% trans "Reviewed By:" %}</p>
<div class="mt-12">
<p class="text-sm text-slate border-b border-slate-300 pb-1 mb-2">&nbsp;</p>
<p class="text-xs text-slate">{% trans "Name & Signature" %}</p>
<p class="text-xs text-slate mt-2">{% trans "Date:" %} _______________</p>
</div>
</div>
<!-- Reviewed and Approved By 1 -->
<div class="border-t border-slate-400 pt-4">
<p class="text-sm font-bold text-navy mb-8">{% trans "Reviewed and Approved By:" %}</p>
<div class="mt-12">
<p class="text-sm text-slate border-b border-slate-300 pb-1 mb-2">&nbsp;</p>
<p class="text-xs text-slate">{% trans "Name & Signature" %}</p>
<p class="text-xs text-slate mt-2">{% trans "Date:" %} _______________</p>
</div>
</div>
<!-- Reviewed and Approved By 2 -->
<div class="border-t border-slate-400 pt-4">
<p class="text-sm font-bold text-navy mb-8">{% trans "Reviewed and Approved By:" %}</p>
<div class="mt-12">
<p class="text-sm text-slate border-b border-slate-300 pb-1 mb-2">&nbsp;</p>
<p class="text-xs text-slate">{% trans "Name & Signature" %}</p>
<p class="text-xs text-slate mt-2">{% trans "Date:" %} _______________</p>
</div>
</div>
</div>
</div>
<!-- Footer --> <!-- Footer -->
<div class="text-xs text-slate text-center border-t pt-4 mt-8"> <div class="text-xs text-slate text-center border-t pt-4 mt-8">
<p> <p>
@ -278,7 +692,8 @@
}], }],
chart: { chart: {
type: 'line', type: 'line',
height: 250, height: 220,
width: '100%',
toolbar: { show: false }, toolbar: { show: false },
animations: { enabled: false } animations: { enabled: false }
}, },
@ -331,7 +746,8 @@
labels: sourceData.labels, labels: sourceData.labels,
chart: { chart: {
type: 'donut', type: 'donut',
height: 250, height: 220,
width: '100%',
animations: { enabled: false } animations: { enabled: false }
}, },
colors: ['#005696', '#007bbd', '#64748b', '#94a3b8', '#cbd5e1'], colors: ['#005696', '#007bbd', '#64748b', '#94a3b8', '#cbd5e1'],
@ -353,16 +769,40 @@
} }
// PDF Generation // PDF Generation
function generatePDF() { async function generatePDF() {
const element = document.getElementById('report-content'); const element = document.getElementById('report-content');
const btn = document.querySelector('button[onclick="generatePDF()"]');
const originalText = btn.innerHTML;
// Show loading
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i> {% trans "Generating..." %}';
btn.disabled = true;
lucide.createIcons();
try {
// Wait for charts to fully render
await new Promise(resolve => setTimeout(resolve, 1000));
// Convert charts to images before PDF generation
await convertChartsToImages();
const opt = { const opt = {
margin: [10, 10, 10, 10], margin: [10, 10, 10, 10],
filename: '{{ report.kpi_id }}_{{ report.year }}_{{ report.month }}_{{ report.hospital.name|slugify }}.pdf', filename: '{{ report.kpi_id }}_{{ report.year }}_{{ report.month }}_{{ report.hospital.name|slugify }}.pdf',
image: { type: 'jpeg', quality: 0.98 }, image: { type: 'jpeg', quality: 0.95 },
html2canvas: { html2canvas: {
scale: 2, scale: 1.5,
useCORS: true, useCORS: false, // Disable CORS to avoid tainted canvas issues
logging: false logging: false,
allowTaint: false, // Don't allow tainted canvas
letterRendering: true,
foreignObjectRendering: false, // Disable foreignObject rendering which causes issues
onclone: function(clonedDoc) {
// Hide any remaining SVGs in the clone, only show our static images
clonedDoc.querySelectorAll('.apexcharts-svg, .apexcharts-canvas').forEach(el => {
el.style.display = 'none';
});
}
}, },
jsPDF: { jsPDF: {
unit: 'mm', unit: 'mm',
@ -371,17 +811,102 @@
} }
}; };
// Show loading await html2pdf().set(opt).from(element).save();
const btn = document.querySelector('button[onclick="generatePDF()"]');
const originalText = btn.innerHTML;
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i> {% trans "Generating..." %}';
btn.disabled = true;
lucide.createIcons();
html2pdf().set(opt).from(element).save().then(() => { } catch (error) {
console.error('PDF generation error:', error);
alert('{% trans "Error generating PDF. Please try again." %}');
} finally {
// Restore charts after PDF generation (success or failure)
restoreChartsFromImages();
btn.innerHTML = originalText; btn.innerHTML = originalText;
btn.disabled = false; btn.disabled = false;
lucide.createIcons(); lucide.createIcons();
}
}
// Convert ApexCharts to static images for PDF capture
async function convertChartsToImages() {
const chartContainers = ['trendChart', 'sourceChart'];
for (const containerId of chartContainers) {
const container = document.getElementById(containerId);
if (!container) continue;
try {
// Find the SVG element (ApexCharts renders SVG)
const svg = container.querySelector('.apexcharts-svg') || container.querySelector('svg');
if (svg) {
// Clone the SVG to modify it for export
const svgClone = svg.cloneNode(true);
// Get dimensions
const rect = svg.getBoundingClientRect();
const width = rect.width || 600;
const height = rect.height || 220;
// Ensure SVG has proper attributes for standalone rendering
svgClone.setAttribute('width', width);
svgClone.setAttribute('height', height);
svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
svgClone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
// Inline any computed styles by copying them
const computedStyles = window.getComputedStyle(svg);
svgClone.style.fontFamily = computedStyles.fontFamily;
// Convert SVG to XML string
const svgData = new XMLSerializer().serializeToString(svgClone);
// Create SVG data URL directly (no canvas needed!)
// Use TextEncoder for proper UTF-8 handling
const utf8Bytes = new TextEncoder().encode(svgData);
const binaryString = Array.from(utf8Bytes).map(b => String.fromCharCode(b)).join('');
const svgBase64 = btoa(binaryString);
const dataUrl = 'data:image/svg+xml;base64,' + svgBase64;
// Create image element
const imgElement = document.createElement('img');
imgElement.src = dataUrl;
imgElement.style.width = width + 'px';
imgElement.style.height = height + 'px';
imgElement.style.maxWidth = '100%';
imgElement.className = 'chart-static-image';
imgElement.dataset.originalContainer = containerId;
// Wait for image to load
await new Promise((resolve) => {
imgElement.onload = resolve;
imgElement.onerror = resolve; // Continue on error
});
// Hide original chart and show image
svg.style.visibility = 'hidden';
container.appendChild(imgElement);
}
} catch (error) {
console.error(`Error converting ${containerId} to image:`, error);
// Continue without converting this chart
}
}
}
// Restore charts from images back to interactive charts
function restoreChartsFromImages() {
// Remove static images
document.querySelectorAll('.chart-static-image').forEach(img => {
img.remove();
});
// Show original SVGs
document.querySelectorAll('#trendChart svg, #sourceChart svg').forEach(svg => {
svg.style.visibility = '';
});
// Reset container positions
document.querySelectorAll('#trendChart, #sourceChart').forEach(container => {
container.style.position = '';
}); });
} }
</script> </script>

View File

@ -0,0 +1,276 @@
{% extends 'layouts/base.html' %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Call Records" %} - PX360{% endblock %}
{% block content %}
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-navy">{% trans "Call Records" %}</h1>
<p class="text-slate text-sm mt-1">{% trans "Manage and analyze imported call center recordings" %}</p>
</div>
<div class="flex items-center gap-3">
<a href="{% url 'callcenter:export_call_records_template' %}"
class="px-4 py-2 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-light transition flex items-center gap-2">
<i data-lucide="download" class="w-4 h-4"></i>
{% trans "Download Template" %}
</a>
<a href="{% url 'callcenter:import_call_records' %}"
class="px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2">
<i data-lucide="upload" class="w-4 h-4"></i>
{% trans "Import CSV" %}
</a>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<!-- Total Calls -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center">
<i data-lucide="phone" class="w-6 h-6 text-blue"></i>
</div>
<span class="text-xs font-bold text-slate uppercase">{% trans "All Calls" %}</span>
</div>
<p class="text-3xl font-bold text-navy">{{ stats.total_calls }}</p>
<p class="text-xs text-slate mt-1">{% trans "Total records" %}</p>
</div>
<!-- Inbound Calls -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center">
<i data-lucide="phone-incoming" class="w-6 h-6 text-green-600"></i>
</div>
<span class="text-xs font-bold text-slate uppercase">{% trans "Inbound" %}</span>
</div>
<p class="text-3xl font-bold text-navy">{{ stats.inbound_calls }}</p>
<p class="text-xs text-slate mt-1">{% trans "Incoming calls" %}</p>
</div>
<!-- Outbound Calls -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 bg-orange-50 rounded-xl flex items-center justify-center">
<i data-lucide="phone-outgoing" class="w-6 h-6 text-orange-600"></i>
</div>
<span class="text-xs font-bold text-slate uppercase">{% trans "Outbound" %}</span>
</div>
<p class="text-3xl font-bold text-navy">{{ stats.outbound_calls }}</p>
<p class="text-xs text-slate mt-1">{% trans "Outgoing calls" %}</p>
</div>
<!-- Total Duration -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 bg-purple-50 rounded-xl flex items-center justify-center">
<i data-lucide="clock" class="w-6 h-6 text-purple-600"></i>
</div>
<span class="text-xs font-bold text-slate uppercase">{% trans "Duration" %}</span>
</div>
<p class="text-3xl font-bold text-navy">{{ stats.total_duration_formatted }}</p>
<p class="text-xs text-slate mt-1">{% trans "Avg:" %} {{ stats.avg_duration_formatted }}</p>
</div>
<!-- Evaluated Calls -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center">
<i data-lucide="circle-check" class="w-6 h-6 text-indigo-600"></i>
</div>
<span class="text-xs font-bold text-slate uppercase">{% trans "Evaluated" %}</span>
</div>
<p class="text-3xl font-bold text-navy">{{ stats.evaluated_calls }}</p>
<p class="text-xs text-slate mt-1">{% trans "Not evaluated:" %} {{ stats.not_evaluated_calls }}</p>
</div>
</div>
<!-- Filters -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 mb-6">
<form method="get" class="flex flex-wrap gap-4">
<div class="flex-1 min-w-48">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Search" %}</label>
<input type="text" name="search" value="{{ filters.search }}"
placeholder="{% trans 'Name, department, extension...' %}"
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm">
</div>
<div class="w-48">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Call Type" %}</label>
<select name="call_type" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm bg-white">
<option value="">{% trans "All Calls" %}</option>
<option value="inbound" {% if filters.call_type == 'inbound' %}selected{% endif %}>{% trans "Inbound" %}</option>
<option value="outbound" {% if filters.call_type == 'outbound' %}selected{% endif %}>{% trans "Outbound" %}</option>
</select>
</div>
<div class="w-48">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Evaluated" %}</label>
<select name="evaluated" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm bg-white">
<option value="">{% trans "All" %}</option>
<option value="true" {% if filters.evaluated == 'true' %}selected{% endif %}>{% trans "Yes" %}</option>
<option value="false" {% if filters.evaluated == 'false' %}selected{% endif %}>{% trans "No" %}</option>
</select>
</div>
<div class="w-48">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Hospital" %}</label>
<select name="hospital" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm bg-white">
<option value="">{% trans "All Hospitals" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name }}
</option>
{% endfor %}
</select>
</div>
<div class="w-40">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "From Date" %}</label>
<input type="date" name="date_from" value="{{ filters.date_from }}"
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm">
</div>
<div class="w-40">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "To Date" %}</label>
<input type="date" name="date_to" value="{{ filters.date_to }}"
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm">
</div>
<div class="flex items-end">
<button type="submit" class="px-6 py-2.5 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2">
<i data-lucide="search" class="w-4 h-4"></i>
{% trans "Filter" %}
</button>
<a href="{% url 'callcenter:call_records_list' %}"
class="px-4 py-2.5 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-light transition ml-2">
<i data-lucide="x" class="w-4 h-4"></i>
</a>
</div>
</form>
</div>
<!-- Call Records Table -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-slate-50 border-b border-slate-200">
<tr>
<th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-4 px-6">{% trans "Date/Time" %}</th>
<th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-4 px-6">{% trans "Caller" %}</th>
<th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-4 px-6">{% trans "Department" %}</th>
<th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-4 px-6">{% trans "Type" %}</th>
<th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-4 px-6">{% trans "Duration" %}</th>
<th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-4 px-6">{% trans "Evaluated" %}</th>
<th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-4 px-6">{% trans "File" %}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for record in call_records %}
<tr class="hover:bg-light/30 transition">
<td class="py-4 px-6">
<p class="text-sm font-semibold text-navy">{{ record.call_start|date:"M d, Y" }}</p>
<p class="text-xs text-slate">{{ record.call_start|date:"h:i A" }}</p>
</td>
<td class="py-4 px-6">
<p class="text-sm font-semibold text-navy">{{ record.caller_full_name|default:"-" }}</p>
{% if record.inbound_name %}
<p class="text-xs text-slate">{{ record.inbound_name }}</p>
{% endif %}
</td>
<td class="py-4 px-6">
<p class="text-sm font-semibold text-navy">{{ record.department|default:"-" }}</p>
{% if record.extension %}
<p class="text-xs text-slate">Ext: {{ record.extension }}</p>
{% endif %}
</td>
<td class="py-4 px-6">
{% if record.is_inbound %}
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold bg-green-100 text-green-800">
<i data-lucide="phone-incoming" class="w-3 h-3 mr-1"></i>
{% trans "Inbound" %}
</span>
{% elif record.is_outbound %}
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold bg-orange-100 text-orange-800">
<i data-lucide="phone-outgoing" class="w-3 h-3 mr-1"></i>
{% trans "Outbound" %}
</span>
{% else %}
<span class="text-xs text-slate">-</span>
{% endif %}
</td>
<td class="py-4 px-6">
<p class="text-sm font-mono text-navy">{{ record.call_length|default:"-" }}</p>
</td>
<td class="py-4 px-6">
{% if record.evaluated %}
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold bg-green-100 text-green-800">
<i data-lucide="check" class="w-3 h-3 mr-1"></i>
{% trans "Yes" %}
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold bg-slate-100 text-slate-600">
<i data-lucide="x" class="w-3 h-3 mr-1"></i>
{% trans "No" %}
</span>
{% endif %}
</td>
<td class="py-4 px-6">
<p class="text-xs text-slate truncate max-w-48" title="{{ record.file_name }}">{{ record.file_name|default:"-" }}</p>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="py-12 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-slate-100 rounded-full mb-4">
<i data-lucide="phone-off" class="w-8 h-8 text-slate-400"></i>
</div>
<p class="text-slate font-medium">{% trans "No call records found" %}</p>
<p class="text-xs text-slate mt-1">{% trans "Import a CSV file to get started" %}</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<div class="border-t border-slate-200 px-6 py-4">
<div class="flex items-center justify-between">
<p class="text-sm text-slate">
{% trans "Showing" %} {{ page_obj.start_index }}-{% if page_obj.has_next %}{{ page_obj.end_index }}{% else %}{{ page_obj.count }}{% endif %} {% trans "of" %} {{ page_obj.count }}
</p>
<div class="flex items-center gap-2">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}{% if filters %}{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}{% endif %}"
class="px-3 py-2 border border-slate-200 rounded-lg text-sm font-semibold hover:bg-light transition">
{% trans "Previous" %}
</a>
{% endif %}
<span class="px-4 py-2 bg-navy text-white rounded-lg text-sm font-semibold">
{{ page_obj.number }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{% if filters %}{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}{% endif %}"
class="px-3 py-2 border border-slate-200 rounded-lg text-sm font-semibold hover:bg-light transition">
{% trans "Next" %}
</a>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>

View File

@ -0,0 +1,226 @@
{% extends 'layouts/base.html' %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Import Call Records" %} - PX360{% endblock %}
{% block extra_js %}
<script>
function updateFileName() {
const input = document.getElementById('csvFile');
const fileName = document.getElementById('fileName');
const importButton = document.getElementById('importButton');
if (input.files && input.files[0]) {
fileName.textContent = 'Selected: ' + input.files[0].name;
// Enable the import button
importButton.disabled = false;
importButton.classList.remove('bg-slate-300', 'text-slate-500', 'cursor-not-allowed');
importButton.classList.add('bg-navy', 'text-white', 'hover:bg-blue');
} else {
fileName.textContent = '';
// Disable the import button
importButton.disabled = true;
importButton.classList.add('bg-slate-300', 'text-slate-500', 'cursor-not-allowed');
importButton.classList.remove('bg-navy', 'text-white', 'hover:bg-blue');
}
}
// Drag and drop support
const dropZone = document.getElementById('dropZone');
const csvFile = document.getElementById('csvFile');
if (dropZone && csvFile) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-blue', 'bg-blue-50');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-blue', 'bg-blue-50');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('border-blue', 'bg-blue-50');
const files = e.dataTransfer.files;
if (files.length > 0 && files[0].name.endsWith('.csv')) {
csvFile.files = files;
updateFileName();
} else {
alert('Please upload a CSV file');
}
});
}
</script>
{% endblock %}
{% block content %}
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-2 text-sm text-slate mb-2">
<a href="{% url 'callcenter:call_records_list' %}" class="hover:text-navy">{% trans "Call Records" %}</a>
<i data-lucide="chevron-right" class="w-4 h-4"></i>
<span class="font-bold text-navy">{% trans "Import CSV" %}</span>
</div>
<h1 class="text-2xl font-bold text-navy">{% trans "Import Call Records" %}</h1>
<p class="text-slate text-sm mt-1">{% trans "Upload call records from your call recording system" %}</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Upload Form -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<h2 class="text-lg font-bold text-navy mb-4 flex items-center gap-2">
<i data-lucide="upload" class="w-5 h-5 text-blue"></i>
{% trans "Upload CSV File" %}
</h2>
<form method="post" enctype="multipart/form-data" id="importForm">
{% csrf_token %}
<div class="mb-6">
<label class="block text-sm font-semibold text-slate mb-2">
{% trans "CSV File" %} <span class="text-red-500">*</span>
</label>
<div class="border-2 border-dashed border-slate-200 rounded-xl p-8 text-center hover:border-blue transition" id="dropZone">
<input type="file" name="csv_file" id="csvFile" accept=".csv" required
class="hidden" onchange="updateFileName()">
<label for="csvFile" class="cursor-pointer">
<div class="w-16 h-16 bg-blue-50 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="file-spreadsheet" class="w-8 h-8 text-blue"></i>
</div>
<p class="text-sm font-semibold text-navy mb-1">{% trans "Click to upload or drag and drop" %}</p>
<p class="text-xs text-slate">{% trans "CSV file (max. 100MB)" %}</p>
<p id="fileName" class="text-xs text-blue mt-2 font-medium"></p>
</label>
</div>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
<div class="flex items-start gap-3">
<i data-lucide="info" class="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0"></i>
<div>
<p class="text-sm font-semibold text-blue-800 mb-1">{% trans "Important Notes" %}</p>
<ul class="text-xs text-blue-700 space-y-1">
<li>• {% trans "File must be in CSV format with exact headers" %}</li>
<li>• {% trans "Duplicate records (by Media ID) will be skipped" %}</li>
<li>• {% trans "Invalid records will be skipped with error reporting" %}</li>
<li>• {% trans "Large files may take a few moments to process" %}</li>
</ul>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button type="submit" id="importButton" disabled
class="px-6 py-3 bg-slate-300 text-slate-500 rounded-xl font-semibold cursor-not-allowed transition flex items-center gap-2">
<i data-lucide="upload" class="w-5 h-5"></i>
{% trans "Import Records" %}
</button>
<a href="{% url 'callcenter:call_records_list' %}"
class="px-6 py-3 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-light transition">
{% trans "Cancel" %}
</a>
</div>
</form>
</div>
<!-- CSV Format Guide -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<h2 class="text-lg font-bold text-navy mb-4 flex items-center gap-2">
<i data-lucide="file-text" class="w-5 h-5 text-blue"></i>
{% trans "CSV Format Guide" %}
</h2>
<p class="text-sm text-slate mb-4">
{% trans "Your CSV file must contain the following headers:" %}
</p>
<div class="bg-slate-50 rounded-xl p-4 mb-6 max-h-80 overflow-y-auto">
<code class="text-xs text-slate font-mono">
{% for header in sample_headers %}
<span class="inline-block px-2 py-1 bg-white border border-slate-200 rounded mr-1 mb-1">{{ header }}</span>
{% endfor %}
</code>
</div>
<div class="space-y-3">
<h3 class="text-sm font-bold text-navy">{% trans "Required Fields" %}</h3>
<ul class="text-sm text-slate space-y-2">
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0"></i>
<span><strong>Media ID</strong> - {% trans "Unique identifier (UUID format)" %}</span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0"></i>
<span><strong>Call Start</strong> - {% trans "Call start date/time (MM/DD/YYYY H:MM AM/PM)" %}</span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0"></i>
<span><strong>First Name</strong> - {% trans "Caller first name" %}</span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0"></i>
<span><strong>Last Name</strong> - {% trans "Caller last name" %}</span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0"></i>
<span><strong>Department</strong> - {% trans "Department name" %}</span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0"></i>
<span><strong>Length</strong> - {% trans "Call duration (HH:MM:SS)" %}</span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0"></i>
<span><strong>File Name</strong> - {% trans "Recording file name" %}</span>
</li>
</ul>
</div>
<div class="mt-6 pt-6 border-t border-slate-200">
<a href="{% url 'callcenter:export_call_records_template' %}"
class="inline-flex items-center gap-2 text-blue font-semibold hover:underline text-sm">
<i data-lucide="download" class="w-4 h-4"></i>
{% trans "Download sample CSV template" %}
</a>
</div>
</div>
</div>
<!-- Example Table -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 mt-6">
<h2 class="text-lg font-bold text-navy mb-4 flex items-center gap-2">
<i data-lucide="table" class="w-5 h-5 text-blue"></i>
{% trans "Example Data Format" %}
</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-200">
<tr>
<th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-3 px-4">Media ID</th>
<th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-3 px-4">Call Start</th>
<th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-3 px-4">First Name</th>
<th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-3 px-4">Last Name</th>
<th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-3 px-4">Department</th>
<th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-3 px-4">Length</th>
<th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-3 px-4">File Name</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr>
<td class="py-3 px-4 font-mono text-xs text-slate">aade2430-2eb0-4e05-93eb-9567e2be07ae</td>
<td class="py-3 px-4 text-slate">10/30/2025 7:57:48 PM</td>
<td class="py-3 px-4 text-navy">Patient</td>
<td class="py-3 px-4 text-navy">Relation</td>
<td class="py-3 px-4 text-slate">Patient Relation</td>
<td class="py-3 px-4 font-mono text-slate">00:01:11</td>
<td class="py-3 px-4 text-slate text-xs">2025-10-30\x1379 19.57.48.467.mp3</td>
</tr>
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@ -1,18 +1,169 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% load i18n %} {% load i18n %}
{% block title %}{% trans "Complaint Adverse Actions" %} - PX360{% endblock %} {% block title %}{% trans "Adverse Actions" %} - PX360{% endblock %}
{% block extra_css %}
<style>
:root {
--hh-navy: #005696;
--hh-blue: #007bbd;
--hh-light: #eef6fb;
--hh-slate: #64748b;
--hh-success: #10b981;
--hh-warning: #f59e0b;
--hh-danger: #ef4444;
--hh-purple: #8b5cf6;
}
.page-header {
background: linear-gradient(135deg, var(--hh-navy) 0%, #0069a8 50%, var(--hh-blue) 100%);
color: white;
padding: 2rem 2.5rem;
border-radius: 1rem;
margin-bottom: 2rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.filter-card, .data-card {
background: white;
border-radius: 1rem;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
.card-header {
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
padding: 1.25rem 1.75rem;
border-bottom: 1px solid #bae6fd;
border-radius: 1rem 1rem 0 0;
}
.data-table th {
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
padding: 0.875rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--hh-navy);
border-bottom: 2px solid #bae6fd;
}
.data-table td {
padding: 1rem;
border-bottom: 1px solid #f1f5f9;
color: #475569;
font-size: 0.875rem;
}
.data-table tbody tr {
transition: background-color 0.2s ease;
}
.data-table tbody tr:hover {
background-color: var(--hh-light);
cursor: pointer;
}
.severity-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 700;
}
.severity-badge.low { background: linear-gradient(135deg, #dcfce7, #bbf7d0); color: #166534; }
.severity-badge.medium { background: linear-gradient(135deg, #fef3c7, #fde68a); color: #92400e; }
.severity-badge.high { background: linear-gradient(135deg, #fee2e2, #fecaca); color: #991b1b; }
.severity-badge.critical { background: linear-gradient(135deg, #7f1d1d, #991b1b); color: white; }
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 700;
}
.status-badge.reported { background: linear-gradient(135deg, #e0f2fe, #bae6fd); color: #075985; }
.status-badge.under_investigation { background: linear-gradient(135deg, #fef3c7, #fde68a); color: #92400e; }
.status-badge.resolved { background: linear-gradient(135deg, #dcfce7, #bbf7d0); color: #166534; }
.status-badge.closed { background: linear-gradient(135deg, #f1f5f9, #e2e8f0); color: #475569; }
.btn-primary {
background: linear-gradient(135deg, var(--hh-navy) 0%, var(--hh-blue) 100%);
color: white;
padding: 0.625rem 1.25rem;
border-radius: 0.75rem;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
font-size: 0.875rem;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 86, 150, 0.3);
}
.btn-secondary {
background: white;
color: #475569;
padding: 0.625rem 1.25rem;
border-radius: 0.75rem;
font-weight: 600;
border: 2px solid #e2e8f0;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
font-size: 0.875rem;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #cbd5e1;
transform: translateY(-1px);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="p-6"> <div class="px-4 py-6">
<!-- Header --> <!-- Page Header -->
<div class="mb-6"> <div class="page-header animate-in">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-2xl font-bold text-[#005696]">{% trans "Complaint Adverse Actions" %}</h1> <h1 class="text-2xl font-bold mb-2">
<p class="text-[#64748b] mt-1">{% trans "Track and manage adverse actions or damages to patients related to complaints" %}</p> <i data-lucide="shield-alert" class="w-7 h-7 inline-block me-2"></i>
{% trans "Adverse Actions" %}
</h1>
<p class="text-white/90">{% trans "Track and manage adverse actions related to complaints" %}</p>
</div> </div>
<a href="{% url 'complaints:complaint_list' %}" class="px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 transition flex items-center gap-2"> <a href="{% url 'complaints:complaint_list' %}" class="btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4"></i> <i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back to Complaints" %} {% trans "Back to Complaints" %}
</a> </a>
@ -20,15 +171,24 @@
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4 mb-6"> <div class="filter-card mb-6 animate-in">
<div class="card-header">
<h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i data-lucide="filter" class="w-5 h-5"></i>
{% trans "Filters" %}
</h2>
</div>
<div class="p-6">
<form method="get" class="flex flex-wrap gap-4"> <form method="get" class="flex flex-wrap gap-4">
<div class="flex-1 min-w-[200px]"> <div class="flex-1 min-w-[250px]">
<label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Search" %}</label>
<input type="text" name="search" value="{{ filters.search|default:'' }}" <input type="text" name="search" value="{{ filters.search|default:'' }}"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent" placeholder="{% trans 'Reference or description...' %}"
placeholder="{% trans 'Search by reference or description...' %}"> class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue focus:ring-2 focus:ring-blue/20">
</div> </div>
<div> <div>
<select name="status" class="px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent"> <label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Status" %}</label>
<select name="status" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% trans "All Statuses" %}</option> <option value="">{% trans "All Statuses" %}</option>
{% for value, label in status_choices %} {% for value, label in status_choices %}
<option value="{{ value }}" {% if filters.status == value %}selected{% endif %}>{{ label }}</option> <option value="{{ value }}" {% if filters.status == value %}selected{% endif %}>{{ label }}</option>
@ -36,7 +196,8 @@
</select> </select>
</div> </div>
<div> <div>
<select name="severity" class="px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent"> <label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Severity" %}</label>
<select name="severity" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% trans "All Severities" %}</option> <option value="">{% trans "All Severities" %}</option>
{% for value, label in severity_choices %} {% for value, label in severity_choices %}
<option value="{{ value }}" {% if filters.severity == value %}selected{% endif %}>{{ label }}</option> <option value="{{ value }}" {% if filters.severity == value %}selected{% endif %}>{{ label }}</option>
@ -44,110 +205,117 @@
</select> </select>
</div> </div>
<div> <div>
<select name="action_type" class="px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent"> <label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Type" %}</label>
<select name="action_type" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% trans "All Types" %}</option> <option value="">{% trans "All Types" %}</option>
{% for value, label in action_type_choices %} {% for value, label in action_type_choices %}
<option value="{{ value }}" {% if filters.action_type == value %}selected{% endif %}>{{ label }}</option> <option value="{{ value }}" {% if filters.action_type == value %}selected{% endif %}>{{ label }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<button type="submit" class="px-4 py-2 bg-[#005696] text-white rounded-lg hover:bg-[#007bbd] transition flex items-center gap-2"> <div class="flex items-end">
<i data-lucide="filter" class="w-4 h-4"></i> <button type="submit" class="btn-primary h-[46px]">
<i data-lucide="search" class="w-4 h-4"></i>
{% trans "Filter" %} {% trans "Filter" %}
</button> </button>
</div>
</form> </form>
</div> </div>
</div>
<!-- Adverse Actions Table --> <!-- Adverse Actions Table -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"> <div class="data-card animate-in">
<div class="card-header flex items-center justify-between">
<h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i data-lucide="shield-alert" class="w-5 h-5"></i>
{% trans "All Adverse Actions" %} ({{ page_obj.paginator.count }})
</h2>
<a href="#" class="btn-primary">
<i data-lucide="plus" class="w-4 h-4"></i>
{% trans "New Adverse Action" %}
</a>
</div>
<div class="p-0">
{% if page_obj %}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full data-table">
<thead class="bg-gray-50"> <thead>
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-[#64748b] uppercase">{% trans "Complaint" %}</th> <th>{% trans "Complaint" %}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-[#64748b] uppercase">{% trans "Type" %}</th> <th>{% trans "Type" %}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-[#64748b] uppercase">{% trans "Severity" %}</th> <th>{% trans "Severity" %}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-[#64748b] uppercase">{% trans "Date" %}</th> <th>{% trans "Date" %}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-[#64748b] uppercase">{% trans "Status" %}</th> <th class="text-center">{% trans "Status" %}</th>
<th class="px-6 py-3 text-center text-xs font-medium text-[#64748b] uppercase">{% trans "Escalated" %}</th> <th class="text-center">{% trans "Escalated" %}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-[#64748b] uppercase">{% trans "Actions" %}</th> <th class="text-right">{% trans "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-100"> <tbody class="divide-y divide-slate-100">
{% for action in page_obj %} {% for action in page_obj %}
<tr class="hover:bg-gray-50"> <tr onclick="window.location='{% url 'complaints:adverse_action_edit' action.pk %}'">
<td class="px-6 py-4"> <td>
<a href="{% url 'complaints:complaint_detail' action.complaint.id %}" class="font-medium text-[#005696] hover:text-[#007bbd]"> <a href="{% url 'complaints:complaint_detail' action.complaint.id %}"
{{ action.complaint.reference_number }} class="font-semibold text-navy hover:text-blue transition">
{{ action.complaint.reference_number|truncatechars:15 }}
</a> </a>
<p class="text-sm text-[#64748b] truncate max-w-[200px]">{{ action.complaint.title }}</p> <p class="text-xs text-slate mt-1">{{ action.complaint.title|truncatechars:30 }}</p>
</td> </td>
<td class="px-6 py-4"> <td>
<span class="text-sm text-gray-900">{{ action.get_action_type_display }}</span> <span class="text-sm font-medium text-slate-700">{{ action.get_action_type_display }}</span>
</td> </td>
<td class="px-6 py-4"> <td>
{% if action.severity == 'critical' %} <span class="severity-badge {{ action.severity }}">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800"> <i data-lucide="{% if action.severity == 'low' %}arrow-down{% elif action.severity == 'medium' %}minus{% elif action.severity == 'high' %}arrow-up{% else %}zap{% endif %}" class="w-3 h-3"></i>
{{ action.get_severity_display }} {{ action.get_severity_display }}
</span> </span>
{% elif action.severity == 'high' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
{{ action.get_severity_display }}
</span>
{% elif action.severity == 'medium' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
{{ action.get_severity_display }}
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{{ action.get_severity_display }}
</span>
{% endif %}
</td> </td>
<td class="px-6 py-4 text-sm text-gray-900"> <td>
{{ action.incident_date|date:"Y-m-d" }} <div class="text-sm text-slate">
<p>{{ action.incident_date|date:"Y-m-d" }}</p>
</div>
</td> </td>
<td class="px-6 py-4"> <td class="text-center">
{% if action.status == 'reported' %} <span class="status-badge {{ action.status }}">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800"> <i data-lucide="{% if action.status == 'reported' %}circle{% elif action.status == 'under_investigation' %}clock{% elif action.status == 'resolved' %}check-circle{% else %}check{% endif %}" class="w-3 h-3"></i>
{{ action.get_status_display }} {{ action.get_status_display }}
</span> </span>
{% elif action.status == 'under_investigation' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{{ action.get_status_display }}
</span>
{% elif action.status == 'verified' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{{ action.get_status_display }}
</span>
{% elif action.status == 'resolved' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{{ action.get_status_display }}
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
{{ action.get_status_display }}
</span>
{% endif %}
</td> </td>
<td class="px-6 py-4 text-center"> <td class="text-center">
{% if action.is_escalated %} {% if action.is_escalated %}
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-500 mx-auto"></i> <span class="inline-flex items-center gap-1 text-red-600 font-bold text-sm">
<i data-lucide="triangle-alert" class="w-4 h-4"></i>
{% trans "Yes" %}
</span>
{% else %} {% else %}
<span class="text-gray-300">-</span> <span class="text-slate-400 text-sm">{% trans "No" %}</span>
{% endif %} {% endif %}
</td> </td>
<td class="px-6 py-4 text-right"> <td class="text-right">
<a href="{% url 'complaints:complaint_detail' action.complaint.id %}" class="text-[#005696] hover:text-[#007bbd] font-medium text-sm"> <div class="flex items-center justify-end gap-2">
{% trans "View" %} <a href="{% url 'complaints:adverse_action_edit' action.pk %}"
class="p-2 text-blue hover:bg-blue-50 rounded-lg transition"
title="{% trans 'Edit' %}">
<i data-lucide="edit" class="w-4 h-4"></i>
</a> </a>
<a href="{% url 'complaints:adverse_action_delete' action.pk %}"
class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition"
title="{% trans 'Delete' %}"
onclick="event.stopPropagation();">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</a>
</div>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="7" class="px-6 py-8 text-center text-[#64748b]"> <td colspan="7" class="py-12 text-center">
<i data-lucide="shield-check" class="w-12 h-12 mx-auto mb-3 text-gray-300"></i> <div class="flex flex-col items-center">
<p>{% trans "No adverse actions found." %}</p> <div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-4">
<i data-lucide="shield-alert" class="w-8 h-8 text-slate-400"></i>
</div>
<p class="text-slate font-medium">{% trans "No adverse actions found" %}</p>
<p class="text-slate text-sm mt-1">{% trans "Create your first adverse action to get started" %}</p>
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -157,32 +325,38 @@
<!-- Pagination --> <!-- Pagination -->
{% if page_obj.has_other_pages %} {% if page_obj.has_other_pages %}
<div class="px-6 py-4 border-t border-gray-100 flex items-center justify-between"> <div class="p-4 border-t border-slate-200">
<p class="text-sm text-[#64748b]"> <div class="flex items-center justify-between">
{% blocktrans with page_obj.number as page and page_obj.paginator.num_pages as total %} <p class="text-sm text-slate">
Page {{ page }} of {{ total }} {% blocktrans with start=page_obj.start_index end=page_obj.end_index total=page_obj.paginator.count %}
Showing {{ start }} to {{ end }} of {{ total }} adverse actions
{% endblocktrans %} {% endblocktrans %}
</p> </p>
<div class="flex gap-2"> <div class="flex gap-2">
{% if page_obj.has_previous %} {% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}&{{ filters.urlencode }}" class="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50"> <a href="?page={{ page_obj.previous_page_number }}{% if filters.search %}&search={{ filters.search }}{% endif %}{% if filters.status %}&status={{ filters.status }}{% endif %}{% if filters.severity %}&severity={{ filters.severity }}{% endif %}{% if filters.action_type %}&action_type={{ filters.action_type }}{% endif %}"
<i data-lucide="chevron-left" class="w-4 h-4"></i> class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
{% trans "Previous" %}
</a> </a>
{% endif %} {% endif %}
{% if page_obj.has_next %} {% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}&{{ filters.urlencode }}" class="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50"> <a href="?page={{ page_obj.next_page_number }}{% if filters.search %}&search={{ filters.search }}{% endif %}{% if filters.status %}&status={{ filters.status }}{% endif %}{% if filters.severity %}&severity={{ filters.severity }}{% endif %}{% if filters.action_type %}&action_type={{ filters.action_type }}{% endif %}"
<i data-lucide="chevron-right" class="w-4 h-4"></i> class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
{% trans "Next" %}
</a> </a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
{% endif %} {% endif %}
{% endif %}
</div>
</div> </div>
</div> </div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons(); lucide.createIcons();
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -3,16 +3,143 @@
{% block title %}{% trans "Complaints Analytics" %} - PX360{% endblock %} {% block title %}{% trans "Complaints Analytics" %} - PX360{% endblock %}
{% block extra_css %}
<style>
:root {
--hh-navy: #005696;
--hh-blue: #007bbd;
--hh-light: #eef6fb;
--hh-slate: #64748b;
--hh-success: #10b981;
--hh-warning: #f59e0b;
--hh-danger: #ef4444;
}
.page-header {
background: linear-gradient(135deg, var(--hh-navy) 0%, #0069a8 50%, var(--hh-blue) 100%);
color: white;
padding: 2rem 2.5rem;
border-radius: 1rem;
margin-bottom: 2rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.stat-card {
background: white;
border-radius: 1rem;
padding: 1.5rem;
border: 1px solid #e2e8f0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
}
.stat-card.blue::before { background: linear-gradient(90deg, #3b82f6, #2563eb); }
.stat-card.orange::before { background: linear-gradient(90deg, #f97316, #ea580c); }
.stat-card.red::before { background: linear-gradient(90deg, #ef4444, #dc2626); }
.stat-card.green::before { background: linear-gradient(90deg, #10b981, #059669); }
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.1);
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon.blue { background: linear-gradient(135deg, #dbeafe, #bfdbfe); }
.stat-icon.orange { background: linear-gradient(135deg, #ffedd5, #fed7aa); }
.stat-icon.red { background: linear-gradient(135deg, #fee2e2, #fecaca); }
.stat-icon.green { background: linear-gradient(135deg, #dcfce7, #bbf7d0); }
.chart-card {
background: white;
border-radius: 1rem;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.chart-card:hover {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.chart-card .card-header {
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
padding: 1.25rem 1.75rem;
border-bottom: 1px solid #bae6fd;
border-radius: 1rem 1rem 0 0;
}
.category-list {
list-style: none;
padding: 0;
margin: 0;
}
.category-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 0;
border-bottom: 1px solid #f1f5f9;
}
.category-item:last-child {
border-bottom: none;
}
.category-badge {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.875rem;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="mb-8"> <div class="px-4 py-6">
<div class="flex justify-between items-center"> <!-- Page Header -->
<div class="page-header animate-in">
<div class="flex items-center justify-between">
<div> <div>
<h1 class="text-3xl font-bold text-gray-800 mb-2">{% trans "Complaints Analytics" %}</h1> <h1 class="text-2xl font-bold mb-2">
<p class="text-gray-400">{% trans "Comprehensive complaints metrics and insights" %}</p> <i data-lucide="circle-help" class="w-7 h-7 inline-block me-2"></i>
{% trans "Complaints Analytics" %}
</h1>
<p class="text-white/90">{% trans "Comprehensive complaints metrics and insights" %}</p>
</div> </div>
<div>
<form method="get" class="inline-flex"> <form method="get" class="inline-flex">
<select name="date_range" class="px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition" onchange="this.form.submit()"> <select name="date_range" class="px-4 py-2.5 bg-white/20 border border-white/30 rounded-xl text-white focus:ring-2 focus:ring-white/50 focus:border-transparent transition" onchange="this.form.submit()">
<option value="7" {% if date_range == 7 %}selected{% endif %}>{% trans "Last 7 Days" %}</option> <option value="7" {% if date_range == 7 %}selected{% endif %}>{% trans "Last 7 Days" %}</option>
<option value="30" {% if date_range == 30 %}selected{% endif %}>{% trans "Last 30 Days" %}</option> <option value="30" {% if date_range == 30 %}selected{% endif %}>{% trans "Last 30 Days" %}</option>
<option value="90" {% if date_range == 90 %}selected{% endif %}>{% trans "Last 90 Days" %}</option> <option value="90" {% if date_range == 90 %}selected{% endif %}>{% trans "Last 90 Days" %}</option>
@ -20,297 +147,291 @@
</form> </form>
</div> </div>
</div> </div>
</div>
<!-- Summary Cards --> <!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div class="bg-white p-6 rounded-2xl shadow-sm border-l-4 border-blue-500 border border-gray-50 hover:shadow-md transition"> <div class="stat-card blue animate-in">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <div>
<div class="text-xs font-bold text-blue-600 uppercase mb-1">{% trans "Total Complaints" %}</div> <p class="text-xs font-bold text-blue-600 uppercase mb-1">{% trans "Total Complaints" %}</p>
<div class="text-3xl font-bold text-gray-800 mb-2">{{ dashboard_summary.status_counts.total }}</div> <p class="text-3xl font-black text-navy mb-2">{{ dashboard_summary.status_counts.total }}</p>
<small class="text-gray-500 flex items-center gap-1"> <div class="flex items-center gap-1 text-sm">
{% if dashboard_summary.trend.percentage_change > 0 %} {% if dashboard_summary.trend.percentage_change > 0 %}
<i data-lucide="trending-up" class="w-4 h-4 text-red-500"></i> +{{ dashboard_summary.trend.percentage_change }}% <i data-lucide="trending-up" class="w-4 h-4 text-red-500"></i>
<span class="text-red-500 font-bold">+{{ dashboard_summary.trend.percentage_change }}%</span>
{% elif dashboard_summary.trend.percentage_change < 0 %} {% elif dashboard_summary.trend.percentage_change < 0 %}
<i data-lucide="trending-down" class="w-4 h-4 text-green-500"></i> {{ dashboard_summary.trend.percentage_change }}% <i data-lucide="trending-down" class="w-4 h-4 text-green-500"></i>
<span class="text-green-500 font-bold">{{ dashboard_summary.trend.percentage_change }}%</span>
{% else %} {% else %}
<i data-lucide="minus" class="w-4 h-4 text-gray-400"></i> 0% <i data-lucide="minus" class="w-4 h-4 text-slate-400"></i>
<span class="text-slate-400 font-bold">0%</span>
{% endif %} {% endif %}
{% trans "vs last period" %} <span class="text-slate-500">{% trans "vs last period" %}</span>
</small>
</div> </div>
<div class="bg-blue-100 p-3 rounded-xl"> </div>
<i data-lucide="activity" class="text-blue-500 w-6 h-6"></i> <div class="stat-icon blue">
<i data-lucide="activity" class="text-blue-600 w-6 h-6"></i>
</div> </div>
</div> </div>
</div> </div>
<div class="bg-white p-6 rounded-2xl shadow-sm border-l-4 border-orange-500 border border-gray-50 hover:shadow-md transition"> <div class="stat-card orange animate-in">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <div>
<div class="text-xs font-bold text-orange-600 uppercase mb-1">{% trans "Open" %}</div> <p class="text-xs font-bold text-orange-600 uppercase mb-1">{% trans "Open" %}</p>
<div class="text-3xl font-bold text-gray-800">{{ dashboard_summary.status_counts.open }}</div> <p class="text-3xl font-black text-navy">{{ dashboard_summary.status_counts.open }}</p>
</div> </div>
<div class="bg-orange-100 p-3 rounded-xl"> <div class="stat-icon orange">
<i data-lucide="folder-open" class="text-orange-500 w-6 h-6"></i> <i data-lucide="folder-open" class="text-orange-600 w-6 h-6"></i>
</div> </div>
</div> </div>
</div> </div>
<div class="bg-white p-6 rounded-2xl shadow-sm border-l-4 border-red-500 border border-gray-50 hover:shadow-md transition"> <div class="stat-card red animate-in">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <div>
<div class="text-xs font-bold text-red-600 uppercase mb-1">{% trans "Overdue" %}</div> <p class="text-xs font-bold text-red-600 uppercase mb-1">{% trans "Overdue" %}</p>
<div class="text-3xl font-bold text-red-500">{{ dashboard_summary.status_counts.overdue }}</div> <p class="text-3xl font-black text-red-500">{{ dashboard_summary.status_counts.overdue }}</p>
</div> </div>
<div class="bg-red-100 p-3 rounded-xl"> <div class="stat-icon red">
<i data-lucide="alert-triangle" class="text-red-500 w-6 h-6"></i> <i data-lucide="alert-triangle" class="text-red-600 w-6 h-6"></i>
</div> </div>
</div> </div>
</div> </div>
<div class="bg-white p-6 rounded-2xl shadow-sm border-l-4 border-green-500 border border-gray-50 hover:shadow-md transition"> <div class="stat-card green animate-in">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <div>
<div class="text-xs font-bold text-green-600 uppercase mb-1">{% trans "Resolved" %}</div> <p class="text-xs font-bold text-green-600 uppercase mb-1">{% trans "Resolved" %}</p>
<div class="text-3xl font-bold text-gray-800">{{ dashboard_summary.status_counts.resolved }}</div> <p class="text-3xl font-black text-navy">{{ dashboard_summary.status_counts.resolved }}</p>
</div>
<div class="stat-icon green">
<i data-lucide="check-circle" class="text-green-600 w-6 h-6"></i>
</div> </div>
<div class="bg-green-100 p-3 rounded-xl">
<i data-lucide="check-circle" class="text-green-500 w-6 h-6"></i>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8"> <!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<!-- Complaints Trend --> <!-- Complaints Trend -->
<div class="lg:col-span-2 bg-white rounded-2xl shadow-sm border border-gray-50 p-6"> <div class="lg:col-span-2 chart-card animate-in">
<h3 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2"> <div class="card-header">
<i data-lucide="trending-up" class="w-5 h-5"></i> {% trans "Complaints Trend" %} <h3 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i data-lucide="trending-up" class="w-5 h-5"></i>
{% trans "Complaints Trend" %}
</h3> </h3>
<div id="trendChart"></div> </div>
<div class="p-6">
<div id="trendChart" style="min-height: 300px;"></div>
</div>
</div> </div>
<!-- Top Categories --> <!-- Top Categories -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 p-6"> <div class="chart-card animate-in">
<h3 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2"> <div class="card-header">
<i data-lucide="pie-chart" class="w-5 h-5"></i> {% trans "Top Categories" %} <h3 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i data-lucide="pie-chart" class="w-5 h-5"></i>
{% trans "Top Categories" %}
</h3> </h3>
<div id="categoryChart"></div>
</div> </div>
</div> <div class="p-6">
<ul class="category-list">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8"> {% for category in top_categories %}
<!-- SLA Compliance --> <li class="category-item">
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 p-6"> <div class="category-badge bg-blue-100 text-blue-600">
<h3 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2"> {{ forloop.counter }}
<i data-lucide="clock" class="w-5 h-5"></i> {% trans "SLA Compliance" %}
</h3>
<div class="text-center mb-6">
<h2 class="{% if sla_compliance.overall_compliance_rate >= 80 %}text-green-500{% elif sla_compliance.overall_compliance_rate >= 60 %}text-orange-500{% else %}text-red-500{% endif %} text-4xl font-bold mb-2">
{{ sla_compliance.overall_compliance_rate }}%
</h2>
<p class="text-gray-400">{% trans "Overall Compliance Rate" %}</p>
</div> </div>
<div class="grid grid-cols-2 gap-4 text-center"> <div class="flex-1">
<div class="bg-green-50 rounded-xl p-4"> <p class="font-semibold text-navy text-sm">{{ category.category__name_en|default:"Uncategorized" }}</p>
<h4 class="text-2xl font-bold text-green-600">{{ sla_compliance.on_time }}</h4> <p class="text-xs text-slate-500">{{ category.count }} complaints</p>
<small class="text-gray-500">{% trans "On Time" %}</small>
</div> </div>
<div class="bg-red-50 rounded-xl p-4"> <div class="text-right">
<h4 class="text-2xl font-bold text-red-500">{{ sla_compliance.overdue }}</h4> <p class="font-bold text-navy">{{ category.percentage|floatformat:1 }}%</p>
<small class="text-gray-500">{% trans "Overdue" %}</small> </div>
</li>
{% empty %}
<li class="text-center py-8 text-slate-500">
<i data-lucide="pie-chart" class="w-12 h-12 mx-auto mb-2 opacity-30"></i>
<p>{% trans "No category data available" %}</p>
</li>
{% endfor %}
</ul>
</div> </div>
</div> </div>
</div> </div>
<!-- Resolution Rate --> <!-- Second Row -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 p-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<h3 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2"> <!-- Department Distribution -->
<i data-lucide="check-square" class="w-5 h-5"></i> {% trans "Resolution Metrics" %} <div class="chart-card animate-in">
<div class="card-header">
<h3 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i data-lucide="building" class="w-5 h-5"></i>
{% trans "Department Distribution" %}
</h3> </h3>
<div class="mb-6">
<div class="flex justify-between mb-2">
<span class="text-gray-600">{% trans "Resolution Rate" %}</span>
<strong class="text-gray-800">{{ resolution_rate.resolution_rate }}%</strong>
</div> </div>
<div class="h-3 bg-gray-100 rounded-full overflow-hidden"> <div class="p-6">
<div class="h-full bg-green-500 rounded-full" style="width: {{ resolution_rate.resolution_rate }}%"></div> <div id="departmentChart" style="min-height: 250px;"></div>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-4 text-center">
<div>
<h4 class="text-2xl font-bold text-gray-800">{{ resolution_rate.resolved }}</h4>
<small class="text-gray-500">{% trans "Resolved" %}</small>
</div>
<div>
<h4 class="text-2xl font-bold text-gray-800">{{ resolution_rate.pending }}</h4>
<small class="text-gray-500">{% trans "Pending" %}</small>
</div>
</div>
{% if resolution_rate.avg_resolution_time_hours %}
<div class="mt-6 text-center bg-gray-50 rounded-xl p-4">
<p class="text-gray-600 mb-1">{% trans "Avg Resolution Time" %}</p>
<h5 class="text-xl font-bold text-gray-800">{{ resolution_rate.avg_resolution_time_hours }} {% trans "hours" %}</h5>
</div>
{% endif %}
</div>
</div>
<!-- Overdue Complaints --> <!-- Severity Breakdown -->
{% if overdue_complaints %} <div class="chart-card animate-in">
<div class="bg-white rounded-2xl shadow-sm border border-gray-50"> <div class="card-header">
<div class="p-6 border-b border-gray-100"> <h3 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<h3 class="text-lg font-bold text-red-500 flex items-center gap-2"> <i data-lucide="circle-help" class="w-5 h-5"></i>
<i data-lucide="alert-triangle" class="w-5 h-5"></i> {% trans "Overdue Complaints" %} {% trans "Severity Breakdown" %}
</h3> </h3>
</div> </div>
<div class="p-6">
<div id="severityChart" style="min-height: 250px;"></div>
</div>
</div>
</div>
<!-- Hospital Performance -->
<div class="chart-card animate-in">
<div class="card-header">
<h3 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i data-lucide="hospital" class="w-5 h-5"></i>
{% trans "Hospital Performance" %}
</h3>
</div>
<div class="p-0">
{% if hospital_performance %}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">
<thead class="bg-gray-50"> <thead>
<tr> <tr class="border-b-2 border-slate-200">
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "ID" %}</th> <th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Hospital" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Source" %}</th> <th class="text-center py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Total" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Title" %}</th> <th class="text-center py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Open" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Patient" %}</th> <th class="text-center py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Resolved" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Severity" %}</th> <th class="text-center py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Overdue" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Due Date" %}</th> <th class="text-center py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Resolution Rate" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Assigned To" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-50"> <tbody class="divide-y divide-slate-100">
{% for complaint in overdue_complaints %} {% for hospital in hospital_performance %}
<tr class="hover:bg-gray-50 transition"> <tr class="hover:bg-slate-50 transition">
<td class="px-6 py-4"> <td class="py-3 px-4">
<a href="{% url 'complaints:complaint_detail' complaint.id %}" class="text-navy hover:underline"> <span class="font-semibold text-navy">{{ hospital.hospital__name }}</span>
<code class="bg-gray-100 px-2 py-1 rounded text-sm font-semibold text-gray-700">{{ complaint.id|slice:8 }}</code>
</a>
</td> </td>
<td class="px-6 py-4"> <td class="py-3 px-4 text-center">
{% if complaint.source_name %} <span class="font-bold text-navy">{{ hospital.total }}</span>
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-blue-100 text-blue-600" title="{% trans 'PX Source' %}: {{ complaint.source_name }}">
<i data-lucide="cloud-arrow-down" class="w-3 h-3 inline mr-1"></i> {{ complaint.source_name|truncatechars:12 }}
</span>
{% elif complaint.complaint_source_type == 'internal' %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-indigo-100 text-indigo-600" title="{% trans 'Internal' %}">
<i data-lucide="building" class="w-3 h-3 inline mr-1"></i> {% trans "Internal" %}
</span>
{% else %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-gray-100 text-gray-600" title="{% trans 'External' %}">
<i data-lucide="user" class="w-3 h-3 inline mr-1"></i> {% trans "Patient" %}
</span>
{% endif %}
</td> </td>
<td class="px-6 py-4"> <td class="py-3 px-4 text-center">
<span class="text-gray-700">{{ complaint.title|truncatechars:50 }}</span> <span class="px-2.5 py-1 bg-blue-100 text-blue-700 rounded-full text-xs font-bold">{{ hospital.open }}</span>
</td> </td>
<td class="px-6 py-4"> <td class="py-3 px-4 text-center">
<span class="text-gray-700">{{ complaint.patient_full_name }}</span> <span class="px-2.5 py-1 bg-green-100 text-green-700 rounded-full text-xs font-bold">{{ hospital.resolved }}</span>
</td> </td>
<td class="px-6 py-4"> <td class="py-3 px-4 text-center">
<span class="px-2.5 py-1 rounded-lg text-xs font-bold {% if complaint.severity == 'critical' %}bg-red-500 text-white{% elif complaint.severity == 'high' %}bg-orange-100 text-orange-600{% else %}bg-gray-100 text-gray-600{% endif %}"> <span class="px-2.5 py-1 bg-red-100 text-red-700 rounded-full text-xs font-bold">{{ hospital.overdue }}</span>
{{ complaint.severity }}
</span>
</td> </td>
<td class="px-6 py-4"> <td class="py-3 px-4 text-center">
<span class="text-red-500 font-semibold">{{ complaint.due_at|date:"Y-m-d H:i" }}</span> <div class="flex items-center justify-center gap-2">
</td> <div class="w-24 bg-slate-200 rounded-full h-2">
<td class="px-6 py-4"> <div class="bg-green-500 h-2 rounded-full" style="width: {{ hospital.resolution_rate }}%"></div>
<span class="text-gray-700">{{ complaint.assigned_to_full_name|default:"Unassigned" }}</span> </div>
</td> <span class="font-bold text-navy text-sm">{{ hospital.resolution_rate|floatformat:1 }}%</span>
<td class="px-6 py-4"> </div>
<a href="{% url 'complaints:complaint_detail' complaint.id %}" class="p-2 bg-light text-navy rounded-lg hover:bg-blue-100 transition">
<i data-lucide="eye" class="w-4 h-4"></i>
</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %}
<div class="text-center py-12">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="hospital" class="w-8 h-8 text-slate-400"></i>
</div>
<p class="text-slate font-medium">{% trans "No hospital data available" %}</p>
</div>
{% endif %}
</div>
</div>
</div> </div>
{% endif %}
<!-- ApexCharts -->
<script src="https://cdn.jsdelivr.net/npm/apexcharts@3.45.1/dist/apexcharts.min.js"></script>
<script> <script>
// Trend Chart - ApexCharts document.addEventListener('DOMContentLoaded', function() {
var trendOptions = { lucide.createIcons();
// Trend Chart
const trendOptions = {
series: [{ series: [{
name: '{% trans "Complaints" %}', name: '{% trans "Complaints" %}',
data: {{ trends.data|safe }} data: {{ trend_data|safe }}
}], }],
chart: { chart: {
type: 'line', type: 'area',
height: 320, height: 300,
toolbar: { fontFamily: 'Inter, sans-serif',
show: false toolbar: { show: false }
},
colors: ['#007bbd'],
dataLabels: { enabled: false },
stroke: { curve: 'smooth', width: 3 },
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.4,
opacityTo: 0.1,
} }
}, },
stroke: {
curve: 'smooth',
width: 3
},
colors: ['#4bc0c0'],
xaxis: { xaxis: {
categories: {{ trends.labels|safe }}, categories: {{ trend_labels|safe }},
labels: { labels: { style: { fontSize: '12px', colors: '#64748b' } }
style: {
fontSize: '12px'
}
}
}, },
yaxis: { yaxis: {
min: 0, labels: { style: { fontSize: '12px', colors: '#64748b' } }
forceNiceScale: true,
labels: {
style: {
fontSize: '12px'
}
}
}, },
grid: { grid: { borderColor: '#e2e8f0', strokeDashArray: 4 }
borderColor: '#e7e7e7', };
strokeDashArray: 5 new ApexCharts(document.querySelector("#trendChart"), trendOptions).render();
},
tooltip: {
theme: 'light'
}
};
var trendChart = new ApexCharts(document.querySelector("#trendChart"), trendOptions);
trendChart.render();
// Category Chart - ApexCharts {% if severity_data %}
var categoryOptions = { // Severity Chart
series: [{% for cat in top_categories.categories %}{{ cat.count }}{% if not forloop.last %},{% endif %}{% endfor %}], const severityOptions = {
series: {{ severity_data|safe }},
chart: { chart: {
type: 'donut', type: 'donut',
height: 360 height: 250,
}, fontFamily: 'Inter, sans-serif',
labels: [{% for cat in top_categories.categories %}'{{ cat.category }}'{% if not forloop.last %},{% endif %}{% endfor %}], toolbar: { show: false }
colors: ['#ff6384', '#36a2eb', '#ffce56', '#4bc0c0', '#9966ff', '#ff9f40'],
legend: {
position: 'bottom',
fontSize: '12px'
},
dataLabels: {
enabled: true,
formatter: function (val) {
return val.toFixed(1) + "%"
}
}, },
labels: ['Low', 'Medium', 'High', 'Critical'],
colors: ['#10b981', '#f59e0b', '#ef4444', '#dc2626'],
plotOptions: { plotOptions: {
pie: { pie: {
donut: { donut: {
size: '65%' size: '65%',
labels: {
show: true,
name: { fontSize: '14px', fontWeight: 600 },
value: { fontSize: '18px', fontWeight: 700 },
total: {
show: true,
label: 'Total',
formatter: function(w) {
return w.globals.seriesTotals.reduce((a, b) => a + b, 0);
}
}
}
} }
} }
}, },
tooltip: { dataLabels: { enabled: false },
theme: 'light' legend: { position: 'bottom', fontSize: '12px' }
} };
}; new ApexCharts(document.querySelector("#severityChart"), severityOptions).render();
var categoryChart = new ApexCharts(document.querySelector("#categoryChart"), categoryOptions); {% endif %}
categoryChart.render(); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -1,15 +1,91 @@
{% extends 'layouts/base.html' %} {% extends 'layouts/base.html' %}
{% load i18n %} {% load i18n %}
{% load static %}
{% block title %}Inquiry #{{ inquiry.id|slice:":8" }} - PX360{% endblock %} {% block title %}{% trans "Inquiry" %} #{{ inquiry.reference_number|truncatechars:15 }} - PX360{% endblock %}
{% block extra_css %} {% block extra_css %}
<style> <style>
:root {
--hh-navy: #005696;
--hh-blue: #007bbd;
--hh-cyan: #06b6d4;
--hh-light: #eef6fb;
--hh-slate: #64748b;
--hh-success: #10b981;
--hh-warning: #f59e0b;
--hh-danger: #ef4444;
}
.detail-card {
background: white;
border-radius: 1rem;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.detail-card:hover {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.card-header {
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
padding: 1.25rem 1.75rem;
border-bottom: 1px solid #bae6fd;
border-radius: 1rem 1rem 0 0;
}
.info-label {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--hh-slate);
margin-bottom: 0.375rem;
}
.info-value {
font-size: 0.95rem;
color: #1e293b;
font-weight: 500;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 700;
}
.status-badge.open { background: linear-gradient(135deg, #e3f2fd, #bbdefb); color: #1565c0; }
.status-badge.in_progress { background: linear-gradient(135deg, #fff3e0, #ffe0b2); color: #e65100; }
.status-badge.resolved { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); color: #2e7d32; }
.status-badge.closed { background: linear-gradient(135deg, #f5f5f5, #e0e0e0); color: #616161; }
.priority-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
}
.priority-badge.low { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); color: #2e7d32; }
.priority-badge.medium { background: linear-gradient(135deg, #fff3e0, #ffe0b2); color: #e65100; }
.priority-badge.high { background: linear-gradient(135deg, #ffebee, #ffcdd2); color: #c62828; }
.priority-badge.urgent { background: linear-gradient(135deg, #880e4f, #ad1457); color: white; }
.timeline { .timeline {
position: relative; position: relative;
padding-left: 30px; padding-left: 2rem;
} }
.timeline::before { .timeline::before {
content: ''; content: '';
position: absolute; position: absolute;
@ -17,60 +93,80 @@
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 2px; width: 2px;
background: #e5e7eb; background: #e2e8f0;
} }
.timeline-item { .timeline-item {
position: relative; position: relative;
padding-bottom: 30px; padding-bottom: 1.5rem;
} }
.timeline-item::before { .timeline-item::before {
content: ''; content: '';
position: absolute; position: absolute;
left: -26px; left: -1.625rem;
top: 5px; top: 4px;
width: 16px; width: 14px;
height: 16px; height: 14px;
border-radius: 50%; border-radius: 50%;
background: white; background: white;
border: 3px solid #17a2b8; border: 3px solid var(--hh-blue);
z-index: 1; z-index: 1;
} }
.timeline-item.status_change::before {
border-color: #f97316; .timeline-item.status_change::before { border-color: var(--hh-warning); }
} .timeline-item.response::before { border-color: var(--hh-success); }
.timeline-item.response::before { .timeline-item.note::before { border-color: var(--hh-navy); }
border-color: #22c55e;
} .btn-primary {
.timeline-item.note::before { background: linear-gradient(135deg, var(--hh-navy) 0%, var(--hh-blue) 100%);
border-color: #3b82f6; color: white;
padding: 0.625rem 1.25rem;
border-radius: 0.75rem;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
font-size: 0.875rem;
} }
.info-label { .btn-primary:hover {
font-weight: 600; transform: translateY(-2px);
color: #9ca3af; box-shadow: 0 8px 16px rgba(0, 86, 150, 0.3);
font-size: 0.75rem; }
text-transform: uppercase;
letter-spacing: 0.05em; @keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
} }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Back Button --> <div class="px-4 py-6">
<div class="mb-6"> <!-- Back Button -->
<div class="mb-6 animate-in">
{% if source_user %} {% if source_user %}
<a href="{% url 'px_sources:source_user_inquiry_list' %}" class="inline-flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-xl text-gray-600 hover:bg-gray-50 transition text-sm font-semibold"> <a href="{% url 'px_sources:source_user_inquiry_list' %}" class="inline-flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 transition text-sm font-semibold">
<i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to My Inquiries" %} <i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to My Inquiries" %}
</a> </a>
{% else %} {% else %}
<a href="{% url 'complaints:inquiry_list' %}" class="inline-flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-xl text-gray-600 hover:bg-gray-50 transition text-sm font-semibold"> <a href="{% url 'complaints:inquiry_list' %}" class="inline-flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 transition text-sm font-semibold">
<i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to Inquiries" %} <i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to Inquiries" %}
</a> </a>
{% endif %} {% endif %}
</div> </div>
<!-- Inquiry Header --> <!-- Inquiry Header -->
<div class="bg-gradient-to-r from-cyan-500 to-teal-500 rounded-2xl p-6 text-white mb-6"> <div class="bg-gradient-to-r from-cyan-500 to-teal-500 rounded-2xl p-6 text-white mb-6 animate-in">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div> <div>
<div class="flex flex-wrap items-center gap-3 mb-4"> <div class="flex flex-wrap items-center gap-3 mb-4">
@ -78,448 +174,279 @@
<span class="px-3 py-1 bg-white/20 rounded-full text-sm font-semibold"> <span class="px-3 py-1 bg-white/20 rounded-full text-sm font-semibold">
{% trans "Inquiry" %} {% trans "Inquiry" %}
</span> </span>
<span class="status-badge {{ inquiry.status }}">
{% if inquiry.status == 'open' %} <i data-lucide="{% if inquiry.status == 'open' %}circle{% elif inquiry.status == 'in_progress' %}clock{% elif inquiry.status == 'resolved' %}check-circle{% else %}check{% endif %}" class="w-3 h-3"></i>
<span class="px-3 py-1 bg-blue-500 rounded-full text-sm font-semibold">{% trans "Open" %}</span> {{ inquiry.get_status_display }}
{% elif inquiry.status == 'in_progress' %} </span>
<span class="px-3 py-1 bg-orange-500 rounded-full text-sm font-semibold">{% trans "In Progress" %}</span>
{% elif inquiry.status == 'resolved' %}
<span class="px-3 py-1 bg-green-500 rounded-full text-sm font-semibold">{% trans "Resolved" %}</span>
{% elif inquiry.status == 'closed' %}
<span class="px-3 py-1 bg-gray-500 rounded-full text-sm font-semibold">{% trans "Closed" %}</span>
{% endif %}
{% if inquiry.priority %} {% if inquiry.priority %}
<span class="priority-badge {{ inquiry.priority }}">
{% if inquiry.priority == 'low' %} {% if inquiry.priority == 'low' %}
<span class="px-3 py-1 bg-green-200 text-green-800 rounded-full text-sm font-semibold">{% trans "Low" %}</span> <i data-lucide="arrow-down" class="w-3 h-3"></i>
{% elif inquiry.priority == 'medium' %} {% elif inquiry.priority == 'medium' %}
<span class="px-3 py-1 bg-orange-200 text-orange-800 rounded-full text-sm font-semibold">{% trans "Medium" %}</span> <i data-lucide="minus" class="w-3 h-3"></i>
{% elif inquiry.priority == 'high' %} {% elif inquiry.priority == 'high' %}
<span class="px-3 py-1 bg-red-200 text-red-800 rounded-full text-sm font-semibold">{% trans "High" %}</span> <i data-lucide="arrow-up" class="w-3 h-3"></i>
{% elif inquiry.priority == 'urgent' %}
<span class="px-3 py-1 bg-navy text-white rounded-full text-sm font-semibold">{% trans "Urgent" %}</span>
{% endif %}
{% endif %}
</div>
<div class="space-y-2 text-white/90">
<p class="flex items-center gap-2">
<i data-lucide="hash" class="w-4 h-4"></i>
<span><strong>{% trans "ID" %}:</strong> {{ inquiry.id|slice:":8" }}</span>
{% if inquiry.patient %}
<span class="mx-2">|</span>
<span><strong>{% trans "Patient" %}:</strong> {{ inquiry.patient.get_full_name }} ({% trans "MRN" %}: {{ inquiry.patient.mrn }})</span>
{% else %} {% else %}
<span class="mx-2">|</span> <i data-lucide="zap" class="w-3 h-3"></i>
<span><strong>{% trans "Contact" %}:</strong> {{ inquiry.contact_name|default:inquiry.contact_email }}</span>
{% endif %} {% endif %}
</p> {{ inquiry.get_priority_display }}
</span>
<p class="flex items-center gap-2">
<i data-lucide="building-2" class="w-4 h-4"></i>
<span><strong>{% trans "Hospital" %}:</strong> {{ inquiry.hospital.name_en }}</span>
{% if inquiry.department %}
<span class="mx-2">|</span>
<span><strong>{% trans "Department" %}:</strong> {{ inquiry.department.name_en }}</span>
{% endif %}
</p>
</div>
</div>
{% if inquiry.due_date %}
<div class="bg-white/10 rounded-2xl p-5 text-center {% if inquiry.is_overdue %}bg-red-500/20{% endif %}">
<div class="flex items-center justify-center gap-2 mb-2">
<i data-lucide="clock" class="w-5 h-5"></i>
<strong>{% trans "Due Date" %}</strong>
</div>
<h3 class="text-2xl font-bold mb-2">{{ inquiry.due_date|date:"M d, Y H:i" }}</h3>
{% if inquiry.is_overdue %}
<div class="text-red-200 font-bold">
<i data-lucide="alert-triangle" class="w-4 h-4 inline"></i>
{% trans "OVERDUE" %}
</div>
{% else %}
<small class="text-white/80">{{ inquiry.due_date|timeuntil }} {% trans "remaining" %}</small>
{% endif %} {% endif %}
</div> </div>
{% endif %} <p class="text-white/90 text-sm">{{ inquiry.message|truncatewords:30 }}</p>
</div> </div>
</div> <div class="lg:text-right">
<div class="flex flex-wrap gap-2 justify-start lg:justify-end mb-3">
<!-- Tab Navigation --> <span class="px-3 py-1 bg-white/20 rounded-full text-sm">
<div class="bg-white rounded-t-2xl border-b border-gray-100 px-6"> <i data-lucide="hash" class="w-3 h-3 inline-block mr-1"></i>
<div class="flex gap-1 overflow-x-auto" role="tablist"> {{ inquiry.reference_number|truncatechars:15 }}
<button class="tab-btn active" data-target="details" role="tab"> </span>
<i data-lucide="info" class="w-4 h-4"></i> {% trans "Details" %} <span class="px-3 py-1 bg-white/20 rounded-full text-sm">
</button> <i data-lucide="calendar" class="w-3 h-3 inline-block mr-1"></i>
<button class="tab-btn" data-target="timeline" role="tab"> {{ inquiry.created_at|date:"Y-m-d" }}
<i data-lucide="clock-history" class="w-4 h-4"></i> {% trans "Timeline" %} ({{ timeline.count }}) </span>
</button> </div>
<button class="tab-btn" data-target="attachments" role="tab"> {% if can_respond %}
<i data-lucide="paperclip" class="w-4 h-4"></i> {% trans "Attachments" %} ({{ attachments.count }}) <button onclick="showRespondModal()" class="bg-white text-cyan-600 px-4 py-2 rounded-xl font-bold hover:bg-cyan-50 transition text-sm inline-flex items-center gap-2">
<i data-lucide="message-square" class="w-4 h-4"></i>
{% trans "Respond" %}
</button> </button>
{% endif %}
</div>
</div>
</div> </div>
</div>
<!-- Tab Content --> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="bg-white rounded-b-2xl border border-gray-50 shadow-sm mb-6"> <!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Inquiry Details -->
<div class="detail-card animate-in">
<div class="card-header">
<h3 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i data-lucide="file-text" class="w-5 h-5"></i>
{% trans "Inquiry Details" %}
</h3>
</div>
<div class="p-6"> <div class="p-6">
<div class="prose prose-sm max-w-none">
<!-- Details Tab --> <p class="text-slate-700 leading-relaxed">{{ inquiry.message|linebreaks }}</p>
<div class="tab-panel active" id="details">
<h3 class="text-xl font-bold text-gray-800 mb-6">{% trans "Inquiry Details" %}</h3>
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div class="info-label mb-2">{% trans "Category" %}</div>
<span class="px-3 py-1 bg-gray-100 text-gray-700 rounded-lg text-sm font-semibold">{{ inquiry.get_category_display }}</span>
</div> </div>
<div>
<div class="info-label mb-2">{% trans "Source" %}</div>
{% if inquiry.source %}
<span class="px-3 py-1 bg-blue-100 text-blue-700 rounded-lg text-sm font-semibold">{{ inquiry.get_source_display }}</span>
{% else %}
<span class="text-gray-400 text-sm">{% trans "N/A" %}</span>
{% endif %}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div class="info-label mb-2">{% trans "Channel" %}</div>
{% if inquiry.channel %}
<span class="text-gray-700 font-medium">{{ inquiry.get_channel_display }}</span>
{% else %}
<span class="text-gray-400 text-sm">{% trans "N/A" %}</span>
{% endif %}
</div>
<div>
<div class="info-label mb-2">{% trans "Assigned To" %}</div>
<span class="text-gray-700 font-medium">
{% if inquiry.assigned_to %}{{ inquiry.assigned_to.get_full_name }}{% else %}<span class="text-gray-400">{% trans "Unassigned" %}</span>{% endif %}
</span>
</div>
</div>
<hr class="border-gray-200">
<div>
<div class="info-label mb-2">{% trans "Message" %}</div>
<div class="bg-gray-50 rounded-xl p-4 text-gray-700">
{{ inquiry.message|linebreaks }}
</div> </div>
</div> </div>
<!-- Response -->
{% if inquiry.response %} {% if inquiry.response %}
<hr class="border-gray-200"> <div class="detail-card animate-in">
<div> <div class="card-header bg-green-50 border-green-100">
<div class="info-label mb-2">{% trans "Response" %}</div> <h3 class="text-lg font-bold text-green-800 flex items-center gap-2 m-0">
<div class="bg-green-50 border border-green-200 rounded-xl p-4"> <i data-lucide="circle-check" class="w-5 h-5"></i>
<p class="text-gray-700 mb-3">{{ inquiry.response|linebreaks }}</p> {% trans "Response" %}
<small class="text-gray-500"> </h3>
{% trans "Responded by" %} {{ inquiry.responded_by.get_full_name }} {% trans "on" %} {{ inquiry.responded_at|date:"M d, Y H:i" }}
</small>
</div> </div>
</div> <div class="p-6">
{% endif %} <div class="flex items-center gap-3 mb-4 text-sm text-slate-600">
<span class="flex items-center gap-1">
<hr class="border-gray-200"> <i data-lucide="user" class="w-4 h-4"></i>
{{ inquiry.responded_by.get_full_name|default:inquiry.responded_by.email }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> </span>
<div> <span></span>
<div class="info-label mb-2">{% trans "Created" %}</div> <span class="flex items-center gap-1">
<span class="text-gray-700 font-medium">{{ inquiry.created_at|date:"M d, Y H:i" }}</span> <i data-lucide="calendar" class="w-4 h-4"></i>
</div> {{ inquiry.response_sent_at|date:"Y-m-d H:i" }}
<div>
<div class="info-label mb-2">{% trans "Last Updated" %}</div>
<span class="text-gray-700 font-medium">{{ inquiry.updated_at|date:"M d, Y H:i" }}</span>
</div>
</div>
</div>
</div>
<!-- Timeline Tab -->
<div class="tab-panel hidden" id="timeline">
<h3 class="text-xl font-bold text-gray-800 mb-6">{% trans "Activity Timeline" %}</h3>
{% if timeline %}
<div class="timeline">
{% for update in timeline %}
<div class="timeline-item {{ update.update_type }}">
<div class="bg-white border border-gray-200 rounded-xl p-4 shadow-sm hover:shadow-md transition">
<div class="flex justify-between items-start mb-2">
<div>
<span class="px-3 py-1 bg-cyan-500 text-white rounded-lg text-sm font-bold">{{ update.get_update_type_display }}</span>
{% if update.created_by %}
<span class="text-gray-500 text-sm ml-2">
by {{ update.created_by.get_full_name }}
</span> </span>
{% endif %}
</div> </div>
<small class="text-gray-400"> <div class="prose prose-sm max-w-none">
{{ update.created_at|date:"M d, Y H:i" }} <p class="text-slate-700 leading-relaxed">{{ inquiry.response|linebreaks }}</p>
</small> </div>
</div> </div>
<p class="text-gray-700">{{ update.message }}</p>
{% if update.old_status and update.new_status %}
<div class="flex gap-2 mt-2">
<span class="px-2 py-1 bg-blue-100 text-blue-600 rounded-lg text-xs font-bold">{{ update.old_status }}</span>
<i data-lucide="arrow-right" class="w-4 h-4 text-gray-400"></i>
<span class="px-2 py-1 bg-green-100 text-green-600 rounded-lg text-xs font-bold">{{ update.new_status }}</span>
</div> </div>
{% endif %} {% endif %}
<!-- Timeline -->
<div class="detail-card animate-in">
<div class="card-header">
<h3 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i data-lucide="history" class="w-5 h-5"></i>
{% trans "Timeline" %}
</h3>
</div> </div>
<div class="p-6">
<div class="timeline">
<!-- Created -->
<div class="timeline-item">
<p class="info-label">{% trans "Inquiry Created" %}</p>
<p class="text-sm text-slate-700">
{% trans "Created by" %} {{ inquiry.created_by.get_full_name|default:inquiry.created_by.email }}
</p>
<p class="text-xs text-slate-500 mt-1">{{ inquiry.created_at|date:"Y-m-d H:i" }}</p>
</div>
<!-- Status Changes -->
{% for update in inquiry.updates.all %}
<div class="timeline-item status_change">
<p class="info-label">{% trans "Status Changed" %}</p>
<p class="text-sm text-slate-700">
{% trans "Status changed to" %} <strong>{{ update.new_status|title }}</strong>
</p>
<p class="text-xs text-slate-500 mt-1">{{ update.created_at|date:"Y-m-d H:i" }}</p>
</div>
{% endfor %}
<!-- Response -->
{% if inquiry.response_sent_at %}
<div class="timeline-item response">
<p class="info-label">{% trans "Response Sent" %}</p>
<p class="text-sm text-slate-700">
{% trans "Response sent by" %} {{ inquiry.responded_by.get_full_name|default:inquiry.responded_by.email }}
</p>
<p class="text-xs text-slate-500 mt-1">{{ inquiry.response_sent_at|date:"Y-m-d H:i" }}</p>
</div>
{% endif %}
<!-- Notes -->
{% for note in inquiry.notes.all %}
<div class="timeline-item note">
<p class="info-label">{% trans "Note Added" %}</p>
<p class="text-sm text-slate-700">{{ note.content|truncatewords:20 }}</p>
<p class="text-xs text-slate-500 mt-1">{{ note.created_at|date:"Y-m-d H:i" }}</p>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %}
<div class="text-center py-12">
<i data-lucide="clock-history" class="w-16 h-16 mx-auto text-gray-300 mb-4"></i>
<p class="text-gray-500">{% trans "No timeline entries yet" %}</p>
</div> </div>
{% endif %} </div>
</div> </div>
<!-- Attachments Tab --> <!-- Sidebar -->
<div class="tab-panel hidden" id="attachments"> <div class="space-y-6">
<h3 class="text-xl font-bold text-gray-800 mb-6">{% trans "Attachments" %}</h3> <!-- Contact Information -->
<div class="detail-card animate-in">
{% if attachments %} <div class="card-header">
<div class="space-y-3"> <h3 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
{% for attachment in attachments %} <i data-lucide="user" class="w-5 h-5"></i>
<div class="bg-white border border-gray-200 rounded-xl p-4 flex justify-between items-center hover:shadow-md transition"> {% trans "Contact Information" %}
</h3>
</div>
<div class="p-6 space-y-4">
<div> <div>
<div class="flex items-center gap-2 font-semibold text-gray-800 mb-1"> <p class="info-label">{% trans "Name" %}</p>
<i data-lucide="file" class="w-5 h-5"></i> {{ attachment.filename }} <p class="info-value">{{ inquiry.contact_name|default:"-" }}</p>
</div>
<div>
<p class="info-label">{% trans "Phone" %}</p>
<p class="info-value">{{ inquiry.contact_phone|default:"-" }}</p>
</div>
<div>
<p class="info-label">{% trans "Email" %}</p>
<p class="info-value">{{ inquiry.contact_email|default:"-" }}</p>
</div>
</div>
</div>
<!-- Organization -->
<div class="detail-card animate-in">
<div class="card-header">
<h3 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i data-lucide="building" class="w-5 h-5"></i>
{% trans "Organization" %}
</h3>
</div>
<div class="p-6 space-y-4">
<div>
<p class="info-label">{% trans "Hospital" %}</p>
<p class="info-value">{{ inquiry.hospital.name }}</p>
</div>
{% if inquiry.department %}
<div>
<p class="info-label">{% trans "Department" %}</p>
<p class="info-value">{{ inquiry.department.name }}</p>
</div> </div>
<small class="text-gray-500">
{% trans "Uploaded by" %} {{ attachment.uploaded_by.get_full_name }}
{% trans "on" %} {{ attachment.created_at|date:"M d, Y H:i" }}
({{ attachment.file_size|filesizeformat }})
</small>
{% if attachment.description %}
<p class="text-sm text-gray-600 mt-1">{{ attachment.description }}</p>
{% endif %} {% endif %}
<div>
<p class="info-label">{% trans "Category" %}</p>
<p class="info-value">{{ inquiry.get_category_display|default:"-" }}</p>
</div> </div>
<a href="{{ attachment.file.url }}" class="p-2 bg-cyan-100 text-cyan-600 rounded-lg hover:bg-cyan-200 transition"> </div>
<i data-lucide="download" class="w-5 h-5"></i> </div>
<!-- Actions -->
<div class="detail-card animate-in">
<div class="card-header">
<h3 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i data-lucide="settings" class="w-5 h-5"></i>
{% trans "Actions" %}
</h3>
</div>
<div class="p-4 space-y-2">
{% if can_respond %}
<button onclick="showRespondModal()" class="w-full btn-primary justify-center">
<i data-lucide="message-square" class="w-4 h-4"></i>
{% trans "Send Response" %}
</button>
{% endif %}
{% if inquiry.status != 'closed' %}
<a href="#" class="flex items-center gap-2 p-3 rounded-lg hover:bg-slate-50 transition text-navy font-medium text-sm">
<i data-lucide="edit" class="w-4 h-4"></i>
{% trans "Edit Inquiry" %}
</a> </a>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-12">
<i data-lucide="paperclip" class="w-16 h-16 mx-auto text-gray-300 mb-4"></i>
<p class="text-gray-500">{% trans "No attachments" %}</p>
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
<!-- Sidebar Actions --> <!-- Respond Modal -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div id="respondModal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center p-4">
<div class="lg:col-span-2"></div> <div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg animate-in">
<div class="p-6 border-b border-slate-200">
<div class="space-y-4"> <h3 class="text-xl font-bold text-navy flex items-center gap-2">
<!-- Quick Actions --> <i data-lucide="message-square" class="w-5 h-5"></i>
{% if can_edit %} {% trans "Send Response" %}
<div class="bg-white rounded-2xl border border-gray-50 shadow-sm p-6"> </h3>
<h4 class="font-bold text-gray-800 mb-4 flex items-center gap-2"> </div>
<i data-lucide="zap" class="w-5 h-5"></i> {% trans "Quick Actions" %} <form method="post" action="{% url 'complaints:inquiry_respond' inquiry.pk %}">
</h4>
<!-- Activate -->
<form method="post" action="{% url 'complaints:inquiry_activate' inquiry.id %}" class="mb-4">
{% csrf_token %} {% csrf_token %}
{% if inquiry.assigned_to and inquiry.assigned_to == user %} <div class="p-6">
<button type="submit" class="w-full px-4 py-3 bg-green-500 text-white rounded-xl font-semibold flex items-center justify-center gap-2" disabled> <div class="mb-4">
<i data-lucide="check-circle" class="w-5 h-5"></i> {% trans "Activated (Assigned to You)" %} <label class="block text-sm font-semibold text-navy mb-2">
{% trans "Response" %} <span class="text-red-500">*</span>
</label>
<textarea name="response" rows="5" required
class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue focus:ring-2 focus:ring-blue/20 resize-none"
placeholder="{% trans 'Enter your response to the inquiry...' %}"></textarea>
</div>
</div>
<div class="p-6 border-t border-slate-200 flex gap-3">
<button type="submit" class="btn-primary flex-1 justify-center">
<i data-lucide="send" class="w-4 h-4"></i>
{% trans "Send Response" %}
</button> </button>
{% else %} <button type="button" onclick="closeRespondModal()" class="btn-secondary">
<button type="submit" class="w-full px-4 py-3 bg-cyan-500 text-white rounded-xl font-semibold hover:bg-cyan-600 transition flex items-center justify-center gap-2"> {% trans "Cancel" %}
<i data-lucide="zap" class="w-5 h-5"></i> {% trans "Activate" %}
</button> </button>
{% endif %} </div>
</form> </form>
<!-- Change Status -->
<form method="post" action="{% url 'complaints:inquiry_change_status' inquiry.id %}" class="mb-4">
{% csrf_token %}
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Change Status" %}</label>
<select name="status" class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition mb-2" required>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if inquiry.status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<textarea name="note" class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition mb-2" rows="2" placeholder="{% trans 'Optional note...' %}"></textarea>
<button type="submit" class="w-full px-4 py-3 bg-cyan-500 text-white rounded-xl font-semibold hover:bg-cyan-600 transition flex items-center justify-center gap-2">
<i data-lucide="refresh-cw" class="w-5 h-5"></i> {% trans "Update Status" %}
</button>
</form>
</div>
{% endif %}
<!-- Add Note -->
<div class="bg-white rounded-2xl border border-gray-50 shadow-sm p-6">
<h4 class="font-bold text-gray-800 mb-4 flex items-center gap-2">
<i data-lucide="message-circle" class="w-5 h-5"></i> {% trans "Add Note" %}
</h4>
<form method="post" action="{% url 'complaints:inquiry_add_note' inquiry.id %}">
{% csrf_token %}
<textarea name="note" class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition mb-3" rows="3" placeholder="{% trans 'Enter your note...' %}" required></textarea>
<button type="submit" class="w-full px-4 py-3 bg-green-500 text-white rounded-xl font-semibold hover:bg-green-600 transition flex items-center justify-center gap-2">
<i data-lucide="plus-circle" class="w-5 h-5"></i> {% trans "Add Note" %}
</button>
</form>
</div>
<!-- Response Form -->
{% if can_edit and inquiry.status != 'resolved' and inquiry.status != 'closed' %}
<div class="bg-white rounded-2xl border border-gray-50 shadow-sm p-6">
<h4 class="font-bold text-gray-800 mb-4 flex items-center gap-2">
<i data-lucide="reply" class="w-5 h-5"></i> {% trans "Respond to Inquiry" %}
</h4>
<form method="post" action="{% url 'complaints:inquiry_respond' inquiry.id %}">
{% csrf_token %}
<textarea name="response" class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition mb-3" rows="4" placeholder="{% trans 'Enter your response...' %}" required></textarea>
<button type="submit" class="w-full px-4 py-3 bg-cyan-500 text-white rounded-xl font-semibold hover:bg-cyan-600 transition flex items-center justify-center gap-2">
<i data-lucide="send" class="w-5 h-5"></i> {% trans "Send Response" %}
</button>
</form>
</div>
{% endif %}
<!-- Contact Information -->
<div class="bg-white rounded-2xl border border-gray-50 shadow-sm p-6">
<h4 class="font-bold text-gray-800 mb-4 flex items-center gap-2">
<i data-lucide="info" class="w-5 h-5"></i> {% trans "Contact Information" %}
</h4>
<div class="space-y-4">
{% if inquiry.patient %}
<div>
<div class="info-label mb-1">{% trans "Patient" %}</div>
<div class="text-gray-700 font-medium">
{{ inquiry.patient.get_full_name }}
<br>
<small class="text-gray-500">{% trans "MRN" %}: {{ inquiry.patient.mrn }}</small>
</div>
</div>
{% if inquiry.patient.phone %}
<div>
<div class="info-label mb-1">{% trans "Phone" %}</div>
<div class="text-gray-700 font-medium">{{ inquiry.patient.phone }}</div>
</div>
{% endif %}
{% if inquiry.patient.email %}
<div>
<div class="info-label mb-1">{% trans "Email" %}</div>
<div class="text-gray-700 font-medium">{{ inquiry.patient.email }}</div>
</div>
{% endif %}
{% else %}
{% if inquiry.contact_name %}
<div>
<div class="info-label mb-1">{% trans "Name" %}</div>
<div class="text-gray-700 font-medium">{{ inquiry.contact_name }}</div>
</div>
{% endif %}
{% if inquiry.contact_phone %}
<div>
<div class="info-label mb-1">{% trans "Phone" %}</div>
<div class="text-gray-700 font-medium">{{ inquiry.contact_phone }}</div>
</div>
{% endif %}
{% if inquiry.contact_email %}
<div>
<div class="info-label mb-1">{% trans "Email" %}</div>
<div class="text-gray-700 font-medium">{{ inquiry.contact_email }}</div>
</div>
{% endif %}
{% endif %}
</div>
</div>
<!-- Assignment Info -->
<div class="bg-white rounded-2xl border border-gray-50 shadow-sm p-6">
<h4 class="font-bold text-gray-800 mb-4 flex items-center gap-2">
<i data-lucide="user-check" class="w-5 h-5"></i> {% trans "Assignment Info" %}
</h4>
<div class="space-y-4">
<div>
<div class="info-label mb-1">{% trans "Assigned To" %}</div>
<div class="text-gray-700 font-medium">
{% if inquiry.assigned_to %}{{ inquiry.assigned_to.get_full_name }}{% else %}<span class="text-gray-400">{% trans "Unassigned" %}</span>{% endif %}
</div>
</div>
{% if inquiry.responded_by %}
<div>
<div class="info-label mb-1">{% trans "Responded By" %}</div>
<div class="text-gray-700 font-medium">
{{ inquiry.responded_by.get_full_name }}
<br>
<small class="text-gray-500">{{ inquiry.responded_at|date:"M d, Y H:i" }}</small>
</div>
</div>
{% endif %}
{% if inquiry.resolved_by %}
<div>
<div class="info-label mb-1">{% trans "Resolved By" %}</div>
<div class="text-gray-700 font-medium">
{{ inquiry.resolved_by.get_full_name }}
<br>
<small class="text-gray-500">{{ inquiry.resolved_at|date:"M d, Y H:i" }}</small>
</div>
</div>
{% endif %}
</div>
</div>
</div> </div>
</div> </div>
<script> <script>
// Tab switching document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.tab-btn').forEach(btn => { lucide.createIcons();
btn.addEventListener('click', () => { });
// Remove active class from all tabs and panels
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
// Add active class to clicked tab function showRespondModal() {
btn.classList.add('active'); document.getElementById('respondModal').classList.remove('hidden');
}
// Show corresponding panel function closeRespondModal() {
const target = btn.dataset.target; document.getElementById('respondModal').classList.add('hidden');
document.getElementById(target).classList.remove('hidden'); }
});
// Close modal on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeRespondModal();
}
}); });
</script> </script>
<style>
.tab-btn {
@apply px-4 py-3 text-sm font-semibold text-gray-500 hover:text-gray-700 hover:bg-gray-50 transition border-b-2 border-transparent;
}
.tab-btn.active {
@apply text-cyan-500 border-cyan-500;
}
.tab-panel {
@apply block;
}
.tab-panel.hidden {
@apply hidden;
}
</style>
{% endblock %} {% endblock %}

View File

@ -1,232 +1,372 @@
{% extends base_layout %} {% extends "layouts/base.html" %}
{% load i18n %} {% load i18n %}
{% load static %}
{% block title %}{{ _("New Inquiry")}} - PX360{% endblock %} {% block title %}{% trans "Create New Inquiry" %} - PX360{% endblock %}
{% block extra_css %} {% block extra_css %}
<style> <style>
.form-section { :root {
background: #fff; --hh-navy: #005696;
border: 1px solid #dee2e6; --hh-blue: #007bbd;
border-radius: 8px; --hh-light: #eef6fb;
padding: 25px; --hh-slate: #64748b;
margin-bottom: 20px;
} }
.form-section-title {
.form-card {
background: white;
border-radius: 1rem;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
.card-header {
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
padding: 1.25rem 1.75rem;
border-bottom: 1px solid #bae6fd;
border-radius: 1rem 1rem 0 0;
}
.form-section {
padding: 1.5rem;
border-bottom: 1px solid #f1f5f9;
}
.form-section:last-child {
border-bottom: none;
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 700;
color: var(--hh-navy);
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600; font-weight: 600;
color: #495057; color: var(--hh-navy);
margin-bottom: 20px; font-size: 0.9rem;
padding-bottom: 10px; }
border-bottom: 2px solid #17a2b8;
.form-input {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #cbd5e1;
border-radius: 0.75rem;
font-size: 0.95rem;
transition: all 0.2s;
background: white;
color: #1e293b;
font-family: 'Inter', sans-serif;
}
.form-input:focus {
outline: none;
border-color: var(--hh-blue);
box-shadow: 0 0 0 4px rgba(0, 123, 189, 0.1);
transform: translateY(-1px);
}
.form-input.error {
border-color: #ef4444;
}
.form-input.error:focus {
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.1);
}
textarea.form-input {
resize: vertical;
min-height: 120px;
}
.form-help {
color: var(--hh-slate);
font-size: 0.825rem;
margin-top: 0.375rem;
}
.form-error {
color: #ef4444;
font-size: 0.825rem;
margin-top: 0.375rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.btn-primary {
background: linear-gradient(135deg, var(--hh-navy) 0%, var(--hh-blue) 100%);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.75rem;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 86, 150, 0.3);
}
.btn-secondary {
background: white;
color: #475569;
padding: 0.75rem 1.5rem;
border-radius: 0.75rem;
font-weight: 600;
border: 2px solid #e2e8f0;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #cbd5e1;
transform: translateY(-1px);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
} }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="px-4 py-6">
<!-- Breadcrumb -->
<nav class="mb-4 animate-in">
<ol class="flex items-center gap-2 text-sm text-slate">
<li><a href="{% url 'complaints:inquiry_list' %}" class="text-blue hover:text-navy font-medium">{% trans "Inquiries" %}</a></li>
<li><i data-lucide="chevron-right" class="w-4 h-4 text-slate"></i></li>
<li class="text-navy font-semibold">{% trans "Create New Inquiry" %}</li>
</ol>
</nav>
<!-- Page Header --> <!-- Page Header -->
<div class="mb-4"> <div class="mb-6 animate-in">
{% if source_user %} <h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<a href="{% url 'px_sources:source_user_inquiry_list' %}" class="btn btn-outline-secondary btn-sm mb-3"> <div class="w-10 h-10 bg-blue/10 rounded-xl flex items-center justify-center">
<i class="bi bi-arrow-left me-1"></i> {{ _("Back to My Inquiries")}} <i data-lucide="plus-circle" class="w-5 h-5 text-blue"></i>
</a> </div>
{% else %} {% trans "Create New Inquiry" %}
<a href="{% url 'complaints:inquiry_list' %}" class="btn btn-outline-secondary btn-sm mb-3"> </h1>
<i class="bi bi-arrow-left me-1"></i> {{ _("Back to Inquiries")}} <p class="text-slate mt-1">{% trans "Create a new patient inquiry or request" %}</p>
</a>
{% endif %}
<h2 class="mb-1">
<i class="bi bi-plus-circle text-info me-2"></i>
{{ _("Create New Inquiry")}}
</h2>
<p class="text-muted mb-0">{{ _("Create a new patient inquiry or request")}}</p>
</div> </div>
<form method="post" action="{% url 'complaints:inquiry_create' %}" id="inquiryForm"> <!-- Form Card -->
<form method="post" action="{% url 'complaints:inquiry_create' %}" id="inquiryForm" class="form-card max-w-5xl animate-in">
{% csrf_token %} {% csrf_token %}
{% if form.non_field_errors %} <div class="card-header">
<div class="alert alert-danger"> <h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
{{ form.non_field_errors }} <i data-lucide="file-text" class="w-5 h-5"></i>
{% trans "Inquiry Information" %}
</h2>
</div> </div>
{% endif %}
<div class="row"> <div class="p-0">
<div class="col-lg-8"> <!-- Organization Section -->
<!-- Organization Information -->
<div class="form-section"> <div class="form-section">
<h5 class="form-section-title"> <h3 class="section-title">
<i class="bi bi-hospital me-2"></i>{{ _("Organization") }} <i data-lucide="building" class="w-5 h-5"></i>
</h5> {% trans "Organization" %}
</h3>
<div class="mb-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
{{ form.hospital.label_tag }} <div class="form-group">
<label for="{{ form.hospital.id_for_label }}" class="form-label">
{{ form.hospital.label }} <span class="text-red-500">*</span>
</label>
{{ form.hospital }} {{ form.hospital }}
{% if form.hospital.help_text %} {% if form.hospital.help_text %}
<small class="form-text text-muted">{{ form.hospital.help_text }}</small> <p class="form-help">{{ form.hospital.help_text }}</p>
{% endif %} {% endif %}
{% for error in form.hospital.errors %} {% for error in form.hospital.errors %}
<div class="invalid-feedback d-block">{{ error }}</div> <p class="form-error">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ error }}
</p>
{% endfor %} {% endfor %}
</div> </div>
<div class="mb-3"> <div class="form-group">
{{ form.department.label_tag }} <label for="{{ form.department.id_for_label }}" class="form-label">
{{ form.department.label }}
</label>
{{ form.department }} {{ form.department }}
{% for error in form.department.errors %} {% for error in form.department.errors %}
<div class="invalid-feedback d-block">{{ error }}</div> <p class="form-error">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ error }}
</p>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div>
<!-- Contact/Patient Information --> <!-- Contact Information Section -->
<div class="form-section"> <div class="form-section">
<h5 class="form-section-title"> <h3 class="section-title">
<i class="bi bi-person-fill me-2"></i>{{ _("Contact Information")}} <i data-lucide="user" class="w-5 h-5"></i>
</h5> {% trans "Contact Information" %}
</h3>
<!-- Patient Field --> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="mb-3"> <div class="form-group">
{{ form.patient.label_tag }} <label for="{{ form.contact_name.id_for_label }}" class="form-label">
{{ form.patient }} {{ form.contact_name.label }} <span class="text-red-500">*</span>
{% for error in form.patient.errors %} </label>
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.contact_name.label_tag }}
{{ form.contact_name }} {{ form.contact_name }}
{% for error in form.contact_name.errors %} {% for error in form.contact_name.errors %}
<div class="invalid-feedback d-block">{{ error }}</div> <p class="form-error">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ error }}
</p>
{% endfor %} {% endfor %}
</div> </div>
<div class="row"> <div class="form-group">
<div class="col-md-6 mb-3"> <label for="{{ form.contact_phone.id_for_label }}" class="form-label">
{{ form.contact_phone.label_tag }} {{ form.contact_phone.label }} <span class="text-red-500">*</span>
</label>
{{ form.contact_phone }} {{ form.contact_phone }}
{% for error in form.contact_phone.errors %} {% for error in form.contact_phone.errors %}
<div class="invalid-feedback d-block">{{ error }}</div> <p class="form-error">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ error }}
</p>
{% endfor %} {% endfor %}
</div> </div>
<div class="col-md-6 mb-3">
{{ form.contact_email.label_tag }} <div class="form-group md:col-span-2">
<label for="{{ form.contact_email.id_for_label }}" class="form-label">
{{ form.contact_email.label }}
</label>
{{ form.contact_email }} {{ form.contact_email }}
{% for error in form.contact_email.errors %} {% for error in form.contact_email.errors %}
<div class="invalid-feedback d-block">{{ error }}</div> <p class="form-error">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ error }}
</p>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div> </div>
<!-- Inquiry Details --> <!-- Inquiry Details Section -->
<div class="form-section"> <div class="form-section">
<h5 class="form-section-title"> <h3 class="section-title">
<i class="bi bi-file-text me-2"></i>{{ _("Inquiry Details")}} <i data-lucide="help-circle" class="w-5 h-5"></i>
</h5> {% trans "Inquiry Details" %}
</h3>
<div class="mb-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
{{ form.category.label_tag }} <div class="form-group">
<label for="{{ form.category.id_for_label }}" class="form-label">
{{ form.category.label }} <span class="text-red-500">*</span>
</label>
{{ form.category }} {{ form.category }}
{% for error in form.category.errors %} {% for error in form.category.errors %}
<div class="invalid-feedback d-block">{{ error }}</div> <p class="form-error">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ error }}
</p>
{% endfor %} {% endfor %}
</div> </div>
<div class="mb-3"> <div class="form-group">
{{ form.subject.label_tag }} <label for="{{ form.priority.id_for_label }}" class="form-label">
{{ form.priority.label }}
</label>
{{ form.priority }}
{% if form.priority.help_text %}
<p class="form-help">{{ form.priority.help_text }}</p>
{% endif %}
{% for error in form.priority.errors %}
<p class="form-error">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ error }}
</p>
{% endfor %}
</div>
</div>
<div class="form-group">
<label for="{{ form.subject.id_for_label }}" class="form-label">
{{ form.subject.label }} <span class="text-red-500">*</span>
</label>
{{ form.subject }} {{ form.subject }}
{% for error in form.subject.errors %} {% for error in form.subject.errors %}
<div class="invalid-feedback d-block">{{ error }}</div> <p class="form-error">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ error }}
</p>
{% endfor %} {% endfor %}
</div> </div>
<div class="mb-3"> <div class="form-group">
{{ form.message.label_tag }} <label for="{{ form.message.id_for_label }}" class="form-label">
{{ form.message.label }} <span class="text-red-500">*</span>
</label>
{{ form.message }} {{ form.message }}
{% for error in form.message.errors %} {% for error in form.message.errors %}
<div class="invalid-feedback d-block">{{ error }}</div> <p class="form-error">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ error }}
</p>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div> </div>
<!-- Sidebar --> <!-- Form Actions -->
<div class="col-lg-4"> <div class="p-6 bg-slate-50 border-t border-slate-200 rounded-b-1rem flex gap-3">
<button type="submit" class="btn-primary">
<!-- Help Information --> <i data-lucide="check-circle" class="w-5 h-5"></i>
<div class="alert alert-info"> {% trans "Create Inquiry" %}
<h6 class="alert-heading">
<i class="bi bi-info-circle me-2"></i>{{ _("Help")}}
</h6>
<p class="mb-0 small">
{{ _("Use this form to create a new inquiry from a patient or visitor.")}}
</p>
<hr class="my-2">
<p class="mb-0 small">
{{ _("Fill in the inquiry details. Fields marked with * are required.")}}
</p>
</div>
<!-- Action Buttons -->
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-check-circle me-2"></i>{{ _("Create Inquiry")}}
</button> </button>
{% if source_user %} <a href="{% url 'complaints:inquiry_list' %}" class="btn-secondary">
<a href="{% url 'px_sources:source_user_inquiry_list' %}" class="btn btn-outline-secondary"> {% trans "Cancel" %}
<i class="bi bi-x-circle me-2"></i>{{ _("Cancel") }}
</a> </a>
{% else %}
<a href="{% url 'complaints:inquiry_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-2"></i>{{ _("Cancel") }}
</a>
{% endif %}
</div>
</div>
</div> </div>
</form> </form>
</div> </div>
{% endblock %}
{% block extra_js %}
<script> <script>
// Department loading document.addEventListener('DOMContentLoaded', function() {
document.getElementById('{{ form.hospital.id_for_label }}')?.addEventListener('change', function() { lucide.createIcons();
const hospitalId = this.value;
const departmentSelect = document.getElementById('{{ form.department.id_for_label }}');
if (!hospitalId) { // Add error class to inputs with errors
departmentSelect.innerHTML = '<option value="">{{ _("Select department")}}</option>'; document.querySelectorAll('.form-error').forEach(function(errorEl) {
return; const input = errorEl.parentElement.querySelector('.form-input');
if (input) {
input.classList.add('error');
} }
fetch(`/complaints/ajax/departments/?hospital_id=${hospitalId}`)
.then(response => response.json())
.then(data => {
departmentSelect.innerHTML = '<option value="">{{ _("Select department")}}</option>';
data.departments.forEach(dept => {
const option = document.createElement('option');
option.value = dept.id;
option.textContent = dept.name_en || dept.name;
departmentSelect.appendChild(option);
}); });
})
.catch(error => console.error('Error loading departments:', error));
}); });
// Patient search (optional - for better UX)
const patientSelect = document.getElementById('{{ form.patient.id_for_label }}');
if (patientSelect) {
patientSelect.addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
if (!selectedOption || selectedOption.value === '') {
document.getElementById('patient-results').style.display = 'none';
}
});
}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -1,471 +1,367 @@
{% extends 'layouts/base.html' %} {% extends 'layouts/base.html' %}
{% load i18n %} {% load i18n %}
{% load static %}
{% block title %}{{ _("Inquiries Console")}} - PX360{% endblock %} {% block title %}{% trans "Inquiries Console" %} - PX360{% endblock %}
{% block extra_css %} {% block extra_css %}
<style> <style>
.filter-panel { :root {
background: #f8f9fa; --hh-navy: #005696;
border: 1px solid #dee2e6; --hh-blue: #007bbd;
border-radius: 8px; --hh-light: #eef6fb;
padding: 20px; --hh-slate: #64748b;
margin-bottom: 20px; --hh-success: #10b981;
--hh-warning: #f59e0b;
--hh-danger: #ef4444;
} }
.filter-panel.collapsed {
padding: 10px 20px;
}
.filter-panel.collapsed .filter-body {
display: none;
}
.table-toolbar {
background: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.status-open { background: #e3f2fd; color: #1976d2; }
.status-in_progress { background: #fff3e0; color: #f57c00; }
.status-resolved { background: #e8f5e9; color: #388e3c; }
.status-closed { background: #f5f5f5; color: #616161; }
.priority-badge { .page-header {
padding: 4px 12px; background: linear-gradient(135deg, var(--hh-navy) 0%, #0069a8 50%, var(--hh-blue) 100%);
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.priority-low { background: #e8f5e9; color: #388e3c; }
.priority-medium { background: #fff3e0; color: #f57c00; }
.priority-high { background: #ffebee; color: #d32f2f; }
.priority-urgent { background: #880e4f; color: #fff; }
.overdue-badge {
background: #d32f2f;
color: white; color: white;
padding: 2px 8px; padding: 2rem 2.5rem;
border-radius: 10px; border-radius: 1rem;
font-size: 0.75rem; margin-bottom: 2rem;
font-weight: 600; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
margin-left: 8px;
} }
.inquiry-row:hover { .data-card {
background: #f8f9fa; background: white;
border-radius: 1rem;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.data-card:hover {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.card-header {
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
padding: 1.25rem 1.75rem;
border-bottom: 1px solid #bae6fd;
border-radius: 1rem 1rem 0 0;
}
.data-table th {
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
padding: 0.875rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--hh-navy);
border-bottom: 2px solid #bae6fd;
}
.data-table td {
padding: 1rem;
border-bottom: 1px solid #f1f5f9;
color: #475569;
font-size: 0.875rem;
}
.data-table tbody tr {
transition: background-color 0.2s ease;
cursor: pointer; cursor: pointer;
} }
.stat-card { .data-table tbody tr:hover {
border-left: 4px solid; background-color: var(--hh-light);
transition: transform 0.2s;
} }
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 700;
}
.status-badge.open { background: linear-gradient(135deg, #e3f2fd, #bbdefb); color: #1565c0; }
.status-badge.in_progress { background: linear-gradient(135deg, #fff3e0, #ffe0b2); color: #e65100; }
.status-badge.resolved { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); color: #2e7d32; }
.status-badge.closed { background: linear-gradient(135deg, #f5f5f5, #e0e0e0); color: #616161; }
.priority-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
}
.priority-badge.low { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); color: #2e7d32; }
.priority-badge.medium { background: linear-gradient(135deg, #fff3e0, #ffe0b2); color: #e65100; }
.priority-badge.high { background: linear-gradient(135deg, #ffebee, #ffcdd2); color: #c62828; }
.priority-badge.urgent { background: linear-gradient(135deg, #880e4f, #ad1457); color: white; }
.stat-card {
background: white;
border-radius: 1rem;
padding: 1.5rem;
border: 1px solid #e2e8f0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.stat-card:hover { .stat-card:hover {
transform: translateY(-2px); transform: translateY(-4px);
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.1);
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 1rem;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
}
.stat-icon.blue { background: linear-gradient(135deg, #e3f2fd, #bbdefb); }
.stat-icon.green { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); }
.stat-icon.orange { background: linear-gradient(135deg, #fff3e0, #ffe0b2); }
.stat-icon.slate { background: linear-gradient(135deg, #f1f5f9, #e2e8f0); }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
} }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="px-4 py-6">
<!-- Page Header --> <!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="page-header animate-in">
<div class="flex items-center justify-between">
<div> <div>
<h2 class="mb-1"> <h1 class="text-2xl font-bold mb-2">
<i class="bi bi-question-circle-fill text-info me-2"></i> <i data-lucide="help-circle" class="w-7 h-7 inline-block me-2"></i>
{{ _("Inquiries Console")}} {% trans "Inquiries Console" %}
</h2> </h1>
<p class="text-muted mb-0">{{ _("Manage patient inquiries and requests")}}</p> <p class="text-white/90">{% trans "Manage patient inquiries and requests" %}</p>
</div> </div>
<div> <a href="{% url 'complaints:inquiry_create' %}"
<a href="{% url 'complaints:inquiry_create' %}" class="btn btn-primary"> class="inline-flex items-center gap-2 bg-white text-navy px-5 py-2.5 rounded-xl font-bold hover:bg-light transition shadow-lg">
<i class="bi bi-plus-circle me-1"></i> {{ _("New Inquiry")}} <i data-lucide="plus" class="w-4 h-4"></i>
{% trans "New Inquiry" %}
</a> </a>
</div> </div>
</div> </div>
<!-- Statistics Cards --> <!-- Statistics Cards -->
<div class="row mb-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6 animate-in">
<div class="col-md-3"> <div class="stat-card">
<div class="card stat-card border-primary"> <div class="stat-icon blue">
<div class="card-body"> <i data-lucide="inbox" class="w-6 h-6 text-blue-600"></i>
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">{% trans "Total Inquiries" %}</h6>
<h3 class="mb-0">{{ stats.total }}</h3>
</div> </div>
<div class="text-primary"> <p class="text-xs font-bold text-slate uppercase tracking-wider">{% trans "Total Inquiries" %}</p>
<i class="bi bi-list-ul" style="font-size: 2rem;"></i> <p class="text-2xl font-black text-navy mt-1">{{ stats.total }}</p>
</div> </div>
<div class="stat-card">
<div class="stat-icon green">
<i data-lucide="check-circle" class="w-6 h-6 text-green-600"></i>
</div> </div>
<p class="text-xs font-bold text-slate uppercase tracking-wider">{% trans "Resolved" %}</p>
<p class="text-2xl font-black text-navy mt-1">{{ stats.resolved }} <span class="text-sm text-green-600">({{ stats.resolved_percentage|floatformat:1 }}%)</span></p>
</div> </div>
<div class="stat-card">
<div class="stat-icon orange">
<i data-lucide="clock" class="w-6 h-6 text-orange-600"></i>
</div> </div>
<p class="text-xs font-bold text-slate uppercase tracking-wider">{% trans "In Progress" %}</p>
<p class="text-2xl font-black text-navy mt-1">{{ stats.in_progress }}</p>
</div> </div>
<div class="col-md-3"> <div class="stat-card">
<div class="card stat-card border-info"> <div class="stat-icon slate">
<div class="card-body"> <i data-lucide="alert-triangle" class="w-6 h-6 text-red-500"></i>
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">{% trans "Open" %}</h6>
<h3 class="mb-0">{{ stats.open }}</h3>
</div>
<div class="text-info">
<i class="bi bi-folder2-open" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card border-warning">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">{% trans "In Progress" %}</h6>
<h3 class="mb-0">{{ stats.in_progress }}</h3>
</div>
<div class="text-warning">
<i class="bi bi-hourglass-split" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card border-success">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">{% trans "Resolved" %}</h6>
<h3 class="mb-0 text-success">{{ stats.resolved }}</h3>
</div>
<div class="text-success">
<i class="bi bi-check-circle-fill" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div> </div>
<p class="text-xs font-bold text-slate uppercase tracking-wider">{% trans "Overdue" %}</p>
<p class="text-2xl font-black text-navy mt-1">{{ stats.overdue }}</p>
</div> </div>
</div> </div>
<!-- Filter Panel --> <!-- Filters -->
<div class="filter-panel" id="filterPanel"> <div class="data-card mb-6 animate-in">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="card-header">
<h5 class="mb-0"> <h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i class="bi bi-funnel me-2"></i>{{ _("Filters") }} <i data-lucide="filter" class="w-5 h-5"></i>
</h5> {% trans "Filters" %}
<button class="btn btn-sm btn-outline-secondary" onclick="toggleFilters()"> </h2>
<i class="bi bi-chevron-up" id="filterToggleIcon"></i>
</button>
</div> </div>
<div class="p-6">
<div class="filter-body"> <form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<form method="get" action="{% url 'complaints:inquiry_list' %}" id="filterForm"> <div>
<div class="row g-3"> <label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Search" %}</label>
<!-- Search --> <input type="text" name="search" value="{{ filters.search }}"
<div class="col-md-4">
<label class="form-label">{% trans "Search" %}</label>
<input type="text" class="form-control" name="search"
placeholder="{% trans 'Subject, contact name...' %}" placeholder="{% trans 'Subject, contact name...' %}"
value="{{ filters.search }}"> class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue focus:ring-2 focus:ring-blue/20">
</div> </div>
<div>
<!-- Status --> <label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Status" %}</label>
<div class="col-md-4"> <select name="status" class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<label class="form-label">{% trans "Status" %}</label> <option value="">{% trans "All Status" %}</option>
<select class="form-select" name="status"> <option value="open" {% if filters.status == 'open' %}selected{% endif %}>{% trans "Open" %}</option>
<option value="">{{ _("All Statuses")}}</option> <option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
{% for value, label in status_choices %} <option value="resolved" {% if filters.status == 'resolved' %}selected{% endif %}>{% trans "Resolved" %}</option>
<option value="{{ value }}" {% if filters.status == value %}selected{% endif %}> <option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>{% trans "Closed" %}</option>
{{ label }}
</option>
{% endfor %}
</select> </select>
</div> </div>
<div>
<!-- Priority --> <label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Category" %}</label>
<div class="col-md-4"> <select name="category" class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<label class="form-label">{% trans "Priority" %}</label> <option value="">{% trans "All Categories" %}</option>
<select class="form-select" name="priority"> <option value="general" {% if filters.category == 'general' %}selected{% endif %}>{% trans "General" %}</option>
<option value="">{{ _("All Priorities")}}</option> <option value="services" {% if filters.category == 'services' %}selected{% endif %}>{% trans "Services" %}</option>
<option value="low" {% if filters.priority == 'low' %}selected{% endif %}>{{ _("Low") }}</option> <option value="appointments" {% if filters.category == 'appointments' %}selected{% endif %}>{% trans "Appointments" %}</option>
<option value="medium" {% if filters.priority == 'medium' %}selected{% endif %}>{{ _("Medium") }}</option> <option value="billing" {% if filters.category == 'billing' %}selected{% endif %}>{% trans "Billing" %}</option>
<option value="high" {% if filters.priority == 'high' %}selected{% endif %}>{{ _("High") }}</option> <option value="medical" {% if filters.category == 'medical' %}selected{% endif %}>{% trans "Medical Records" %}</option>
<option value="urgent" {% if filters.priority == 'urgent' %}selected{% endif %}>{{ _("Urgent") }}</option>
</select> </select>
</div> </div>
<div class="flex items-end">
<!-- Category --> <button type="submit" class="w-full px-6 py-2.5 bg-navy text-white rounded-xl font-bold hover:bg-blue transition shadow-lg shadow-navy/25">
<div class="col-md-4"> <i data-lucide="search" class="w-4 h-4 inline-block me-2"></i>
<label class="form-label">{% trans "Category" %}</label> {% trans "Filter" %}
<select class="form-select" name="category">
<option value="">{{ _("All Categories")}}</option>
<option value="appointment" {% if filters.category == 'appointment' %}selected{% endif %}>{{ _("Appointment")}}</option>
<option value="billing" {% if filters.category == 'billing' %}selected{% endif %}>{{ _("Billing") }}</option>
<option value="medical_records" {% if filters.category == 'medical_records' %}selected{% endif %}>{{ _("Medical Records")}}</option>
<option value="pharmacy" {% if filters.category == 'pharmacy' %}selected{% endif %}>{{ _("Pharmacy")}}</option>
<option value="insurance" {% if filters.category == 'insurance' %}selected{% endif %}>{{ _("Insurance")}}</option>
<option value="feedback" {% if filters.category == 'feedback' %}selected{% endif %}>{{ _("Feedback")}}</option>
<option value="general" {% if filters.category == 'general' %}selected{% endif %}>{{ _("General")}}</option>
<option value="other" {% if filters.category == 'other' %}selected{% endif %}>{{ _("Other") }}</option>
</select>
</div>
<!-- Hospital -->
<div class="col-md-4">
<label class="form-label">{% trans "Hospital" %}</label>
<select class="form-select" name="hospital">
<option value="">{{ _("All Hospitals")}}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name_en }}
</option>
{% endfor %}
</select>
</div>
<!-- Assigned To -->
<div class="col-md-4">
<label class="form-label">{% trans "Assigned To" %}</label>
<select class="form-select" name="assigned_to">
<option value="">{{ _("All Users")}}</option>
{% for user_obj in assignable_users %}
<option value="{{ user_obj.id }}" {% if filters.assigned_to == user_obj.id|stringformat:"s" %}selected{% endif %}>
{{ user_obj.get_full_name }}
</option>
{% endfor %}
</select>
</div>
<!-- Date Range -->
<div class="col-md-3">
<label class="form-label">{% trans "Date From" %}</label>
<input type="date" class="form-control" name="date_from" value="{{ filters.date_from }}">
</div>
<div class="col-md-3">
<label class="form-label">{% trans "Date To" %}</label>
<input type="date" class="form-control" name="date_to" value="{{ filters.date_to }}">
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-search me-1"></i> {{ _("Apply Filters")}}
</button> </button>
<a href="{% url 'complaints:inquiry_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i> {{ _("Clear") }}
</a>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<!-- Table Toolbar -->
<div class="table-toolbar">
<div>
<span class="text-muted">
Showing {{ page_obj.start_index }} to {{ page_obj.end_index }} of {{ page_obj.paginator.count }} inquiries
</span>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" onclick="exportData('csv')">
<i class="bi bi-file-earmark-spreadsheet me-1"></i> {{ _("Export CSV")}}
</button>
<button class="btn btn-sm btn-outline-primary" onclick="exportData('excel')">
<i class="bi bi-file-earmark-excel me-1"></i> {{ _("Export Excel")}}
</button>
</div>
</div>
<!-- Inquiries Table --> <!-- Inquiries Table -->
<div class="card"> <div class="data-card animate-in">
<div class="card-body p-0"> <div class="card-header">
<div class="table-responsive"> <h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<table class="table table-hover mb-0"> <i data-lucide="help-circle" class="w-5 h-5"></i>
<thead class="table-light"> {% trans "All Inquiries" %} ({{ inquiries.paginator.count }})
</h2>
</div>
<div class="p-0">
{% if inquiries %}
<div class="overflow-x-auto">
<table class="w-full data-table">
<thead>
<tr> <tr>
<th style="width: 50px;"> <th>{% trans "Reference" %}</th>
<input type="checkbox" class="form-check-input" id="selectAll">
</th>
<th>{% trans "ID" %}</th>
<th>{% trans "Subject" %}</th> <th>{% trans "Subject" %}</th>
<th>{% trans "Contact" %}</th> <th>{% trans "Contact" %}</th>
<th>{% trans "Category" %}</th> <th>{% trans "Category" %}</th>
<th>{% trans "Status" %}</th> <th class="text-center">{% trans "Status" %}</th>
<th>{% trans "Priority" %}</th> <th class="text-center">{% trans "Priority" %}</th>
<th>{% trans "Hospital" %}</th> <th class="text-center">{% trans "Hospital" %}</th>
<th>{% trans "Assigned To" %}</th>
<th>{% trans "Due Date" %}</th>
<th>{% trans "Created" %}</th> <th>{% trans "Created" %}</th>
<th>{% trans "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100">
{% for inquiry in inquiries %} {% for inquiry in inquiries %}
<tr class="inquiry-row" onclick="window.location='{% url 'complaints:inquiry_detail' inquiry.id %}'"> <tr onclick="window.location='{% url 'complaints:inquiry_detail' inquiry.pk %}'">
<td onclick="event.stopPropagation();"> <td>
<input type="checkbox" class="form-check-input inquiry-checkbox" <span class="font-mono text-xs bg-slate-100 px-2 py-1 rounded">{{ inquiry.reference_number|truncatechars:15 }}</span>
value="{{ inquiry.id }}">
</td> </td>
<td> <td>
<small class="text-muted">#{{ inquiry.id|slice:":8" }}</small> <a href="{% url 'complaints:inquiry_detail' inquiry.pk %}"
class="font-semibold text-navy hover:text-blue transition">
{{ inquiry.subject|truncatechars:40 }}
</a>
</td> </td>
<td> <td>
<div class="d-flex align-items-center"> <div>
{{ inquiry.subject|truncatewords:8 }} <p class="font-medium text-slate-700">{{ inquiry.contact_name|default:"-" }}</p>
{% if inquiry.is_overdue %} <p class="text-xs text-slate">{{ inquiry.contact_phone|default:inquiry.contact_email|default:"-" }}</p>
<span class="overdue-badge">{{ _("OVERDUE") }}</span>
{% endif %}
</div> </div>
</td> </td>
<td> <td>
{% if inquiry.patient %} <span class="text-sm text-slate">{{ inquiry.get_category_display|default:"-" }}</span>
<strong>{{ inquiry.patient.get_full_name }}</strong><br>
<small class="text-muted">{{ _("MRN") }}: {{ inquiry.patient.mrn }}</small>
{% else %}
<strong>{{ inquiry.contact_name|default:inquiry.contact_email }}</strong><br>
<small class="text-muted">{{ inquiry.contact_email }}</small>
{% endif %}
</td> </td>
<td> <td class="text-center">
<span class="badge bg-secondary">{{ inquiry.get_category_display }}</span> <span class="status-badge {{ inquiry.status }}">
</td> <i data-lucide="{% if inquiry.status == 'open' %}circle{% elif inquiry.status == 'in_progress' %}clock{% elif inquiry.status == 'resolved' %}check-circle{% else %}check{% endif %}" class="w-3 h-3"></i>
<td>
<span class="status-badge status-{{ inquiry.status }}">
{{ inquiry.get_status_display }} {{ inquiry.get_status_display }}
</span> </span>
</td> </td>
<td> <td class="text-center">
{% if inquiry.priority %} <span class="priority-badge {{ inquiry.priority }}">
<span class="priority-badge priority-{{ inquiry.priority }}"> {% if inquiry.priority == 'low' %}
<i data-lucide="arrow-down" class="w-3 h-3"></i>
{% elif inquiry.priority == 'medium' %}
<i data-lucide="minus" class="w-3 h-3"></i>
{% elif inquiry.priority == 'high' %}
<i data-lucide="arrow-up" class="w-3 h-3"></i>
{% else %}
<i data-lucide="zap" class="w-3 h-3"></i>
{% endif %}
{{ inquiry.get_priority_display }} {{ inquiry.get_priority_display }}
</span> </span>
{% else %} </td>
<span class="text-muted"><em>-</em></span> <td class="text-center">
{% endif %} <span class="text-sm text-slate">{{ inquiry.hospital.name|truncatechars:15 }}</span>
</td> </td>
<td> <td>
<small>{{ inquiry.hospital.name_en|truncatewords:3 }}</small> <div class="text-sm text-slate">
</td> <p>{{ inquiry.created_at|date:"Y-m-d" }}</p>
<td> <p class="text-xs">{{ inquiry.created_at|date:"H:i" }}</p>
{% if inquiry.assigned_to %}
<small>{{ inquiry.assigned_to.get_full_name }}</small>
{% else %}
<span class="text-muted"><em>{{ _("Unassigned") }}</em></span>
{% endif %}
</td>
<td>
{% if inquiry.due_date %}
<small class="{% if inquiry.is_overdue %}text-danger fw-bold{% endif %}">
{{ inquiry.due_date|date:"M d, Y H:i" }}
</small>
{% else %}
<span class="text-muted"><em>-</em></span>
{% endif %}
</td>
<td>
<small class="text-muted">{{ inquiry.created_at|date:"M d, Y" }}</small>
</td>
<td onclick="event.stopPropagation();">
<div class="btn-group btn-group-sm">
<a href="{% url 'complaints:inquiry_detail' inquiry.id %}"
class="btn btn-outline-primary" title="{% trans 'View' %}">
<i class="bi bi-eye"></i>
</a>
</div> </div>
</td> </td>
</tr> </tr>
{% empty %}
<tr>
<td colspan="12" class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 3rem; color: #ccc;"></i>
<p class="text-muted mt-3">{{ _("No inquiries found")}}</p>
</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</div>
<!-- Pagination --> <!-- Pagination -->
{% if page_obj.has_other_pages %} {% if inquiries.has_other_pages %}
<nav aria-label="Inquiries pagination" class="mt-4"> <div class="p-4 border-t border-slate-200">
<ul class="pagination justify-content-center"> <div class="flex items-center justify-between">
{% if page_obj.has_previous %} <p class="text-sm text-slate">
<li class="page-item"> {% blocktrans with start=inquiries.start_index end=inquiries.end_index total=inquiries.paginator.count %}
<a class="page-link" href="?page=1{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"> Showing {{ start }} to {{ end }} of {{ total }} inquiries
<i class="bi bi-chevron-double-left"></i> {% endblocktrans %}
</p>
<div class="flex gap-2">
{% if inquiries.has_previous %}
<a href="?page={{ inquiries.previous_page_number }}{% if filters.search %}&search={{ filters.search }}{% endif %}{% if filters.status %}&status={{ filters.status }}{% endif %}{% if filters.category %}&category={{ filters.category }}{% endif %}"
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
{% trans "Previous" %}
</a> </a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% endif %} {% endif %}
{% if inquiries.has_next %}
{% for num in page_obj.paginator.page_range %} <a href="?page={{ inquiries.next_page_number }}{% if filters.search %}&search={{ filters.search }}{% endif %}{% if filters.status %}&status={{ filters.status }}{% endif %}{% if filters.category %}&category={{ filters.category }}{% endif %}"
{% if page_obj.number == num %} class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
<li class="page-item active"><span class="page-link">{{ num }}</span></li> {% trans "Next" %}
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
{{ num }}
</a> </a>
</li>
{% endif %} {% endif %}
{% endfor %} </div>
</div>
{% if page_obj.has_next %} </div>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="bi bi-chevron-right"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="bi bi-chevron-double-right"></i>
</a>
</li>
{% endif %} {% endif %}
</ul> {% else %}
</nav> <div class="text-center py-12">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="help-circle" class="w-8 h-8 text-slate-400"></i>
</div>
<p class="text-slate font-medium">{% trans "No inquiries found" %}</p>
<p class="text-slate text-sm mt-1">{% trans "Adjust your filters or create a new inquiry" %}</p>
</div>
{% endif %} {% endif %}
</div>
</div>
</div> </div>
{% endblock %}
{% block extra_js %}
<script> <script>
function toggleFilters() { document.addEventListener('DOMContentLoaded', function() {
const panel = document.getElementById('filterPanel'); lucide.createIcons();
const icon = document.getElementById('filterToggleIcon');
panel.classList.toggle('collapsed');
icon.classList.toggle('bi-chevron-up');
icon.classList.toggle('bi-chevron-down');
}
function exportData(format) {
const params = new URLSearchParams(window.location.search);
params.set('export', format);
window.location.href = '{% url "complaints:inquiry_list" %}?' + params.toString();
}
// Select all checkbox
document.getElementById('selectAll')?.addEventListener('change', function() {
const checkboxes = document.querySelectorAll('.inquiry-checkbox');
checkboxes.forEach(cb => cb.checked = this.checked);
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -11,6 +11,31 @@
{% if complaint.resolution %} {% if complaint.resolution %}
<p class="text-slate-700 mb-4">{{ complaint.resolution|linebreaks }}</p> <p class="text-slate-700 mb-4">{{ complaint.resolution|linebreaks }}</p>
{% endif %} {% endif %}
<!-- Resolution Outcome Display -->
{% if complaint.resolution_outcome %}
<div class="mb-4 pt-4 border-t border-green-200">
<p class="text-sm font-semibold text-green-800 mb-2">{% trans "Resolution Outcome" %}</p>
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-bold
{% if complaint.resolution_outcome == 'patient' %}bg-blue-100 text-blue-800
{% elif complaint.resolution_outcome == 'hospital' %}bg-green-100 text-green-800
{% else %}bg-orange-100 text-orange-800{% endif %}">
{% if complaint.resolution_outcome == 'patient' %}
<i data-lucide="user" class="w-4 h-4 mr-1.5"></i>{% trans "Patient" %}
{% elif complaint.resolution_outcome == 'hospital' %}
<i data-lucide="building-2" class="w-4 h-4 mr-1.5"></i>{% trans "Hospital" %}
{% else %}
<i data-lucide="circle" class="w-4 h-4 mr-1.5"></i>{% trans "Other" %}
{% endif %}
</span>
{% if complaint.resolution_outcome == 'other' and complaint.resolution_outcome_other %}
<span class="text-sm text-slate">({{ complaint.resolution_outcome_other }})</span>
{% endif %}
</div>
</div>
{% endif %}
<div class="text-sm text-slate"> <div class="text-sm text-slate">
<p>{% trans "Resolved by:" %} {{ complaint.resolved_by.get_full_name|default:"-" }}</p> <p>{% trans "Resolved by:" %} {{ complaint.resolved_by.get_full_name|default:"-" }}</p>
<p>{% trans "Resolved at:" %} {{ complaint.resolved_at|date:"M d, Y H:i"|default:"-" }}</p> <p>{% trans "Resolved at:" %} {{ complaint.resolved_at|date:"M d, Y H:i"|default:"-" }}</p>
@ -76,6 +101,25 @@
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Resolution Notes" %}</label> <label class="block text-sm font-semibold text-slate mb-2">{% trans "Resolution Notes" %}</label>
<textarea name="resolution" id="resolutionTextarea" rows="6" class="w-full border border-slate-200 rounded-xl p-4 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="{% trans 'Enter resolution details...' %}"></textarea> <textarea name="resolution" id="resolutionTextarea" rows="6" class="w-full border border-slate-200 rounded-xl p-4 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="{% trans 'Enter resolution details...' %}"></textarea>
</div> </div>
<!-- Resolution Outcome - Who was in wrong/right -->
<div class="mb-4">
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Resolution Outcome" %}</label>
<p class="text-xs text-slate mb-2">{% trans "Who was in wrong / who was in right?" %}</p>
<select name="resolution_outcome" id="resolutionOutcome" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none bg-white">
<option value="">-- {% trans "Select Outcome" %} --</option>
<option value="patient">{% trans "Patient" %}</option>
<option value="hospital">{% trans "Hospital" %}</option>
<option value="other">{% trans "Other — please specify" %}</option>
</select>
</div>
<!-- Other specification field (shown when Other is selected) -->
<div class="mb-4 hidden" id="otherSpecificationDiv">
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Please Specify" %}</label>
<textarea name="resolution_outcome_other" id="resolutionOutcomeOther" rows="3" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="{% trans 'Specify who was in wrong/right...' %}"></textarea>
</div>
<button type="submit" class="w-full px-6 py-3 bg-green-500 text-white rounded-xl font-bold hover:bg-green-600 transition flex items-center justify-center gap-2"> <button type="submit" class="w-full px-6 py-3 bg-green-500 text-white rounded-xl font-bold hover:bg-green-600 transition flex items-center justify-center gap-2">
<i data-lucide="check-circle" class="w-5 h-5"></i> {% trans "Mark as Resolved" %} <i data-lucide="check-circle" class="w-5 h-5"></i> {% trans "Mark as Resolved" %}
</button> </button>
@ -194,4 +238,20 @@ function selectResolution(lang) {
} }
}); });
} }
// Show/hide "Other" specification field
document.addEventListener('DOMContentLoaded', function() {
const outcomeSelect = document.getElementById('resolutionOutcome');
const otherDiv = document.getElementById('otherSpecificationDiv');
if (outcomeSelect) {
outcomeSelect.addEventListener('change', function() {
if (this.value === 'other') {
otherDiv.classList.remove('hidden');
} else {
otherDiv.classList.add('hidden');
}
});
}
});
</script> </script>

View File

@ -3,210 +3,212 @@
{% block title %}{% trans "Track Your Complaint" %}{% endblock %} {% block title %}{% trans "Track Your Complaint" %}{% endblock %}
{% block extra_css %}
<style>
.glass-effect {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.timeline-dot::after {
content: '';
position: absolute;
width: 2px;
height: 100%;
background: #e2e8f0;
left: 50%;
transform: translateX(-50%);
top: 24px;
z-index: 0;
}
.timeline-item:last-child .timeline-dot::after {
display: none;
}
@keyframes subtle-float {
0% { transform: translateY(0px); }
50% { transform: translateY(-5px); }
100% { transform: translateY(0px); }
}
.float-icon { animation: subtle-float 3s ease-in-out infinite; }
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="min-h-screen bg-gradient-to-br from-light to-blue-50 py-12 px-4 sm:px-6 lg:px-8"> <div class="max-w-4xl mx-auto px-4 py-8 md:py-12">
<div class="max-w-4xl mx-auto"> <a href="/" class="inline-flex items-center gap-2 text-navy/70 hover:text-navy mb-8 transition-all font-medium group">
<!-- Back Link --> <div class="p-2 rounded-full group-hover:bg-navy/5 transition-colors">
<a href="/" class="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 mb-8 transition font-medium"> <i data-lucide="arrow-left" class="w-5 h-5 group-hover:-translate-x-1 transition-transform"></i>
<i data-lucide="arrow-left" class="w-5 h-5"></i> </div>
{% trans "Back to Home" %} {% trans "Back to Home" %}
</a> </a>
<!-- Search Card --> <div class="glass-card rounded-[2rem] shadow-xl border border-white/50 p-8 md:p-12 mb-10 transition-all hover:shadow-2xl animate-fade-in overflow-hidden relative">
<div class="bg-white rounded-3xl shadow-xl p-8 md:p-12 mb-8"> <div class="absolute -top-24 -right-24 w-48 h-48 bg-blue/5 rounded-full blur-3xl"></div>
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 bg-blue-100 rounded-full mb-6"> <div class="text-center mb-10 relative">
<i data-lucide="search" class="w-10 h-10 text-blue-500"></i> <div class="inline-flex items-center justify-center w-24 h-24 bg-gradient-to-tr from-navy via-navy to-blue rounded-3xl mb-6 shadow-xl rotate-3 float-icon">
<i data-lucide="search" class="w-10 h-10 text-white -rotate-3"></i>
</div> </div>
<h1 class="text-3xl font-bold text-gray-800 mb-2"> <h1 class="text-4xl font-extrabold text-navy mb-3 tracking-tight">
{% trans "Track Your Complaint" %} {% trans "Track Your Complaint" %}
</h1> </h1>
<p class="text-gray-500 text-lg"> <p class="text-slate/80 text-lg max-w-md mx-auto">
{% trans "Enter your reference number to check the status of your complaint" %} {% trans "Enter your reference number below to see real-time updates on your request." %}
</p> </p>
</div> </div>
<form method="POST" class="max-w-lg mx-auto"> <form method="POST" class="max-w-lg mx-auto relative">
{% csrf_token %} {% csrf_token %}
<div class="mb-4"> <div class="relative group">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<i data-lucide="hash" class="w-5 h-5 text-slate/40 group-focus-within:text-blue transition-colors"></i>
</div>
<input <input
type="text" type="text"
name="reference_number" name="reference_number"
class="w-full px-6 py-4 border-2 border-gray-200 rounded-xl text-gray-800 text-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition" class="w-full pl-12 pr-6 py-5 border-2 border-slate-100 rounded-2xl text-navy text-lg focus:ring-4 focus:ring-blue/10 focus:border-blue transition-all duration-300 bg-white/80 placeholder:text-slate/30"
placeholder="{% trans 'e.g., CMP-20240101-123456' %}" placeholder="{% trans 'e.g., CMP-20240101-123456' %}"
value="{{ reference_number }}" value="{{ reference_number }}"
required required
> >
</div> </div>
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white px-8 py-4 rounded-xl font-bold text-lg transition shadow-lg shadow-blue-200 hover:shadow-xl hover:-translate-y-0.5"> <button type="submit" class="w-full mt-4 bg-navy hover:bg-navy/90 text-white px-8 py-5 rounded-2xl font-bold text-lg transition-all duration-300 shadow-lg shadow-navy/20 hover:shadow-xl hover:-translate-y-1 flex items-center justify-center gap-3">
<i data-lucide="search" class="inline w-5 h-5 mr-2"></i> <i data-lucide="crosshair" class="w-5 h-5"></i>
{% trans "Track Complaint" %} {% trans "Track Status" %}
</button> </button>
</form> </form>
<p class="text-center text-gray-400 text-sm mt-4"> <p class="text-center text-slate/50 text-xs mt-6 uppercase tracking-widest font-semibold">
{% trans "Your reference number was provided when you submitted your complaint" %} <i data-lucide="info" class="w-3 h-3 inline mr-1"></i>
{% trans "Found in your confirmation email" %}
</p> </p>
</div> </div>
<!-- Error Message -->
{% if error_message %} {% if error_message %}
<div class="bg-yellow-50 border border-yellow-200 rounded-2xl p-6 mb-8 flex items-start gap-4"> <div class="bg-rose-50 border border-rose-100 rounded-2xl p-6 mb-8 flex items-center gap-4 animate-shake">
<div class="text-yellow-500 flex-shrink-0"> <div class="w-12 h-12 bg-rose-100 rounded-xl flex items-center justify-center text-rose-600 shrink-0">
<i data-lucide="alert-triangle" class="w-6 h-6"></i> <i data-lucide="alert-circle" class="w-6 h-6"></i>
</div> </div>
<div> <div>
<strong class="text-yellow-800 block mb-1">{% trans "Not Found" %}</strong> <h3 class="font-bold text-rose-900">{% trans "Reference Not Found" %}</h3>
<span class="text-yellow-700">{{ error_message }}</span> <p class="text-rose-700/80 text-sm">{{ error_message }}</p>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Complaint Details -->
{% if complaint %} {% if complaint %}
<div class="bg-white rounded-3xl shadow-xl p-8 md:p-12"> <div class="animate-slide-up" style="animation-delay: 0.1s">
<!-- Header --> <div class="bg-white rounded-3xl shadow-lg border border-slate-100 p-6 md:p-8 mb-6 relative overflow-hidden">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6 pb-6 border-b border-gray-200"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div class="text-center md:text-left"> <div>
<h2 class="text-2xl font-bold text-gray-800 flex items-center gap-2 md:justify-start justify-center"> <span class="text-xs font-bold text-slate/40 uppercase tracking-widest block mb-1">{% trans "Case Reference" %}</span>
<i data-lucide="hash" class="w-6 h-6 text-gray-400"></i> <h2 class="text-3xl font-black text-navy">{{ complaint.reference_number }}</h2>
{{ complaint.reference_number }} </div>
</h2> <div class="flex items-center gap-3">
<div class="text-right hidden md:block">
<span class="text-xs font-bold text-slate/40 uppercase tracking-widest block mb-1">{% trans "Current Status" %}</span>
<p class="font-bold text-navy">{{ complaint.get_status_display }}</p>
</div>
<div class="px-6 py-3 rounded-2xl text-sm font-black uppercase tracking-wider shadow-sm border-b-4
{% if complaint.status == 'open' %}bg-amber-50 text-amber-700 border-amber-200
{% elif complaint.status == 'in_progress' %}bg-blue-50 text-blue-700 border-blue-200
{% elif complaint.status == 'resolved' %}bg-emerald-50 text-emerald-700 border-emerald-200
{% else %}bg-slate-50 text-slate-700 border-slate-200{% endif %}">
{{ complaint.status }}
</div> </div>
<div class="text-center md:text-right">
<span class="px-4 py-2 rounded-full text-sm font-bold uppercase tracking-wide
{% if complaint.status == 'open' %}bg-yellow-100 text-yellow-700
{% elif complaint.status == 'in_progress' %}bg-blue-100 text-blue-700
{% elif complaint.status == 'partially_resolved' %}bg-orange-100 text-orange-700
{% elif complaint.status == 'resolved' %}bg-green-100 text-green-700
{% elif complaint.status == 'closed' %}bg-gray-100 text-gray-700
{% elif complaint.status == 'cancelled' %}bg-red-100 text-red-700
{% endif %}">
{{ complaint.get_status_display }}
</span>
</div> </div>
</div> </div>
<!-- SLA Information --> <div class="mt-8 h-2 w-full bg-slate-100 rounded-full overflow-hidden">
<div class="{% if complaint.is_overdue %}bg-red-50 border-l-4 border-red-500{% else %}bg-blue-50 border-l-4 border-blue-500{% endif %} rounded-xl p-6 mb-6"> <div class="h-full bg-navy transition-all duration-1000"
<div class="flex items-center gap-4"> style="width: {% if complaint.status == 'resolved' %}100%{% elif complaint.status == 'in_progress' %}50%{% else %}15%{% endif %}">
<div class="{% if complaint.is_overdue %}text-red-500{% else %}text-blue-500{% endif %} flex-shrink-0">
<i data-lucide="{% if complaint.is_overdue %}alert-circle{% else %}clock{% endif %}" class="w-8 h-8"></i>
</div> </div>
<div class="flex-1"> </div>
<h4 class="font-bold text-gray-800 mb-1"> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm transition-hover hover:shadow-md">
<i data-lucide="calendar" class="w-5 h-5 text-blue mb-3"></i>
<span class="block text-xs font-bold text-slate/50 uppercase">{% trans "Submitted" %}</span>
<p class="font-bold text-navy">{{ complaint.created_at|date:"M d, Y" }}</p>
</div>
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm transition-hover hover:shadow-md">
<i data-lucide="building" class="w-5 h-5 text-blue mb-3"></i>
<span class="block text-xs font-bold text-slate/50 uppercase">{% trans "Department" %}</span>
<p class="font-bold text-navy truncate">{{ complaint.department.name|default:"General" }}</p>
</div>
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm transition-hover hover:shadow-md relative overflow-hidden">
<i data-lucide="clock" class="w-5 h-5 {% if complaint.is_overdue %}text-rose-500{% else %}text-blue{% endif %} mb-3"></i>
<span class="block text-xs font-bold text-slate/50 uppercase">{% trans "SLA Deadline" %}</span>
<p class="font-bold text-navy">{{ complaint.due_at|date:"M d, H:i" }}</p>
{% if complaint.is_overdue %} {% if complaint.is_overdue %}
{% trans "Response Overdue" %} <span class="absolute top-2 right-2 flex h-2 w-2">
{% else %} <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
{% trans "Expected Response Time" %} <span class="relative inline-flex rounded-full h-2 w-2 bg-rose-500"></span>
</span>
{% endif %} {% endif %}
</h4>
<p class="text-gray-600">
<strong>{% trans "Due:" %}</strong>
{{ complaint.due_at|date:"F j, Y" }} at {{ complaint.due_at|time:"g:i A" }}
</p>
</div>
</div> </div>
</div> </div>
<!-- Information Grid --> <div class="bg-white rounded-3xl shadow-lg border border-slate-100 p-8 md:p-10">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8"> <h3 class="text-2xl font-bold text-navy mb-10 flex items-center gap-3">
<div class="bg-gray-50 rounded-xl p-4"> <div class="p-2 bg-navy text-white rounded-lg">
<div class="flex items-center gap-2 text-gray-500 text-sm mb-1"> <i data-lucide="list-checks" class="w-5 h-5"></i>
<i data-lucide="calendar" class="w-4 h-4"></i>
{% trans "Submitted On" %}
</div> </div>
<div class="font-semibold text-gray-800"> {% trans "Resolution Journey" %}
{{ complaint.created_at|date:"F j, Y" }}
</div>
</div>
<div class="bg-gray-50 rounded-xl p-4">
<div class="flex items-center gap-2 text-gray-500 text-sm mb-1">
<i data-lucide="building" class="w-4 h-4"></i>
{% trans "Hospital" %}
</div>
<div class="font-semibold text-gray-800">
{{ complaint.hospital.name }}
</div>
</div>
{% if complaint.department %}
<div class="bg-gray-50 rounded-xl p-4">
<div class="flex items-center gap-2 text-gray-500 text-sm mb-1">
<i data-lucide="building-2" class="w-4 h-4"></i>
{% trans "Department" %}
</div>
<div class="font-semibold text-gray-800">
{{ complaint.department.name }}
</div>
</div>
{% endif %}
<div class="bg-gray-50 rounded-xl p-4">
<div class="flex items-center gap-2 text-gray-500 text-sm mb-1">
<i data-lucide="list" class="w-4 h-4"></i>
{% trans "Category" %}
</div>
<div class="font-semibold text-gray-800">
{% if complaint.category %}
{{ complaint.category.name_en }}
{% else %}
{% trans "Pending Classification" %}
{% endif %}
</div>
</div>
</div>
<!-- Timeline -->
{% if public_updates %}
<div class="mt-8">
<h3 class="text-xl font-bold text-gray-800 mb-6 flex items-center gap-2">
<i data-lucide="history" class="w-6 h-6 text-blue-500"></i>
{% trans "Complaint Timeline" %}
</h3> </h3>
<div class="relative pl-8">
<!-- Timeline Line -->
<div class="absolute left-3 top-0 bottom-0 w-0.5 bg-gray-200"></div>
{% if public_updates %}
<div class="space-y-1">
{% for update in public_updates %} {% for update in public_updates %}
<div class="relative pb-6 last:pb-0"> <div class="timeline-item flex gap-6 pb-10 relative">
<!-- Timeline Dot --> <div class="timeline-dot shrink-0 relative z-10">
<div class="absolute left-[-1.3rem] top-1 w-4 h-4 rounded-full border-2 border-white <div class="w-12 h-12 rounded-2xl flex items-center justify-center shadow-sm border-2 border-white
{% if update.update_type == 'status_change' %}bg-orange-500 shadow-[0_0_0_2px_#f97316] {% if update.update_type == 'status_change' %}bg-amber-100 text-amber-600
{% elif update.update_type == 'resolution' %}bg-green-500 shadow-[0_0_0_2px_#22c55e] {% elif update.update_type == 'resolution' %}bg-emerald-100 text-emerald-600
{% else %}bg-blue-500 shadow-[0_0_0_2px_#3b82f6]{% endif %}"> {% else %}bg-blue-50 text-blue-600{% endif %}">
<i data-lucide="{% if update.update_type == 'status_change' %}refresh-cw{% elif update.update_type == 'resolution' %}check-circle-2{% else %}message-square{% endif %}" class="w-6 h-6"></i>
</div> </div>
<div class="text-sm text-gray-500 mb-1">
{{ update.created_at|date:"F j, Y" }} at {{ update.created_at|time:"g:i A" }}
</div> </div>
<div class="font-semibold text-gray-800 mb-2"> <div class="flex-1 pt-1">
{% if update.update_type == 'status_change' %} <div class="flex flex-col md:flex-row md:items-center justify-between mb-2">
{% trans "Status Updated" %} <h4 class="font-black text-navy text-lg">
{% elif update.update_type == 'resolution' %} {% if update.update_type == 'status_change' %}{% trans "Status Updated" %}
{% trans "Resolution Added" %} {% elif update.update_type == 'resolution' %}{% trans "Final Resolution" %}
{% elif update.update_type == 'communication' %} {% else %}{% trans "Update Received" %}{% endif %}
{% trans "Update" %} </h4>
{% else %} <time class="text-sm font-medium text-slate/40">{{ update.created_at|date:"F j, Y • g:i A" }}</time>
{{ update.get_update_type_display }}
{% endif %}
</div> </div>
{% if update.comments %} {% if update.comments %}
<div class="text-gray-600 leading-relaxed"> <div class="bg-slate-50/50 rounded-2xl p-5 border border-slate-100 text-slate-700 leading-relaxed shadow-inner">
{{ update.comments|linebreaks }} {{ update.comments|linebreaks }}
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div>
{% endfor %} {% endfor %}
</div> </div>
</div>
{% else %} {% else %}
<div class="text-center py-8 px-6 bg-gray-50 rounded-xl"> <div class="text-center py-12">
<i data-lucide="info" class="w-8 h-8 text-gray-400 mx-auto mb-2"></i> <div class="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-4">
<p class="text-gray-500"> <i data-lucide="loader" class="w-8 h-8 text-slate/30 animate-spin"></i>
{% trans "No updates available yet. You will be notified when there is progress on your complaint." %} </div>
</p> <p class="text-slate/60 font-medium">{% trans "Your complaint is being reviewed. Updates will appear here." %}</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endif %}
</div> </div>
{% endif %}
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});
</script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,219 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% trans "Complaint Templates" %} - PX360{% endblock %}
{% block extra_css %}
<style>
:root {
--hh-navy: #005696;
--hh-blue: #007bbd;
--hh-light: #eef6fb;
--hh-slate: #64748b;
}
.page-header {
background: linear-gradient(135deg, var(--hh-navy) 0%, #0069a8 50%, var(--hh-blue) 100%);
color: white;
padding: 2rem 2.5rem;
border-radius: 1rem;
margin-bottom: 2rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.template-card {
background: white;
border-radius: 1rem;
border: 1px solid #e2e8f0;
padding: 1.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.template-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.1);
border-color: var(--hh-blue);
}
.usage-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 700;
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
color: #1e40af;
}
.btn-primary {
background: linear-gradient(135deg, var(--hh-navy) 0%, var(--hh-blue) 100%);
color: white;
padding: 0.625rem 1.25rem;
border-radius: 0.75rem;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
font-size: 0.875rem;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 86, 150, 0.3);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
}
</style>
{% endblock %}
{% block content %}
<div class="px-4 py-6">
<!-- Page Header -->
<div class="page-header animate-in">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold mb-2">
<i data-lucide="file-template" class="w-7 h-7 inline-block me-2"></i>
{% trans "Complaint Templates" %}
</h1>
<p class="text-white/90">{% trans "Pre-defined templates for common complaints" %}</p>
</div>
<a href="{% url 'complaints:template_create' %}" class="inline-flex items-center gap-2 bg-white text-navy px-5 py-2.5 rounded-xl font-bold hover:bg-light transition shadow-lg">
<i data-lucide="plus" class="w-4 h-4"></i>
{% trans "New Template" %}
</a>
</div>
</div>
<!-- Filters -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 mb-6 animate-in">
<form method="get" class="flex flex-wrap gap-4">
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Hospital" %}</label>
<select name="hospital" class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% trans "All Hospitals" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}" {% if hospital_filter == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name }}
</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Status" %}</label>
<select name="is_active" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% trans "All Status" %}</option>
<option value="true" {% if is_active_filter == 'true' %}selected{% endif %}>{% trans "Active" %}</option>
<option value="false" {% if is_active_filter == 'false' %}selected{% endif %}>{% trans "Inactive" %}</option>
</select>
</div>
<div class="flex-1 min-w-[250px]">
<label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Search" %}</label>
<input type="text" name="search" value="{{ search }}"
placeholder="{% trans 'Template name...' %}"
class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue">
</div>
<div class="flex items-end">
<button type="submit" class="btn-primary h-[46px]">
<i data-lucide="search" class="w-4 h-4"></i>
{% trans "Filter" %}
</button>
</div>
</form>
</div>
<!-- Templates Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-in">
{% for template in templates %}
<div class="template-card" onclick="window.location='{% url 'complaints:template_detail' template.pk %}'">
<div class="flex items-start justify-between mb-4">
<div class="w-12 h-12 bg-blue/10 rounded-xl flex items-center justify-center">
<i data-lucide="file-text" class="w-6 h-6 text-blue-600"></i>
</div>
{% if template.is_active %}
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-green-100 text-green-700 rounded-full text-xs font-bold">
<i data-lucide="check-circle" class="w-3.5 h-3.5"></i>
{% trans "Active" %}
</span>
{% else %}
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 text-slate-600 rounded-full text-xs font-bold">
<i data-lucide="x-circle" class="w-3.5 h-3.5"></i>
{% trans "Inactive" %}
</span>
{% endif %}
</div>
<h3 class="text-lg font-bold text-navy mb-2">{{ template.name }}</h3>
<p class="text-sm text-slate mb-4 line-clamp-2">{{ template.description|truncatewords:15 }}</p>
<div class="flex items-center gap-3 mb-4">
<div class="usage-badge">
<i data-lucide="trending-up" class="w-3.5 h-3.5"></i>
{{ template.usage_count }} {% trans "uses" %}
</div>
{% if template.category %}
<span class="text-xs text-slate font-medium">{{ template.category.name_en }}</span>
{% endif %}
</div>
<div class="flex items-center justify-between pt-4 border-t border-slate-100">
<div class="text-xs text-slate">
<i data-lucide="building" class="w-3 h-3 inline-block mr-1"></i>
{{ template.hospital.name }}
</div>
<div class="flex items-center gap-2">
<a href="{% url 'complaints:template_edit' template.pk %}"
class="p-2 text-blue hover:bg-blue-50 rounded-lg transition"
title="{% trans 'Edit' %}"
onclick="event.stopPropagation();">
<i data-lucide="edit" class="w-4 h-4"></i>
</a>
{% if user.is_px_admin %}
<a href="{% url 'complaints:template_delete' template.pk %}"
class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition"
title="{% trans 'Delete' %}"
onclick="event.stopPropagation();">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</a>
{% endif %}
</div>
</div>
</div>
{% empty %}
<div class="col-span-full">
<div class="text-center py-12">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="file-template" class="w-8 h-8 text-slate-400"></i>
</div>
<p class="text-slate font-medium">{% trans "No templates found" %}</p>
<p class="text-slate text-sm mt-1">{% trans "Create your first template to get started" %}</p>
<a href="{% url 'complaints:template_create' %}" class="inline-flex items-center gap-2 mt-4 btn-primary">
<i data-lucide="plus" class="w-4 h-4"></i>
{% trans "Create Template" %}
</a>
</div>
</div>
{% endfor %}
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}

View File

@ -58,5 +58,31 @@
{% trans "Manage Hospitals" %} {% trans "Manage Hospitals" %}
</a> </a>
</div> </div>
<!-- On-Call Admin Schedules -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover">
<div class="w-16 h-16 mx-auto mb-4 bg-purple-100 rounded-2xl flex items-center justify-center">
<i data-lucide="moon" class="w-8 h-8 text-purple-600"></i>
</div>
<h3 class="font-bold text-navy text-lg mb-2">{% trans "On-Call Schedules" %}</h3>
<p class="text-sm text-slate mb-4">{{ oncall_schedules_count }} {% trans "active schedules" %}</p>
<a href="{% url 'complaints:oncall_dashboard' %}" class="inline-flex items-center gap-2 bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-navy/20 hover:bg-blue transition">
<i data-lucide="users" class="w-4 h-4"></i>
{% trans "Manage On-Call" %}
</a>
</div>
<!-- Call Records -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 text-center card-hover">
<div class="w-16 h-16 mx-auto mb-4 bg-pink-100 rounded-2xl flex items-center justify-center">
<i data-lucide="phone-call" class="w-8 h-8 text-pink-600"></i>
</div>
<h3 class="font-bold text-navy text-lg mb-2">{% trans "Call Records" %}</h3>
<p class="text-sm text-slate mb-4">{{ call_records_count }} {% trans "records" %}</p>
<a href="{% url 'callcenter:call_records_list' %}" class="inline-flex items-center gap-2 bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-navy/20 hover:bg-blue transition">
<i data-lucide="headphones" class="w-4 h-4"></i>
{% trans "Manage Call Records" %}
</a>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -288,14 +288,14 @@
</div> </div>
<!-- Form Content --> <!-- Form Content -->
<div id="formContent" class="max-h-[600px] overflow-y-auto pr-2"></div> <div id="formContent" class="pr-2"></div>
</div> </div>
<!-- Track Submission Links --> <!-- Track Submission Links -->
<div class="glass-card rounded-2xl shadow-xl p-8 text-center"> <div class="glass-card rounded-2xl shadow-xl p-8 text-center">
<p class="text-slate mb-4 font-medium">{% trans "Already submitted something?" %}</p> <p class="text-slate mb-4 font-medium">{% trans "Already submitted something?" %}</p>
<div class="flex justify-center gap-3 flex-wrap"> <div class="flex justify-center gap-3 flex-wrap">
<a href="{% url 'complaints:public_complaint_success' 'lookup' %}" <a href="{% url 'complaints:public_complaint_track' %}"
class="inline-flex items-center gap-2 px-6 py-3 bg-navy text-white rounded-xl hover:bg-blue transition font-semibold shadow-lg shadow-navy/30 btn-hover"> class="inline-flex items-center gap-2 px-6 py-3 bg-navy text-white rounded-xl hover:bg-blue transition font-semibold shadow-lg shadow-navy/30 btn-hover">
<i data-lucide="search" class="w-5 h-5"></i> <i data-lucide="search" class="w-5 h-5"></i>
{% trans "Track Complaint" %} {% trans "Track Complaint" %}

View File

@ -1,14 +1,14 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% load i18n %} {% load i18n static %}
{% block title %}{% trans "Select Hospital" %} - PX360{% endblock %} {% block title %}{% trans "Select Hospital" %} - PX360{% endblock %}
{% block content %} {% block content %}
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header --> <!-- Header -->
<div class="text-center mb-8"> <div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-blue-500 to-navy rounded-2xl mb-4 shadow-lg"> <div class="mb-4">
<i data-lucide="building-2" class="w-10 h-10 text-white"></i> <img src="{% static 'img/hh-logo.png' %}" alt="Al Hammadi Hospital" class="h-20 mx-auto">
</div> </div>
<h1 class="text-3xl font-bold text-navy mb-3"> <h1 class="text-3xl font-bold text-navy mb-3">
{% trans "Select Hospital" %} {% trans "Select Hospital" %}
@ -19,61 +19,114 @@
</div> </div>
<!-- Hospital Selection Form --> <!-- Hospital Selection Form -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden"> <form method="post" class="space-y-6" id="hospitalForm">
<form method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="next" value="{{ next }}"> <input type="hidden" name="next" value="{{ next }}">
<div class="divide-y divide-slate-100"> {% if hospitals %}
<!-- Hospital Cards Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for hospital in hospitals %} {% for hospital in hospitals %}
<div class="block cursor-pointer hover:bg-light/30 transition group relative"> {% with hospital_id_str=hospital.id|stringformat:"s" %}
<input type="radio" <div
class="hospital-card group relative cursor-pointer rounded-2xl border-2 transition-all duration-200
{% if hospital_id_str == selected_hospital_id %}
border-blue bg-blue-50/50 shadow-md selected
{% else %}
border-slate-200 bg-white hover:border-blue/50 hover:-translate-y-1
{% endif %}"
onclick="selectHospital('{{ hospital_id_str }}')"
data-hospital-id="{{ hospital_id_str }}"
>
<input
type="radio"
id="hospital_{{ hospital.id }}" id="hospital_{{ hospital.id }}"
name="hospital_id" name="hospital_id"
value="{{ hospital.id }}" value="{{ hospital.id }}"
{% if hospital.id == selected_hospital_id %}checked{% endif %} {% if hospital_id_str == selected_hospital_id %}checked{% endif %}
class="peer" class="hospital-radio sr-only"
style="position: absolute; opacity: 0; width: 0; height: 0;"> >
<label for="hospital_{{ hospital.id }}" class="block p-6"> <!-- Card Content -->
<div class="flex items-start gap-4"> <div class="p-6">
<!-- Radio Button --> <!-- Selection Indicator -->
<div class="flex-shrink-0 mt-1"> <div class="absolute top-4 right-4">
<div class="w-5 h-5 rounded-full border-2 border-slate-300 peer-checked:border-blue peer-checked:bg-blue flex items-center justify-center transition-all"> <div class="selection-indicator w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all
<div class="w-2.5 h-2.5 bg-white rounded-full opacity-0 peer-checked:opacity-100 transition-opacity"></div> {% if hospital_id_str == selected_hospital_id %}
border-blue bg-blue
{% else %}
border-slate-300
{% endif %}">
<i data-lucide="check" class="w-3.5 h-3.5 text-white transition-opacity
{% if hospital_id_str == selected_hospital_id %}
opacity-100
{% else %}
opacity-0
{% endif %}"></i>
</div> </div>
</div> </div>
<!-- Hospital Info --> <!-- Hospital Icon -->
<div class="flex-1 min-w-0"> <div class="hospital-icon w-14 h-14 rounded-xl flex items-center justify-center mb-4 transition-all
<div class="flex items-start justify-between gap-4"> {% if hospital_id_str == selected_hospital_id %}
<div class="flex-1"> bg-gradient-to-br from-blue to-navy ring-4 ring-blue/20
<h3 class="text-lg font-bold text-navy mb-1 group-hover:text-blue transition"> {% else %}
bg-slate-100 group-hover:bg-blue-50
{% endif %}">
<i data-lucide="building-2" class="w-7 h-7 transition-colors
{% if hospital_id_str == selected_hospital_id %}
text-white
{% else %}
text-slate-500 group-hover:text-blue
{% endif %}"></i>
</div>
<!-- Hospital Name -->
<h3 class="hospital-name text-lg font-bold mb-2 transition-colors pr-8
{% if hospital_id_str == selected_hospital_id %}
text-blue
{% else %}
text-navy group-hover:text-blue
{% endif %}">
{{ hospital.name }} {{ hospital.name }}
</h3> </h3>
<!-- Location -->
{% if hospital.city %} {% if hospital.city %}
<p class="text-slate text-sm flex items-center gap-1.5"> <div class="flex items-center gap-2 text-slate text-sm">
<i data-lucide="map-pin" class="w-3.5 h-3.5"></i> <i data-lucide="map-pin" class="w-4 h-4 text-slate/70"></i>
<span>
{{ hospital.city }} {{ hospital.city }}
{% if hospital.country %}, {{ hospital.country }}{% endif %} {% if hospital.country %}, {{ hospital.country }}{% endif %}
</p> </span>
{% endif %}
</div> </div>
{% else %}
<div class="flex items-center gap-2 text-slate/50 text-sm">
<i data-lucide="map-pin" class="w-4 h-4"></i>
<span>{% trans "Location not specified" %}</span>
</div>
{% endif %}
<!-- Selected Badge --> <!-- Selected Badge -->
{% if hospital.id == selected_hospital_id %} <div class="selected-badge mt-4 pt-4 border-t transition-all
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-bold bg-green-100 text-green-800 flex-shrink-0"> {% if hospital_id_str == selected_hospital_id %}
<i data-lucide="check-circle-2" class="w-3.5 h-3.5 mr-1.5"></i> block border-blue/20
{% trans "Selected" %} {% else %}
hidden border-slate-100
{% endif %}">
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-blue">
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
{% trans "Currently Selected" %}
</span> </span>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
</label> {% endwith %}
{% endfor %}
</div> </div>
{% empty %} {% else %}
<div class="p-12 text-center"> <!-- Empty State -->
<div class="bg-white rounded-2xl border border-slate-200 p-12 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-amber-50 rounded-full mb-4"> <div class="inline-flex items-center justify-center w-16 h-16 bg-amber-50 rounded-full mb-4">
<i data-lucide="alert-triangle" class="w-8 h-8 text-amber-500"></i> <i data-lucide="alert-triangle" class="w-8 h-8 text-amber-500"></i>
</div> </div>
@ -84,13 +137,13 @@
{% trans "No hospitals found in the system. Please contact your administrator." %} {% trans "No hospitals found in the system. Please contact your administrator." %}
</p> </p>
</div> </div>
{% endfor %} {% endif %}
</div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="p-6 bg-slate-50 border-t border-slate-200"> {% if hospitals %}
<div class="bg-white rounded-2xl border border-slate-200 p-6 mt-8">
<div class="flex flex-col sm:flex-row justify-between items-center gap-4"> <div class="flex flex-col sm:flex-row justify-between items-center gap-4">
<a href="/" class="w-full sm:w-auto px-6 py-3 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-white transition flex items-center justify-center gap-2"> <a href="/" class="w-full sm:w-auto px-6 py-3 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 transition flex items-center justify-center gap-2">
<i data-lucide="arrow-left" class="w-5 h-5"></i> <i data-lucide="arrow-left" class="w-5 h-5"></i>
{% trans "Back to Dashboard" %} {% trans "Back to Dashboard" %}
</a> </a>
@ -100,8 +153,8 @@
</button> </button>
</div> </div>
</div> </div>
{% endif %}
</form> </form>
</div>
<!-- Info Banner --> <!-- Info Banner -->
<div class="bg-blue-50 border border-blue-200 rounded-2xl p-4 mt-6"> <div class="bg-blue-50 border border-blue-200 rounded-2xl p-4 mt-6">
@ -123,5 +176,103 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons(); lucide.createIcons();
}); });
function selectHospital(hospitalId) {
// Check the radio button
const radio = document.getElementById('hospital_' + hospitalId);
if (radio) {
radio.checked = true;
}
// Update all cards visual state
document.querySelectorAll('.hospital-card').forEach(card => {
const cardHospitalId = card.dataset.hospitalId;
const isSelected = cardHospitalId === hospitalId;
// Update card border and background
if (isSelected) {
card.classList.remove('border-slate-200', 'bg-white');
card.classList.add('border-blue', 'bg-blue-50/50', 'shadow-md', 'selected');
} else {
card.classList.remove('border-blue', 'bg-blue-50/50', 'shadow-md', 'selected');
card.classList.add('border-slate-200', 'bg-white');
}
// Update selection indicator (check circle)
const indicator = card.querySelector('.selection-indicator');
if (indicator) {
if (isSelected) {
indicator.classList.remove('border-slate-300');
indicator.classList.add('border-blue', 'bg-blue');
} else {
indicator.classList.remove('border-blue', 'bg-blue');
indicator.classList.add('border-slate-300');
}
}
// Update check icon
const checkIcon = card.querySelector('.selection-indicator i');
if (checkIcon) {
if (isSelected) {
checkIcon.classList.remove('opacity-0');
checkIcon.classList.add('opacity-100');
} else {
checkIcon.classList.remove('opacity-100');
checkIcon.classList.add('opacity-0');
}
}
// Update hospital icon
const iconContainer = card.querySelector('.hospital-icon');
if (iconContainer) {
if (isSelected) {
iconContainer.classList.remove('bg-slate-100');
iconContainer.classList.add('bg-gradient-to-br', 'from-blue', 'to-navy', 'ring-4', 'ring-blue/20');
} else {
iconContainer.classList.remove('bg-gradient-to-br', 'from-blue', 'to-navy', 'ring-4', 'ring-blue/20');
iconContainer.classList.add('bg-slate-100');
}
}
// Update icon color
const icon = card.querySelector('.hospital-icon i');
if (icon) {
if (isSelected) {
icon.classList.remove('text-slate-500');
icon.classList.add('text-white');
} else {
icon.classList.remove('text-white');
icon.classList.add('text-slate-500');
}
}
// Update hospital name color
const name = card.querySelector('.hospital-name');
if (name) {
if (isSelected) {
name.classList.remove('text-navy');
name.classList.add('text-blue');
} else {
name.classList.remove('text-blue');
name.classList.add('text-navy');
}
}
// Update selected badge
const badge = card.querySelector('.selected-badge');
if (badge) {
if (isSelected) {
badge.classList.remove('hidden');
badge.classList.add('block', 'border-blue/20');
} else {
badge.classList.remove('block', 'border-blue/20');
badge.classList.add('hidden');
}
}
});
// Re-render icons
lucide.createIcons();
}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -7,16 +7,23 @@
{% block content %} {% block content %}
<div class="space-y-6"> <div class="space-y-6">
<!-- Header --> <!-- Page Header -->
<div class="flex justify-between items-start"> <header class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div> <div>
<h1 class="text-3xl font-bold mb-2">{% trans "Admin Evaluation Dashboard" %}</h1> <h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<p class="text-gray-500">{% trans "Staff performance analysis for complaints and inquiries" %}</p> <i data-lucide="shield-check" class="w-7 h-7"></i>
{% trans "Admin Evaluation Dashboard" %}
</h1>
<p class="text-sm text-slate mt-1">{% trans "Staff performance analysis for complaints and inquiries" %}</p>
</div> </div>
<div class="text-right">
<p class="text-xs text-slate uppercase tracking-wider">{% trans "Last Updated" %}</p>
<p class="text-sm font-bold text-navy">{% now "j M Y, H:i" %}</p>
</div> </div>
</header>
<!-- Filters --> <!-- Filters -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 p-6"> <div class="card">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Date Range --> <!-- Date Range -->
<div> <div>
@ -75,99 +82,134 @@
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<a href="{% url 'dashboard:department_benchmarks' %}?date_range={{ date_range }}" class="inline-flex items-center gap-2 px-4 py-2.5 border-2 border-navy text-navy rounded-xl font-semibold hover:bg-light transition"> <a href="{% url 'dashboard:department_benchmarks' %}?date_range={{ date_range }}" class="inline-flex items-center gap-2 px-4 py-2 border-2 border-navy text-navy rounded-lg font-semibold hover:bg-light transition">
<i data-lucide="bar-chart-2" class="w-4 h-4"></i> <i data-lucide="bar-chart-2" class="w-4 h-4"></i>
{% trans "Department Benchmarks" %} {% trans "Department Benchmarks" %}
</a> </a>
<button onclick="exportReport('csv')" class="inline-flex items-center gap-2 px-4 py-2.5 border-2 border-green-500 text-green-500 rounded-xl font-semibold hover:bg-green-50 transition"> <button onclick="exportReport('csv')" class="inline-flex items-center gap-2 px-4 py-2 border-2 border-green-500 text-green-500 rounded-lg font-semibold hover:bg-green-50 transition">
<i data-lucide="download" class="w-4 h-4"></i> <i data-lucide="download" class="w-4 h-4"></i>
{% trans "Export CSV" %} {% trans "Export CSV" %}
</button> </button>
<button onclick="exportReport('json')" class="inline-flex items-center gap-2 px-4 py-2.5 border-2 border-blue-500 text-blue-500 rounded-xl font-semibold hover:bg-blue-50 transition"> <button onclick="exportReport('json')" class="inline-flex items-center gap-2 px-4 py-2 border-2 border-blue-500 text-blue-500 rounded-lg font-semibold hover:bg-blue-50 transition">
<i data-lucide="file-code" class="w-4 h-4"></i> <i data-lucide="file-code" class="w-4 h-4"></i>
{% trans "Export JSON" %} {% trans "Export JSON" %}
</button> </button>
</div> </div>
<!-- Summary Cards --> <!-- Summary Cards -->
<div id="summaryCards" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div id="summaryCards" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{% if performance_data.staff_metrics %} {% if performance_data.staff_metrics %}
<div class="bg-gradient-to-br from-navy to-navy rounded-2xl p-6 text-white shadow-lg shadow-blue-200"> <div class="card stat-card">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-start justify-between">
<div class="bg-white/20 p-3 rounded-xl"> <div>
<i data-lucide="users" class="w-6 h-6"></i> <p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Total Staff" %}</p>
<p class="text-3xl font-bold text-navy">{{ performance_data.staff_metrics|length }}</p>
<div class="flex items-center gap-1.5 mt-2">
<i data-lucide="building" class="w-4 h-4 text-blue"></i>
<span class="text-sm text-slate">{% trans "Active Staff" %}</span>
</div> </div>
<h3 class="text-sm font-medium opacity-90">{% trans "Total Staff" %}</h3>
</div> </div>
<div class="text-4xl font-bold">{{ performance_data.staff_metrics|length }}</div> <div class="p-3 bg-blue-50 rounded-xl">
<i data-lucide="users" class="w-6 h-6 text-blue"></i>
</div> </div>
<div class="bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-2xl p-6 text-white shadow-lg shadow-indigo-200">
<div class="flex items-center gap-3 mb-4">
<div class="bg-white/20 p-3 rounded-xl">
<i data-lucide="alert-triangle" class="w-6 h-6"></i>
</div> </div>
<h3 class="text-sm font-medium opacity-90">{% trans "Total Complaints" %}</h3>
</div> </div>
<div class="text-4xl font-bold" id="totalComplaints">0</div>
<div class="card stat-card">
<div class="flex items-start justify-between">
<div>
<p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Total Complaints" %}</p>
<p class="text-3xl font-bold text-navy" id="totalComplaints">0</p>
<div class="flex items-center gap-1.5 mt-2">
<i data-lucide="trending-up" class="w-4 h-4 text-red-500"></i>
<span class="text-xs text-slate">{% trans "Requires Attention" %}</span>
</div> </div>
<div class="bg-gradient-to-br from-orange-500 to-orange-600 rounded-2xl p-6 text-white shadow-lg shadow-orange-200">
<div class="flex items-center gap-3 mb-4">
<div class="bg-white/20 p-3 rounded-xl">
<i data-lucide="message-circle" class="w-6 h-6"></i>
</div> </div>
<h3 class="text-sm font-medium opacity-90">{% trans "Total Inquiries" %}</h3> <div class="p-3 bg-red-50 rounded-xl">
<i data-lucide="alert-triangle" class="w-6 h-6 text-red-500"></i>
</div> </div>
<div class="text-4xl font-bold" id="totalInquiries">0</div>
</div> </div>
<div class="bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-2xl p-6 text-white shadow-lg shadow-emerald-200">
<div class="flex items-center gap-3 mb-4">
<div class="bg-white/20 p-3 rounded-xl">
<i data-lucide="check-circle" class="w-6 h-6"></i>
</div> </div>
<h3 class="text-sm font-medium opacity-90">{% trans "Resolution Rate" %}</h3>
<div class="card stat-card">
<div class="flex items-start justify-between">
<div>
<p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Total Inquiries" %}</p>
<p class="text-3xl font-bold text-navy" id="totalInquiries">0</p>
<div class="flex items-center gap-1.5 mt-2">
<i data-lucide="message-circle" class="w-4 h-4 text-blue"></i>
<span class="text-xs text-slate">{% trans "Open Requests" %}</span>
</div>
</div>
<div class="p-3 bg-blue-50 rounded-xl">
<i data-lucide="message-circle" class="w-6 h-6 text-blue"></i>
</div>
</div>
</div>
<div class="card stat-card">
<div class="flex items-start justify-between">
<div>
<p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Resolution Rate" %}</p>
<p class="text-3xl font-bold text-navy" id="resolutionRate">0%</p>
<div class="flex items-center gap-1.5 mt-2">
<i data-lucide="trending-up" class="w-4 h-4 text-green-500"></i>
<span class="text-sm font-bold text-green-500">{% trans "Performance" %}</span>
</div>
</div>
<div class="p-3 bg-green-50 rounded-xl">
<i data-lucide="check-circle" class="w-6 h-6 text-green-500"></i>
</div>
</div> </div>
<div class="text-4xl font-bold" id="resolutionRate">0%</div>
</div> </div>
{% else %} {% else %}
<div class="col-span-full bg-blue-50 border border-blue-200 rounded-2xl p-6 text-center"> <div class="col-span-full card">
<i data-lucide="info" class="w-6 h-6 text-blue-500 mx-auto mb-2"></i> <div class="flex flex-col items-center justify-center py-8 text-center">
<p class="text-blue-700">{% trans "No staff members with assigned complaints or inquiries found in the selected time period." %}</p> <div class="bg-blue-50 w-16 h-16 rounded-full flex items-center justify-center mb-4">
<i data-lucide="info" class="w-8 h-8 text-blue"></i>
</div>
<p class="text-sm text-slate">{% trans "No staff members with assigned complaints or inquiries found in the selected time period." %}</p>
</div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- Tabs --> <!-- Tabs -->
{% if performance_data.staff_metrics %} {% if performance_data.staff_metrics %}
<div class="bg-white rounded-2xl shadow-sm border border-gray-50"> <div class="card">
<div class="border-b border-gray-100"> <div class="border-b border-slate-100">
<nav class="flex gap-1 p-2" id="evaluationTabs"> <nav class="flex gap-1" id="evaluationTabs">
<button class="px-6 py-3 rounded-xl font-semibold bg-light0 text-white" data-tab="complaints" id="complaints-tab"> <button class="px-6 py-3 rounded-lg font-semibold bg-navy text-white" data-tab="complaints" id="complaints-tab">
{% trans "Complaints" %} {% trans "Complaints" %}
</button> </button>
<button class="px-6 py-3 rounded-xl font-semibold text-gray-500 hover:bg-gray-100 transition" data-tab="inquiries" id="inquiries-tab"> <button class="px-6 py-3 rounded-lg font-semibold text-slate hover:text-navy hover:bg-light transition" data-tab="inquiries" id="inquiries-tab">
{% trans "Inquiries" %} {% trans "Inquiries" %}
</button> </button>
</nav> </nav>
</div> </div>
<!-- Complaints Tab Content --> <!-- Complaints Tab Content -->
<div id="complaints-content" class="tab-content p-6"> <div id="complaints-content" class="tab-content">
<!-- Charts Row 1 --> <!-- Charts Row 1 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="border border-gray-100 rounded-2xl p-6"> <div class="card">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2"> <div class="card-header">
<i data-lucide="pie-chart" class="w-5 h-5 text-navy"></i> <h3 class="card-title flex items-center gap-2">
<i data-lucide="pie-chart" class="w-4 h-4"></i>
{% trans "Complaint Source Breakdown" %} {% trans "Complaint Source Breakdown" %}
</h3> </h3>
</div>
<div class="h-[320px]"> <div class="h-[320px]">
<canvas id="complaintSourceChart"></canvas> <canvas id="complaintSourceChart"></canvas>
</div> </div>
</div> </div>
<div class="border border-gray-100 rounded-2xl p-6"> <div class="card">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2"> <div class="card-header">
<i data-lucide="bar-chart-2" class="w-5 h-5 text-navy"></i> <h3 class="card-title flex items-center gap-2">
<i data-lucide="bar-chart-2" class="w-4 h-4"></i>
{% trans "Complaint Status Distribution" %} {% trans "Complaint Status Distribution" %}
</h3> </h3>
</div>
<div class="h-[320px]"> <div class="h-[320px]">
<canvas id="complaintStatusChart"></canvas> <canvas id="complaintStatusChart"></canvas>
</div> </div>
@ -176,22 +218,26 @@
<!-- Charts Row 2 --> <!-- Charts Row 2 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="border border-gray-100 rounded-2xl p-6"> <div class="card">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2"> <div class="card-header">
<i data-lucide="clock" class="w-5 h-5 text-navy"></i> <h3 class="card-title flex items-center gap-2">
<i data-lucide="clock" class="w-4 h-4"></i>
{% trans "Complaint Activation Time" %} {% trans "Complaint Activation Time" %}
</h3> </h3>
<p class="text-sm text-gray-500 mb-4">{% trans "Time from creation to assignment" %}</p> <p class="text-sm text-slate">{% trans "Time from creation to assignment" %}</p>
</div>
<div class="h-[320px]"> <div class="h-[320px]">
<canvas id="complaintActivationChart"></canvas> <canvas id="complaintActivationChart"></canvas>
</div> </div>
</div> </div>
<div class="border border-gray-100 rounded-2xl p-6"> <div class="card">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2"> <div class="card-header">
<i data-lucide="gauge" class="w-5 h-5 text-navy"></i> <h3 class="card-title flex items-center gap-2">
<i data-lucide="gauge" class="w-4 h-4"></i>
{% trans "Complaint Response Time" %} {% trans "Complaint Response Time" %}
</h3> </h3>
<p class="text-sm text-gray-500 mb-4">{% trans "Time to first response/update" %}</p> <p class="text-sm text-slate">{% trans "Time to first response/update" %}</p>
</div>
<div class="h-[320px]"> <div class="h-[320px]">
<canvas id="complaintResponseChart"></canvas> <canvas id="complaintResponseChart"></canvas>
</div> </div>
@ -199,47 +245,47 @@
</div> </div>
<!-- Staff Comparison Table --> <!-- Staff Comparison Table -->
<div class="border border-gray-100 rounded-2xl overflow-hidden"> <div class="card">
<div class="bg-gradient-to-r from-light to-blue-50 px-6 py-4 border-b border-gray-100"> <div class="card-header">
<h3 class="font-bold text-lg flex items-center gap-2"> <h3 class="card-title flex items-center gap-2">
<i data-lucide="users" class="w-5 h-5 text-navy"></i> <i data-lucide="users" class="w-4 h-4"></i>
{% trans "Staff Complaint Performance" %} {% trans "Staff Complaint Performance" %}
</h3> </h3>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">
<thead class="bg-gray-50"> <thead class="bg-light">
<tr> <tr>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Staff Name" %}</th> <th class="px-6 py-4 text-left text-xs font-bold text-slate uppercase tracking-wider">{% trans "Staff Name" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Hospital" %}</th> <th class="px-6 py-4 text-left text-xs font-bold text-slate uppercase tracking-wider">{% trans "Hospital" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Department" %}</th> <th class="px-6 py-4 text-left text-xs font-bold text-slate uppercase tracking-wider">{% trans "Department" %}</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Total" %}</th> <th class="px-6 py-4 text-center text-xs font-bold text-slate uppercase tracking-wider">{% trans "Total" %}</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Internal" %}</th> <th class="px-6 py-4 text-center text-xs font-bold text-slate uppercase tracking-wider">{% trans "Internal" %}</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "External" %}</th> <th class="px-6 py-4 text-center text-xs font-bold text-slate uppercase tracking-wider">{% trans "External" %}</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Open" %}</th> <th class="px-6 py-4 text-center text-xs font-bold text-slate uppercase tracking-wider">{% trans "Open" %}</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Resolved" %}</th> <th class="px-6 py-4 text-center text-xs font-bold text-slate uppercase tracking-wider">{% trans "Resolved" %}</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Activation ≤2h" %}</th> <th class="px-6 py-4 text-center text-xs font-bold text-slate uppercase tracking-wider">{% trans "Activation ≤2h" %}</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Response ≤24h" %}</th> <th class="px-6 py-4 text-center text-xs font-bold text-slate uppercase tracking-wider">{% trans "Response ≤24h" %}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-50"> <tbody class="divide-y divide-slate-100">
{% for staff in performance_data.staff_metrics %} {% for staff in performance_data.staff_metrics %}
<tr class="hover:bg-gray-50 transition"> <tr class="hover:bg-light transition">
<td class="px-6 py-4"> <td class="px-6 py-4">
<a href="{% url 'dashboard:staff_performance_detail' staff.id %}?date_range={{ date_range }}" class="font-semibold text-navy hover:text-navy"> <a href="{% url 'dashboard:staff_performance_detail' staff.id %}?date_range={{ date_range }}" class="font-bold text-navy hover:text-blue transition group">
{{ staff.name }} {{ staff.name }}
</a> </a>
</td> </td>
<td class="px-6 py-4 text-sm text-gray-600">{{ staff.hospital|default:"-" }}</td> <td class="px-6 py-4 text-sm text-slate">{{ staff.hospital|default:"-" }}</td>
<td class="px-6 py-4 text-sm text-gray-600">{{ staff.department|default:"-" }}</td> <td class="px-6 py-4 text-sm text-slate">{{ staff.department|default:"-" }}</td>
<td class="px-6 py-4 text-center font-bold">{{ staff.complaints.total }}</td> <td class="px-6 py-4 text-center font-bold text-navy">{{ staff.complaints.total }}</td>
<td class="px-6 py-4 text-center">{{ staff.complaints.internal }}</td> <td class="px-6 py-4 text-center">{{ staff.complaints.internal }}</td>
<td class="px-6 py-4 text-center">{{ staff.complaints.external }}</td> <td class="px-6 py-4 text-center">{{ staff.complaints.external }}</td>
<td class="px-6 py-4 text-center"> <td class="px-6 py-4 text-center">
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-yellow-100 text-yellow-600">{{ staff.complaints.status.open }}</span> <span class="px-3 py-1 rounded-full text-xs font-bold bg-yellow-100 text-yellow-600">{{ staff.complaints.status.open }}</span>
</td> </td>
<td class="px-6 py-4 text-center"> <td class="px-6 py-4 text-center">
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-green-100 text-green-600">{{ staff.complaints.status.resolved }}</span> <span class="px-3 py-1 rounded-full text-xs font-bold bg-green-100 text-green-600">{{ staff.complaints.status.resolved }}</span>
</td> </td>
<td class="px-6 py-4 text-center">{{ staff.complaints.activation_time.within_2h }}</td> <td class="px-6 py-4 text-center">{{ staff.complaints.activation_time.within_2h }}</td>
<td class="px-6 py-4 text-center">{{ staff.complaints.response_time.within_24h }}</td> <td class="px-6 py-4 text-center">{{ staff.complaints.response_time.within_24h }}</td>
@ -252,24 +298,28 @@
</div> </div>
<!-- Inquiries Tab Content --> <!-- Inquiries Tab Content -->
<div id="inquiries-content" class="tab-content p-6 hidden"> <div id="inquiries-content" class="tab-content hidden">
<!-- Charts Row --> <!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="border border-gray-100 rounded-2xl p-6"> <div class="card">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2"> <div class="card-header">
<i data-lucide="bar-chart-2" class="w-5 h-5 text-navy"></i> <h3 class="card-title flex items-center gap-2">
<i data-lucide="bar-chart-2" class="w-4 h-4"></i>
{% trans "Inquiry Status Distribution" %} {% trans "Inquiry Status Distribution" %}
</h3> </h3>
</div>
<div class="h-[320px]"> <div class="h-[320px]">
<canvas id="inquiryStatusChart"></canvas> <canvas id="inquiryStatusChart"></canvas>
</div> </div>
</div> </div>
<div class="border border-gray-100 rounded-2xl p-6"> <div class="card">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2"> <div class="card-header">
<i data-lucide="gauge" class="w-5 h-5 text-navy"></i> <h3 class="card-title flex items-center gap-2">
<i data-lucide="gauge" class="w-4 h-4"></i>
{% trans "Inquiry Response Time" %} {% trans "Inquiry Response Time" %}
</h3> </h3>
<p class="text-sm text-gray-500 mb-4">{% trans "Time to first response/update" %}</p> <p class="text-sm text-slate">{% trans "Time to first response/update" %}</p>
</div>
<div class="h-[320px]"> <div class="h-[320px]">
<canvas id="inquiryResponseChart"></canvas> <canvas id="inquiryResponseChart"></canvas>
</div> </div>
@ -277,40 +327,40 @@
</div> </div>
<!-- Staff Comparison Table --> <!-- Staff Comparison Table -->
<div class="border border-gray-100 rounded-2xl overflow-hidden"> <div class="card">
<div class="bg-gradient-to-r from-light to-blue-50 px-6 py-4 border-b border-gray-100"> <div class="card-header">
<h3 class="font-bold text-lg flex items-center gap-2"> <h3 class="card-title flex items-center gap-2">
<i data-lucide="users" class="w-5 h-5 text-navy"></i> <i data-lucide="users" class="w-4 h-4"></i>
{% trans "Staff Inquiry Performance" %} {% trans "Staff Inquiry Performance" %}
</h3> </h3>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">
<thead class="bg-gray-50"> <thead class="bg-light">
<tr> <tr>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Staff Name" %}</th> <th class="px-6 py-4 text-left text-xs font-bold text-slate uppercase tracking-wider">{% trans "Staff Name" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Hospital" %}</th> <th class="px-6 py-4 text-left text-xs font-bold text-slate uppercase tracking-wider">{% trans "Hospital" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Department" %}</th> <th class="px-6 py-4 text-left text-xs font-bold text-slate uppercase tracking-wider">{% trans "Department" %}</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Total" %}</th> <th class="px-6 py-4 text-center text-xs font-bold text-slate uppercase tracking-wider">{% trans "Total" %}</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Open" %}</th> <th class="px-6 py-4 text-center text-xs font-bold text-slate uppercase tracking-wider">{% trans "Open" %}</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Resolved" %}</th> <th class="px-6 py-4 text-center text-xs font-bold text-slate uppercase tracking-wider">{% trans "Resolved" %}</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Response ≤24h" %}</th> <th class="px-6 py-4 text-center text-xs font-bold text-slate uppercase tracking-wider">{% trans "Response ≤24h" %}</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Response ≤48h" %}</th> <th class="px-6 py-4 text-center text-xs font-bold text-slate uppercase tracking-wider">{% trans "Response ≤48h" %}</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Response ≤72h" %}</th> <th class="px-6 py-4 text-center text-xs font-bold text-slate uppercase tracking-wider">{% trans "Response ≤72h" %}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-50"> <tbody class="divide-y divide-slate-100">
{% for staff in performance_data.staff_metrics %} {% for staff in performance_data.staff_metrics %}
<tr class="hover:bg-gray-50 transition"> <tr class="hover:bg-light transition">
<td class="px-6 py-4 font-semibold text-gray-800">{{ staff.name }}</td> <td class="px-6 py-4 font-bold text-navy">{{ staff.name }}</td>
<td class="px-6 py-4 text-sm text-gray-600">{{ staff.hospital|default:"-" }}</td> <td class="px-6 py-4 text-sm text-slate">{{ staff.hospital|default:"-" }}</td>
<td class="px-6 py-4 text-sm text-gray-600">{{ staff.department|default:"-" }}</td> <td class="px-6 py-4 text-sm text-slate">{{ staff.department|default:"-" }}</td>
<td class="px-6 py-4 text-center font-bold">{{ staff.inquiries.total }}</td> <td class="px-6 py-4 text-center font-bold text-navy">{{ staff.inquiries.total }}</td>
<td class="px-6 py-4 text-center"> <td class="px-6 py-4 text-center">
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-yellow-100 text-yellow-600">{{ staff.inquiries.status.open }}</span> <span class="px-3 py-1 rounded-full text-xs font-bold bg-yellow-100 text-yellow-600">{{ staff.inquiries.status.open }}</span>
</td> </td>
<td class="px-6 py-4 text-center"> <td class="px-6 py-4 text-center">
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-green-100 text-green-600">{{ staff.inquiries.status.resolved }}</span> <span class="px-3 py-1 rounded-full text-xs font-bold bg-green-100 text-green-600">{{ staff.inquiries.status.resolved }}</span>
</td> </td>
<td class="px-6 py-4 text-center">{{ staff.inquiries.response_time.within_24h }}</td> <td class="px-6 py-4 text-center">{{ staff.inquiries.response_time.within_24h }}</td>
<td class="px-6 py-4 text-center">{{ staff.inquiries.response_time.within_48h }}</td> <td class="px-6 py-4 text-center">{{ staff.inquiries.response_time.within_48h }}</td>

View File

@ -81,6 +81,15 @@
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Dashboard" %}</span> <span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Dashboard" %}</span>
</a> </a>
<!-- Admin Evaluation (Admin only) -->
{% if user.is_px_admin or user.is_hospital_admin %}
<a href="{% url 'dashboard:admin_evaluation' %}"
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'admin-evaluation' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
<i data-lucide="shield-check" class="w-5 h-5 flex-shrink-0"></i>
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Admin Evaluation" %}</span>
</a>
{% endif %}
<!-- Complaints --> <!-- Complaints -->
<a href="{% url 'complaints:complaint_list' %}" <a href="{% url 'complaints:complaint_list' %}"
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'complaints' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}"> class="flex items-center gap-3 p-3 rounded-lg transition {% if 'complaints' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
@ -263,6 +272,38 @@
</div> </div>
</div> </div>
<!-- PX Sources (with submenu) -->
<div class="px-sources-nav-container">
<button onclick="togglePXSourcesMenu(event)"
class="w-full flex items-center gap-3 p-3 rounded-lg transition {% if 'px-sources' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
<i data-lucide="radio" class="w-5 h-5 flex-shrink-0"></i>
<span class="sidebar-text text-sm font-semibold whitespace-nowrap flex-1 text-left">{% trans "PX Sources" %}</span>
<i data-lucide="chevron-down" class="sidebar-text w-4 h-4 flex-shrink-0 transform transition-transform duration-200 {% if 'px-sources' in request.path %}rotate-180{% endif %}" id="px-sources-chevron"></i>
</button>
<div class="submenu {% if 'px-sources' in request.path %}open{% endif %} space-y-1 mt-1" id="px-sources-submenu">
<a href="{% url 'px_sources:source_list' %}"
class="flex items-center gap-2 p-2 rounded-lg transition {% if request.resolver_match.url_name == 'source_list' %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
<i data-lucide="list" class="w-4 h-4 flex-shrink-0"></i>
<span class="sidebar-text whitespace-nowrap">{% trans "All Sources" %}</span>
</a>
<a href="{% url 'px_sources:source_user_dashboard' %}"
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'dashboard' in request.path and 'px-sources' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
<i data-lucide="layout-dashboard" class="w-4 h-4 flex-shrink-0"></i>
<span class="sidebar-text whitespace-nowrap">{% trans "Dashboard" %}</span>
</a>
<a href="{% url 'px_sources:source_user_complaint_list' %}"
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'complaints' in request.path and 'px-sources' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
<i data-lucide="file-text" class="w-4 h-4 flex-shrink-0"></i>
<span class="sidebar-text whitespace-nowrap">{% trans "Source Complaints" %}</span>
</a>
<a href="{% url 'px_sources:source_user_inquiry_list' %}"
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'inquiries' in request.path and 'px-sources' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
<i data-lucide="help-circle" class="w-4 h-4 flex-shrink-0"></i>
<span class="sidebar-text whitespace-nowrap">{% trans "Source Inquiries" %}</span>
</a>
</div>
</div>
<!-- Send SMS (Admin only) --> <!-- Send SMS (Admin only) -->
{% if user.is_px_admin or user.is_hospital_admin %} {% if user.is_px_admin or user.is_hospital_admin %}
<a href="{% url 'notifications:send_sms_direct' %}" <a href="{% url 'notifications:send_sms_direct' %}"
@ -350,6 +391,14 @@ function toggleStaffMenu(event) {
chevron.classList.toggle('rotate-180'); chevron.classList.toggle('rotate-180');
} }
function togglePXSourcesMenu(event) {
event.preventDefault();
const submenu = document.getElementById('px-sources-submenu');
const chevron = document.getElementById('px-sources-chevron');
submenu.classList.toggle('open');
chevron.classList.toggle('rotate-180');
}
function logout() { function logout() {
if (confirm('{% trans "Are you sure you want to logout?" %}')) { if (confirm('{% trans "Are you sure you want to logout?" %}')) {
document.querySelector('form[action="{% url 'accounts:logout' %}"]').submit(); document.querySelector('form[action="{% url 'accounts:logout' %}"]').submit();

View File

@ -5,110 +5,133 @@
{% block title %}{% trans "Doctor Rating Import Jobs" %} - PX360{% endblock %} {% block title %}{% trans "Doctor Rating Import Jobs" %} - PX360{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <!-- Header -->
<!-- Header --> <div class="mb-6">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="mb-1"> <div class="flex items-center gap-2 text-sm text-slate mb-2">
<i class="bi bi-clock-history text-primary me-2"></i> <a href="{% url 'physicians:individual_ratings_list' %}" class="hover:text-navy">{% trans "Physician Ratings" %}</a>
{% trans "Import History" %} <i data-lucide="chevron-right" class="w-4 h-4"></i>
</h2> <span class="font-bold text-navy">{% trans "Import History" %}</span>
<p class="text-muted mb-0">{% trans "Track doctor rating import jobs" %}</p>
</div> </div>
<div> <h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<a href="{% url 'physicians:doctor_rating_import' %}" class="btn btn-primary"> <div class="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center">
<i class="bi bi-plus-circle me-2"></i>{% trans "New Import" %} <i data-lucide="clock" class="w-5 h-5 text-blue"></i>
</div>
{% trans "Import History" %}
</h1>
<p class="text-slate text-sm mt-1">{% trans "Track doctor rating import jobs" %}</p>
</div>
<a href="{% url 'physicians:doctor_rating_import' %}"
class="px-6 py-3 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2 shadow-lg shadow-navy/20">
<i data-lucide="plus-circle" class="w-5 h-5"></i>
{% trans "New Import" %}
</a> </a>
</div> </div>
</div> </div>
<!-- Jobs Table --> <!-- Jobs Table -->
<div class="card"> <div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="card-body p-0"> {% if jobs %}
<div class="table-responsive"> <div class="overflow-x-auto">
<table class="table table-hover mb-0"> <table class="w-full">
<thead class="table-light"> <thead class="bg-slate-50 border-b border-slate-200">
<tr> <tr>
<th>{% trans "Job Name" %}</th> <th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-4 px-6">{% trans "Job Name" %}</th>
<th>{% trans "Hospital" %}</th> <th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-4 px-6">{% trans "Hospital" %}</th>
<th>{% trans "Source" %}</th> <th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-4 px-6">{% trans "Source" %}</th>
<th>{% trans "Status" %}</th> <th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-4 px-6">{% trans "Status" %}</th>
<th>{% trans "Progress" %}</th> <th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-4 px-6">{% trans "Progress" %}</th>
<th>{% trans "Results" %}</th> <th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-4 px-6">{% trans "Results" %}</th>
<th>{% trans "Created" %}</th> <th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-4 px-6">{% trans "Created" %}</th>
<th>{% trans "Actions" %}</th> <th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-4 px-6">{% trans "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100">
{% for job in jobs %} {% for job in jobs %}
<tr> <tr class="hover:bg-light/30 transition">
<td> <td class="py-4 px-6">
<strong>{{ job.name|truncatechars:40 }}</strong> <p class="text-sm font-semibold text-navy">{{ job.name|truncatechars:40 }}</p>
</td> </td>
<td>{{ job.hospital.name }}</td> <td class="py-4 px-6">
<td> <p class="text-sm text-slate">{{ job.hospital.name }}</p>
</td>
<td class="py-4 px-6">
{% if job.source == 'his_api' %} {% if job.source == 'his_api' %}
<span class="badge bg-info">{% trans "HIS API" %}</span> <span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold bg-blue-100 text-blue-700">
<i data-lucide="database" class="w-3 h-3 mr-1"></i>
{% trans "HIS API" %}
</span>
{% else %} {% else %}
<span class="badge bg-secondary">{% trans "CSV Upload" %}</span> <span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold bg-slate-100 text-slate-700">
<i data-lucide="upload" class="w-3 h-3 mr-1"></i>
{% trans "CSV Upload" %}
</span>
{% endif %} {% endif %}
</td> </td>
<td> <td class="py-4 px-6">
{% if job.status == 'pending' %} {% if job.status == 'pending' %}
<span class="badge bg-secondary">{% trans "Pending" %}</span> <span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold bg-slate-100 text-slate-700">
<i data-lucide="clock" class="w-3 h-3 mr-1"></i>
{% trans "Pending" %}
</span>
{% elif job.status == 'processing' %} {% elif job.status == 'processing' %}
<span class="badge bg-primary"> <span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold bg-blue-100 text-blue-700">
<span class="spinner-border spinner-border-sm me-1"></span> <i data-lucide="loader-2" class="w-3 h-3 mr-1 animate-spin"></i>
{% trans "Processing" %} {% trans "Processing" %}
</span> </span>
{% elif job.status == 'completed' %} {% elif job.status == 'completed' %}
<span class="badge bg-success">{% trans "Completed" %}</span> <span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold bg-green-100 text-green-700">
<i data-lucide="check-circle" class="w-3 h-3 mr-1"></i>
{% trans "Completed" %}
</span>
{% elif job.status == 'failed' %} {% elif job.status == 'failed' %}
<span class="badge bg-danger">{% trans "Failed" %}</span> <span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold bg-red-100 text-red-700">
<i data-lucide="x-circle" class="w-3 h-3 mr-1"></i>
{% trans "Failed" %}
</span>
{% elif job.status == 'partial' %} {% elif job.status == 'partial' %}
<span class="badge bg-warning text-dark">{% trans "Partial" %}</span> <span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold bg-amber-100 text-amber-700">
<i data-lucide="alert-circle" class="w-3 h-3 mr-1"></i>
{% trans "Partial" %}
</span>
{% endif %} {% endif %}
</td> </td>
<td> <td class="py-4 px-6">
<div class="d-flex align-items-center"> <div class="flex items-center gap-2">
<div class="progress flex-grow-1 me-2" style="height: 8px; width: 80px;"> <div class="flex-1 h-2 bg-slate-100 rounded-full overflow-hidden" style="min-width: 80px;">
<div class="progress-bar {% if job.status == 'failed' %}bg-danger{% elif job.status == 'completed' %}bg-success{% else %}bg-primary{% endif %}" <div class="h-full {% if job.status == 'failed' %}bg-red-500{% elif job.status == 'completed' %}bg-green-500{% else %}bg-blue-500{% endif %} rounded-full transition-all"
role="progressbar" style="width: {{ job.progress_percentage }}%"></div>
style="width: {{ job.progress_percentage }}%">
</div> </div>
</div> <span class="text-xs font-semibold text-slate w-10">{{ job.progress_percentage }}%</span>
<small class="text-muted">{{ job.progress_percentage }}%</small>
</div> </div>
</td> </td>
<td> <td class="py-4 px-6">
{% if job.is_complete %} {% if job.is_complete %}
<small> <div class="flex items-center gap-3">
<span class="text-success">{{ job.success_count }} <i class="bi bi-check"></i></span> <span class="inline-flex items-center gap-1 text-xs font-semibold text-green-600">
<i data-lucide="check" class="w-3 h-3"></i>
{{ job.success_count }}
</span>
{% if job.failed_count > 0 %} {% if job.failed_count > 0 %}
<span class="text-danger ms-2">{{ job.failed_count }} <i class="bi bi-x"></i></span> <span class="inline-flex items-center gap-1 text-xs font-semibold text-red-600">
<i data-lucide="x" class="w-3 h-3"></i>
{{ job.failed_count }}
</span>
{% endif %} {% endif %}
</small> </div>
{% else %} {% else %}
<small class="text-muted">{{ job.processed_count }} / {{ job.total_records }}</small> <span class="text-xs text-slate">{{ job.processed_count }} / {{ job.total_records }}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td class="py-4 px-6">
<small class="text-muted">{{ job.created_at|date:"Y-m-d H:i" }}</small> <p class="text-sm text-slate">{{ job.created_at|date:"Y-m-d H:i" }}</p>
</td> </td>
<td> <td class="py-4 px-6">
<a href="{% url 'physicians:doctor_rating_job_status' job.id %}" <a href="{% url 'physicians:doctor_rating_job_status' job.id %}"
class="btn btn-sm btn-outline-primary"> class="inline-flex items-center gap-1.5 px-3 py-1.5 border border-blue-200 text-blue rounded-lg text-xs font-semibold hover:bg-blue-50 transition">
<i class="bi bi-eye"></i> <i data-lucide="eye" class="w-3.5 h-3.5"></i>
</a> {% trans "View" %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="8" class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 3rem; color: #ccc;"></i>
<p class="text-muted mt-3">{% trans "No import jobs found" %}</p>
<a href="{% url 'physicians:doctor_rating_import' %}" class="btn btn-primary mt-2">
<i class="bi bi-plus-circle me-2"></i>{% trans "Import Ratings" %}
</a> </a>
</td> </td>
</tr> </tr>
@ -116,7 +139,28 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %}
<!-- Empty State -->
<div class="py-16 text-center">
<div class="inline-flex items-center justify-center w-20 h-20 bg-slate-100 rounded-full mb-4">
<i data-lucide="inbox" class="w-10 h-10 text-slate-400"></i>
</div> </div>
<h3 class="text-lg font-bold text-navy mb-2">{% trans "No Import Jobs" %}</h3>
<p class="text-slate text-sm mb-6">{% trans "No import jobs found. Start by importing doctor ratings." %}</p>
<a href="{% url 'physicians:doctor_rating_import' %}"
class="inline-flex items-center gap-2 px-6 py-3 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition shadow-lg shadow-navy/20">
<i data-lucide="upload" class="w-5 h-5"></i>
{% trans "Import Ratings" %}
</a>
</div> </div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}

View File

@ -7,46 +7,69 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.progress-ring { .progress-ring {
width: 120px; width: 140px;
height: 120px; height: 140px;
} }
.progress-ring-circle { .progress-ring-circle {
transition: stroke-dashoffset 0.35s; transition: stroke-dashoffset 0.35s;
transform: rotate(-90deg); transform: rotate(-90deg);
transform-origin: 50% 50%; transform-origin: 50% 50%;
} }
.stat-card {
transition: all 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <!-- Header -->
<!-- Header --> <div class="mb-6">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="mb-1"> <div class="flex items-center gap-2 text-sm text-slate mb-2">
<i class="bi bi-activity text-primary me-2"></i> <a href="{% url 'physicians:doctor_rating_job_list' %}" class="hover:text-navy">{% trans "Import Jobs" %}</a>
{% trans "Import Job Status" %} <i data-lucide="chevron-right" class="w-4 h-4"></i>
</h2> <span class="font-bold text-navy">{% trans "Job Details" %}</span>
<p class="text-muted mb-0">{{ job.name }}</p>
</div> </div>
<div> <h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<a href="{% url 'physicians:doctor_rating_job_list' %}" class="btn btn-outline-secondary"> <div class="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center">
<i class="bi bi-list me-2"></i>{% trans "All Jobs" %} <i data-lucide="activity" class="w-5 h-5 text-blue"></i>
</div>
{% trans "Import Job Status" %}
</h1>
<p class="text-slate text-sm mt-1">{{ job.name }}</p>
</div>
<div class="flex items-center gap-3">
<a href="{% url 'physicians:doctor_rating_job_list' %}"
class="px-4 py-2 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-light transition flex items-center gap-2">
<i data-lucide="list" class="w-4 h-4"></i>
{% trans "All Jobs" %}
</a>
<a href="{% url 'physicians:doctor_rating_import' %}"
class="px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2">
<i data-lucide="upload" class="w-4 h-4"></i>
{% trans "Import More" %}
</a> </a>
</div> </div>
</div> </div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Status Card --> <!-- Status Card -->
<div class="row mb-4"> <div class="lg:col-span-1">
<div class="col-md-4"> <div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<div class="card text-center"> <div class="text-center">
<div class="card-body">
<!-- Progress Circle --> <!-- Progress Circle -->
<div class="position-relative d-inline-block mb-3"> <div class="inline-flex items-center justify-center mb-4">
<div class="relative">
<svg class="progress-ring" viewBox="0 0 120 120"> <svg class="progress-ring" viewBox="0 0 120 120">
<circle <circle
class="progress-ring-circle" class="progress-ring-circle"
stroke="#e9ecef" stroke="#f1f5f9"
stroke-width="8" stroke-width="8"
fill="transparent" fill="transparent"
r="52" r="52"
@ -55,205 +78,261 @@
/> />
<circle <circle
class="progress-ring-circle" class="progress-ring-circle"
stroke="{% if job.status == 'completed' %}#28a745{% elif job.status == 'failed' %}#dc3545{% elif job.status == 'partial' %}#ffc107{% else %}#007bbd{% endif %}" stroke="{% if job.status == 'completed' %}#22c55e{% elif job.status == 'failed' %}#ef4444{% elif job.status == 'partial' %}#f59e0b{% else %}#007bbd{% endif %}"
stroke-width="8" stroke-width="8"
fill="transparent" fill="transparent"
r="52" r="52"
cx="60" cx="60"
cy="60" cy="60"
stroke-dasharray="326.73" stroke-dasharray="326.73"
stroke-dashoffset="{{ 326.73|add:-progress|div:100|mul:326.73 }}" stroke-dashoffset="{{ stroke_dashoffset }}"
stroke-linecap="round" stroke-linecap="round"
/> />
</svg> </svg>
<div class="position-absolute top-50 start-50 translate-middle"> <div class="absolute inset-0 flex items-center justify-center">
<h3 class="mb-0">{{ progress }}%</h3> <div class="text-center">
<h3 class="text-2xl font-bold text-navy mb-0">{{ progress }}%</h3>
</div>
</div>
</div> </div>
</div> </div>
<h5 class="mb-2"> <!-- Status Badge -->
<div class="mb-4">
{% if job.status == 'pending' %} {% if job.status == 'pending' %}
<span class="badge bg-secondary">{% trans "Pending" %}</span> <span class="inline-flex items-center px-4 py-2 rounded-xl text-sm font-bold bg-slate-100 text-slate-700">
<i data-lucide="clock" class="w-4 h-4 mr-2"></i>
{% trans "Pending" %}
</span>
{% elif job.status == 'processing' %} {% elif job.status == 'processing' %}
<span class="badge bg-primary">{% trans "Processing" %}</span> <span class="inline-flex items-center px-4 py-2 rounded-xl text-sm font-bold bg-blue-100 text-blue-700">
<i data-lucide="loader-2" class="w-4 h-4 mr-2 animate-spin"></i>
{% trans "Processing" %}
</span>
{% elif job.status == 'completed' %} {% elif job.status == 'completed' %}
<span class="badge bg-success">{% trans "Completed" %}</span> <span class="inline-flex items-center px-4 py-2 rounded-xl text-sm font-bold bg-green-100 text-green-700">
<i data-lucide="check-circle" class="w-4 h-4 mr-2"></i>
{% trans "Completed" %}
</span>
{% elif job.status == 'failed' %} {% elif job.status == 'failed' %}
<span class="badge bg-danger">{% trans "Failed" %}</span> <span class="inline-flex items-center px-4 py-2 rounded-xl text-sm font-bold bg-red-100 text-red-700">
<i data-lucide="x-circle" class="w-4 h-4 mr-2"></i>
{% trans "Failed" %}
</span>
{% elif job.status == 'partial' %} {% elif job.status == 'partial' %}
<span class="badge bg-warning text-dark">{% trans "Partial Success" %}</span> <span class="inline-flex items-center px-4 py-2 rounded-xl text-sm font-bold bg-amber-100 text-amber-700">
<i data-lucide="alert-circle" class="w-4 h-4 mr-2"></i>
{% trans "Partial Success" %}
</span>
{% endif %} {% endif %}
</h5> </div>
{% if not is_complete %} {% if not is_complete %}
<div class="spinner-border text-primary" role="status"> <div class="flex items-center justify-center gap-2 text-blue">
<span class="visually-hidden">{% trans "Processing..." %}</span> <i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i>
<span class="text-sm font-semibold">{% trans "Processing..." %}</span>
</div> </div>
{% else %}
<p class="text-sm text-slate">
<i data-lucide="check" class="w-4 h-4 inline mr-1"></i>
{% trans "Job completed" %}
</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-8"> <!-- Job Details -->
<div class="card h-100"> <div class="lg:col-span-2">
<div class="card-header bg-white"> <div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<h5 class="mb-0">{% trans "Job Details" %}</h5> <div class="px-6 py-4 border-b border-slate-100 bg-slate-50">
<h3 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="file-text" class="w-5 h-5 text-blue"></i>
{% trans "Job Details" %}
</h3>
</div> </div>
<div class="card-body"> <div class="p-6">
<div class="row"> <div class="grid grid-cols-2 gap-6">
<div class="col-md-6"> <div class="space-y-4">
<table class="table table-borderless table-sm"> <div>
<tr> <p class="text-xs font-bold text-slate uppercase mb-1">{% trans "Hospital" %}</p>
<td class="text-muted">{% trans "Hospital:" %}</td> <p class="text-sm font-semibold text-navy">{{ job.hospital.name }}</p>
<td><strong>{{ job.hospital.name }}</strong></td> </div>
</tr> <div>
<tr> <p class="text-xs font-bold text-slate uppercase mb-1">{% trans "Source" %}</p>
<td class="text-muted">{% trans "Source:" %}</td> <p class="text-sm text-slate">{{ job.get_source_display }}</p>
<td>{{ job.get_source_display }}</td> </div>
</tr> <div>
<tr> <p class="text-xs font-bold text-slate uppercase mb-1">{% trans "Total Records" %}</p>
<td class="text-muted">{% trans "Total Records:" %}</td> <p class="text-lg font-bold text-navy">{{ job.total_records }}</p>
<td><strong>{{ job.total_records }}</strong></td> </div>
</tr> <div>
<tr> <p class="text-xs font-bold text-slate uppercase mb-1">{% trans "Created By" %}</p>
<td class="text-muted">{% trans "Created By:" %}</td> <p class="text-sm text-slate">{{ job.created_by.get_full_name|default:job.created_by.email }}</p>
<td>{{ job.created_by.get_full_name|default:job.created_by.email }}</td> </div>
</tr> </div>
</table> <div class="space-y-4">
<div>
<p class="text-xs font-bold text-slate uppercase mb-1">{% trans "Created" %}</p>
<p class="text-sm text-slate">{{ job.created_at|date:"Y-m-d H:i" }}</p>
</div>
<div>
<p class="text-xs font-bold text-slate uppercase mb-1">{% trans "Started" %}</p>
<p class="text-sm text-slate">{{ job.started_at|date:"Y-m-d H:i"|default:"-" }}</p>
</div>
<div>
<p class="text-xs font-bold text-slate uppercase mb-1">{% trans "Completed" %}</p>
<p class="text-sm text-slate">{{ job.completed_at|date:"Y-m-d H:i"|default:"-" }}</p>
</div> </div>
<div class="col-md-6">
<table class="table table-borderless table-sm">
<tr>
<td class="text-muted">{% trans "Created:" %}</td>
<td>{{ job.created_at|date:"Y-m-d H:i" }}</td>
</tr>
<tr>
<td class="text-muted">{% trans "Started:" %}</td>
<td>{{ job.started_at|date:"Y-m-d H:i"|default:"-" }}</td>
</tr>
<tr>
<td class="text-muted">{% trans "Completed:" %}</td>
<td>{{ job.completed_at|date:"Y-m-d H:i"|default:"-" }}</td>
</tr>
{% if job.duration_seconds %} {% if job.duration_seconds %}
<div>
<p class="text-xs font-bold text-slate uppercase mb-1">{% trans "Duration" %}</p>
<p class="text-sm font-semibold text-blue">{{ job.duration_seconds }} {% trans "seconds" %}</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Results Summary -->
{% if is_complete %}
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mt-6">
<div class="stat-card bg-white rounded-2xl p-6 shadow-sm border border-l-4 border-green-500">
<div class="flex items-center justify-between mb-3">
<div class="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center">
<i data-lucide="check-circle" class="w-6 h-6 text-green-600"></i>
</div>
<span class="text-xs font-bold text-slate uppercase">{% trans "Successful" %}</span>
</div>
<p class="text-3xl font-bold text-green-600">{{ job.success_count }}</p>
</div>
<div class="stat-card bg-white rounded-2xl p-6 shadow-sm border border-l-4 border-red-500">
<div class="flex items-center justify-between mb-3">
<div class="w-12 h-12 bg-red-50 rounded-xl flex items-center justify-center">
<i data-lucide="x-circle" class="w-6 h-6 text-red-600"></i>
</div>
<span class="text-xs font-bold text-slate uppercase">{% trans "Failed" %}</span>
</div>
<p class="text-3xl font-bold text-red-600">{{ job.failed_count }}</p>
</div>
<div class="stat-card bg-white rounded-2xl p-6 shadow-sm border border-l-4 border-amber-500">
<div class="flex items-center justify-between mb-3">
<div class="w-12 h-12 bg-amber-50 rounded-xl flex items-center justify-center">
<i data-lucide="skip-forward" class="w-6 h-6 text-amber-600"></i>
</div>
<span class="text-xs font-bold text-slate uppercase">{% trans "Skipped" %}</span>
</div>
<p class="text-3xl font-bold text-amber-600">{{ job.skipped_count }}</p>
</div>
<div class="stat-card bg-white rounded-2xl p-6 shadow-sm border border-l-4 border-blue-500">
<div class="flex items-center justify-between mb-3">
<div class="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center">
<i data-lucide="layers" class="w-6 h-6 text-blue-600"></i>
</div>
<span class="text-xs font-bold text-slate uppercase">{% trans "Processed" %}</span>
</div>
<p class="text-3xl font-bold text-blue-600">{{ job.processed_count }}</p>
</div>
</div>
{% endif %}
<!-- Error Message -->
{% if job.error_message %}
<div class="bg-red-50 border border-red-200 rounded-2xl p-6 mt-6">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-red-100 rounded-xl flex items-center justify-center flex-shrink-0">
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-600"></i>
</div>
<div class="flex-1">
<h4 class="font-bold text-red-800 mb-2 flex items-center gap-2">
<i data-lucide="circle-alert" class="w-4 h-4"></i>
{% trans "Error" %}
</h4>
<p class="text-sm text-red-700">{{ job.error_message }}</p>
</div>
</div>
</div>
{% endif %}
<!-- Error Details -->
{% if results and results.errors %}
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 mt-6 overflow-hidden">
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50">
<h3 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="list" class="w-5 h-5 text-blue"></i>
{% trans "Error Details" %}
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-slate-50 border-b border-slate-200">
<tr> <tr>
<td class="text-muted">{% trans "Duration:" %}</td> <th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-3 px-4">{% trans "Row" %}</th>
<td>{{ job.duration_seconds }} {% trans "seconds" %}</td> <th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-3 px-4">{% trans "Error" %}</th>
</tr> <th class="text-left text-xs font-bold text-slate uppercase tracking-wider py-3 px-4">{% trans "Data" %}</th>
{% endif %}
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Results Summary -->
{% if is_complete %}
<div class="row mb-4">
<div class="col-md-3">
<div class="card border-left-success">
<div class="card-body text-center">
<h2 class="text-success mb-1">{{ job.success_count }}</h2>
<span class="text-muted">{% trans "Successful" %}</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-left-danger">
<div class="card-body text-center">
<h2 class="text-danger mb-1">{{ job.failed_count }}</h2>
<span class="text-muted">{% trans "Failed" %}</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-left-warning">
<div class="card-body text-center">
<h2 class="text-warning mb-1">{{ job.skipped_count }}</h2>
<span class="text-muted">{% trans "Skipped" %}</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-left-info">
<div class="card-body text-center">
<h2 class="text-info mb-1">{{ job.processed_count }}</h2>
<span class="text-muted">{% trans "Processed" %}</span>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Error Message -->
{% if job.error_message %}
<div class="alert alert-danger mb-4">
<h6 class="alert-heading">
<i class="bi bi-exclamation-triangle me-2"></i>{% trans "Error" %}
</h6>
<p class="mb-0">{{ job.error_message }}</p>
</div>
{% endif %}
<!-- Detailed Results -->
{% if results and results.errors %}
<div class="card">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="bi bi-list-ul me-2"></i>{% trans "Error Details" %}
</h5>
</div>
<div class="card-body">
<div class="table-responsive" style="max-height: 400px;">
<table class="table table-sm">
<thead>
<tr>
<th>{% trans "Row" %}</th>
<th>{% trans "Error" %}</th>
<th>{% trans "Data" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100">
{% for error in results.errors|slice:":50" %} {% for error in results.errors|slice:":50" %}
<tr> <tr class="hover:bg-light/30 transition">
<td>{{ error.row }}</td> <td class="py-3 px-4">
<td class="text-danger">{{ error.message }}</td> <span class="text-sm font-mono text-slate">{{ error.row }}</span>
<td><code class="small">{{ error.data|truncatechars:50 }}</code></td> </td>
<td class="py-3 px-4">
<span class="text-sm text-red-600">{{ error.message }}</span>
</td>
<td class="py-3 px-4">
<code class="text-xs bg-slate-100 px-2 py-1 rounded">{{ error.data|truncatechars:50 }}</code>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% if results.errors|length > 50 %} {% if results.errors|length > 50 %}
<p class="text-muted small mt-2"> <div class="px-6 py-4 bg-slate-50 border-t border-slate-200">
{% trans "Showing first 50 errors of" %} {{ results.errors|length }} <p class="text-sm text-slate">
{% trans "Showing first 50 errors of" %} <strong>{{ results.errors|length }}</strong>
</p> </p>
{% endif %}
</div>
</div> </div>
{% endif %} {% endif %}
<!-- Navigation -->
<div class="mt-4">
<a href="{% url 'physicians:doctor_rating_import' %}" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>{% trans "Import More Ratings" %}
</a>
<a href="{% url 'physicians:individual_ratings_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-list me-2"></i>{% trans "View Imported Ratings" %}
</a>
</div>
</div> </div>
{% endif %}
<!-- Action Buttons -->
{% if is_complete %}
<div class="flex items-center gap-4 mt-6">
<a href="{% url 'physicians:individual_ratings_list' %}"
class="px-6 py-3 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2 shadow-lg shadow-navy/20">
<i data-lucide="list" class="w-5 h-5"></i>
{% trans "View Imported Ratings" %}
</a>
<a href="{% url 'physicians:doctor_rating_import' %}"
class="px-6 py-3 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-light transition flex items-center gap-2">
<i data-lucide="upload" class="w-5 h-5"></i>
{% trans "Import More Ratings" %}
</a>
</div>
{% endif %}
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
{% if not is_complete %}
<script> <script>
// Auto-refresh page every 3 seconds while job is processing document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() { lucide.createIcons();
});
{% if not is_complete %}
// Auto-refresh page every 3 seconds while job is processing
setTimeout(function() {
window.location.reload(); window.location.reload();
}, 3000); }, 3000);
</script>
{% endif %} {% endif %}
</script>
{% endblock %} {% endblock %}

View File

@ -4,110 +4,141 @@
{% block title %}{% trans "Delete Source" %}{% endblock %} {% block title %}{% trans "Delete Source" %}{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="p-6">
<!-- Page Header --> <!-- Breadcrumb -->
<div class="d-flex justify-content-between align-items-center mb-4"> <nav class="mb-4">
<div> <ol class="flex items-center gap-2 text-sm text-slate">
<nav aria-label="breadcrumb"> <li><a href="{% url 'px_sources:source_list' %}" class="text-blue hover:text-navy">{% trans "PX Sources" %}</a></li>
<ol class="breadcrumb mb-2"> <li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li class="breadcrumb-item"> <li><a href="{% url 'px_sources:source_detail' source.pk %}" class="text-blue hover:text-navy">{{ source.name_en }}</a></li>
<a href="{% url 'px_sources:source_list' %}">{% trans "PX Sources" %}</a> <li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
</li> <li class="text-navy font-semibold">{% trans "Delete" %}</li>
<li class="breadcrumb-item">
<a href="{% url 'px_sources:source_detail' source.pk %}">{{ source.name_en }}</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
{% trans "Delete" %}
</li>
</ol> </ol>
</nav> </nav>
<h2 class="mb-1">
<i class="bi bi-exclamation-triangle-fill text-danger me-2"></i> <!-- Page Header -->
<div class="flex flex-wrap justify-between items-center gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<i data-lucide="alert-triangle" class="w-8 h-8 text-red-500"></i>
{% trans "Delete Source" %} {% trans "Delete Source" %}
</h2> </h1>
<p class="text-muted mb-0">{{ source.name_en }}</p> <p class="text-slate mt-1">{{ source.name_en }}</p>
</div> </div>
<div> <div>
<a href="{% url 'px_sources:source_detail' source.pk %}" class="btn btn-outline-secondary"> <a href="{% url 'px_sources:source_detail' source.pk %}"
<i class="bi bi-x-circle me-1"></i> {% trans "Cancel" %} class="inline-flex items-center gap-2 px-4 py-2 border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition">
<i data-lucide="x" class="w-4 h-4"></i> {% trans "Cancel" %}
</a> </a>
</div> </div>
</div> </div>
<!-- Delete Confirmation Card --> <!-- Delete Confirmation Card -->
<div class="card"> <div class="bg-white rounded-xl shadow-sm border border-slate-200">
<div class="card-header"> <div class="p-4 border-b border-slate-200 bg-slate-50/50 rounded-t-xl">
<h5 class="card-title mb-0"> <h2 class="text-lg font-semibold text-navy flex items-center gap-2">
<i class="bi bi-exclamation-circle me-2"></i>{% trans "Confirm Deletion" %} <i data-lucide="alert-circle" class="w-5 h-5 text-slate"></i>
</h5> {% trans "Confirm Deletion" %}
</h2>
</div>
<div class="p-6">
<!-- Warning Alert -->
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<div class="flex items-start gap-3">
<i data-lucide="alert-circle" class="w-5 h-5 text-amber-600 mt-0.5"></i>
<div>
<h4 class="font-semibold text-amber-800 mb-1">{% trans "Warning" %}</h4>
<p class="text-amber-700 text-sm">{% trans "Are you sure you want to delete this source? This action cannot be undone." %}</p>
</div>
</div> </div>
<div class="card-body">
<div class="alert alert-warning">
<h4><i class="fas fa-exclamation-circle"></i> {% trans "Warning" %}</h4>
<p>{% trans "Are you sure you want to delete this source? This action cannot be undone." %}</p>
</div> </div>
<div class="table-responsive mb-4"> <!-- Source Info Table -->
<table class="table table-bordered"> <div class="overflow-x-auto mb-6">
<table class="w-full border border-slate-200 rounded-lg">
<tbody class="divide-y divide-slate-200">
<tr> <tr>
<th width="30%">{% trans "Name (English)" %}</th> <th class="py-3 px-4 text-left text-sm font-semibold text-navy bg-slate-50 w-1/3">{% trans "Name (English)" %}</th>
<td><strong>{{ source.name_en }}</strong></td> <td class="py-3 px-4 text-navy font-semibold">{{ source.name_en }}</td>
</tr> </tr>
<tr> <tr>
<th>{% trans "Name (Arabic)" %}</th> <th class="py-3 px-4 text-left text-sm font-semibold text-navy bg-slate-50">{% trans "Name (Arabic)" %}</th>
<td dir="rtl">{{ source.name_ar|default:"-" }}</td> <td class="py-3 px-4 text-navy" dir="rtl">{{ source.name_ar|default:"-" }}</td>
</tr> </tr>
<tr> <tr>
<th>{% trans "Description" %}</th> <th class="py-3 px-4 text-left text-sm font-semibold text-navy bg-slate-50">{% trans "Description" %}</th>
<td>{{ source.description|default:"-"|truncatewords:20 }}</td> <td class="py-3 px-4 text-slate">{{ source.description|default:"-"|truncatewords:20 }}</td>
</tr> </tr>
<tr> <tr>
<th>{% trans "Status" %}</th> <th class="py-3 px-4 text-left text-sm font-semibold text-navy bg-slate-50">{% trans "Status" %}</th>
<td> <td class="py-3 px-4">
{% if source.is_active %} {% if source.is_active %}
<span class="badge bg-success">{% trans "Active" %}</span> <span class="inline-flex items-center gap-1 px-2.5 py-1 bg-green-100 text-green-700 rounded-full text-xs font-semibold">
<i data-lucide="check-circle" class="w-3 h-3"></i>
{% trans "Active" %}
</span>
{% else %} {% else %}
<span class="badge bg-secondary">{% trans "Inactive" %}</span> <span class="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-100 text-slate-600 rounded-full text-xs font-semibold">
<i data-lucide="x-circle" class="w-3 h-3"></i>
{% trans "Inactive" %}
</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr> <tr>
<th>{% trans "Usage Count" %}</th> <th class="py-3 px-4 text-left text-sm font-semibold text-navy bg-slate-50">{% trans "Usage Count" %}</th>
<td> <td class="py-3 px-4">
{% if usage_count > 0 %} {% if usage_count > 0 %}
<span class="badge bg-danger">{{ usage_count }}</span> <span class="inline-flex items-center gap-1 px-2.5 py-1 bg-red-100 text-red-700 rounded-full text-xs font-semibold">{{ usage_count }}</span>
{% else %} {% else %}
<span class="badge bg-success">0</span> <span class="inline-flex items-center gap-1 px-2.5 py-1 bg-green-100 text-green-700 rounded-full text-xs font-semibold">0</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
</tbody>
</table> </table>
</div> </div>
{% if usage_count > 0 %} {% if usage_count > 0 %}
<div class="alert alert-danger"> <!-- Cannot Delete Alert -->
<h5><i class="fas fa-exclamation-triangle"></i> {% trans "Cannot Delete" %}</h5> <div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p>{% trans "This source has been used in {{ usage_count }} record(s). You cannot delete sources that have usage records." %}</p> <div class="flex items-start gap-3">
<p><strong>{% trans "Recommended action:" %}</strong> {% trans "Deactivate this source instead by editing it and unchecking the 'Active' checkbox." %}</p> <i data-lucide="alert-triangle" class="w-5 h-5 text-red-600 mt-0.5"></i>
<div>
<h5 class="font-semibold text-red-800 mb-1">{% trans "Cannot Delete" %}</h5>
<p class="text-red-700 text-sm mb-2">{% trans "This source has been used in {{ usage_count }} record(s). You cannot delete sources that have usage records." %}</p>
<p class="text-red-700 text-sm"><strong>{% trans "Recommended action:" %}</strong> {% trans "Deactivate this source instead by editing it and unchecking the 'Active' checkbox." %}</p>
</div>
</div>
</div> </div>
{% endif %} {% endif %}
<form method="post"> <!-- Action Buttons -->
<form method="post" class="flex gap-3">
{% csrf_token %} {% csrf_token %}
{% if usage_count == 0 %} {% if usage_count == 0 %}
<button type="submit" class="btn btn-danger"> <button type="submit"
<i class="fas fa-trash"></i> {% trans "Yes, Delete" %} class="inline-flex items-center gap-2 px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition shadow-lg shadow-red-500/20">
<i data-lucide="trash-2" class="w-4 h-4"></i> {% trans "Yes, Delete" %}
</button> </button>
{% else %} {% else %}
<button type="button" class="btn btn-danger" disabled> <button type="button" disabled
<i class="fas fa-trash"></i> {% trans "Cannot Delete" %} class="inline-flex items-center gap-2 px-6 py-2 bg-red-300 text-white rounded-lg cursor-not-allowed">
<i data-lucide="trash-2" class="w-4 h-4"></i> {% trans "Cannot Delete" %}
</button> </button>
{% endif %} {% endif %}
<a href="{% url 'px_sources:source_detail' source.pk %}" class="btn btn-secondary"> <a href="{% url 'px_sources:source_detail' source.pk %}"
class="inline-flex items-center gap-2 px-6 py-2 border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition">
{% trans "Cancel" %} {% trans "Cancel" %}
</a> </a>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %} {% endblock %}

View File

@ -1,67 +1,153 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% load i18n %} {% load i18n %}
{% block title %}{{ source.name_en }} - {% trans "PX Source" %}{% endblock %} {% block title %}{{ source.name_en }} - {% trans "PX Source" %} - PX360{% endblock %}
{% block extra_css %}
<style>
:root {
--hh-navy: #005696;
--hh-blue: #007bbd;
--hh-light: #eef6fb;
--hh-slate: #64748b;
}
.info-card {
background: white;
border-radius: 1rem;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
.card-header {
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
padding: 1.25rem 1.75rem;
border-bottom: 1px solid #bae6fd;
border-radius: 1rem 1rem 0 0;
}
.info-table th {
padding: 1rem;
text-align: left;
font-size: 0.875rem;
font-weight: 600;
color: var(--hh-navy);
width: 35%;
border-bottom: 1px solid #f1f5f9;
}
.info-table td {
padding: 1rem;
color: #475569;
border-bottom: 1px solid #f1f5f9;
}
.info-table tr:last-child th,
.info-table tr:last-child td {
border-bottom: none;
}
.stat-card {
background: linear-gradient(135deg, var(--hh-navy), var(--hh-blue));
color: white;
padding: 1.5rem;
border-radius: 1rem;
text-align: center;
}
.stat-value {
font-size: 2.5rem;
font-weight: 800;
}
.stat-label {
font-size: 0.875rem;
opacity: 0.9;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="px-4 py-6">
<!-- Page Header --> <!-- Breadcrumb -->
<div class="d-flex justify-content-between align-items-center mb-4"> <nav class="mb-4 animate-in">
<div> <ol class="flex items-center gap-2 text-sm text-slate">
<nav aria-label="breadcrumb"> <li><a href="{% url 'px_sources:source_list' %}" class="text-blue hover:text-navy font-medium">{% trans "PX Sources" %}</a></li>
<ol class="breadcrumb mb-2"> <li><i data-lucide="chevron-right" class="w-4 h-4 text-slate"></i></li>
<li class="breadcrumb-item"> <li class="text-navy font-semibold">{{ source.name_en }}</li>
<a href="{% url 'px_sources:source_list' %}">{% trans "PX Sources" %}</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
{{ source.name_en }}
</li>
</ol> </ol>
</nav> </nav>
<h2 class="mb-1">
<i class="bi bi-lightning-fill text-warning me-2"></i> <!-- Page Header -->
{{ source.name_en }} <div class="flex flex-wrap justify-between items-start gap-4 mb-6 animate-in">
</h2>
<p class="text-muted mb-0">
{% if source.is_active %}
<span class="badge bg-success">{% trans "Active" %}</span>
{% else %}
<span class="badge bg-secondary">{% trans "Inactive" %}</span>
{% endif %}
</p>
</div>
<div> <div>
<a href="{% url 'px_sources:source_list' %}" class="btn btn-outline-secondary me-2"> <h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<i class="bi bi-arrow-left me-1"></i> {% trans "Back to List" %} <div class="w-10 h-10 bg-blue/10 rounded-xl flex items-center justify-center">
<i data-lucide="radio" class="w-6 h-6 text-blue"></i>
</div>
{{ source.name_en }}
</h1>
<div class="mt-2 flex items-center gap-3">
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 {% if source.is_active %}bg-green-100 text-green-700{% else %}bg-slate-100 text-slate-600{% endif %} rounded-full text-xs font-bold">
<i data-lucide="{% if source.is_active %}check-circle{% else %}x-circle{% endif %}" class="w-3.5 h-3.5"></i>
{% if source.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}
</span>
<span class="font-mono text-xs bg-slate-100 px-2 py-1 rounded">{{ source.code }}</span>
<span class="source-type-badge inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-100 text-blue-700 rounded-full text-xs font-bold">
<i data-lucide="tag" class="w-3.5 h-3.5"></i>
{{ source.get_source_type_display }}
</span>
</div>
</div>
<div class="flex gap-2">
<a href="{% url 'px_sources:source_list' %}"
class="inline-flex items-center gap-2 px-4 py-2 border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back" %}
</a> </a>
{% if request.user.is_px_admin or request.user.is_hospital_admin %}
<a href="{% url 'px_sources:source_edit' source.pk %}"
class="inline-flex items-center gap-2 px-4 py-2 bg-navy text-white rounded-lg hover:bg-blue transition shadow-lg shadow-navy/20">
<i data-lucide="edit" class="w-4 h-4"></i>
{% trans "Edit" %}
</a>
{% endif %}
{% if request.user.is_px_admin %} {% if request.user.is_px_admin %}
<a href="{% url 'px_sources:source_edit' source.pk %}" class="btn btn-primary me-2"> <a href="{% url 'px_sources:source_delete' source.pk %}"
<i class="bi bi-pencil me-1"></i> {% trans "Edit" %} class="inline-flex items-center gap-2 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition">
</a> <i data-lucide="trash-2" class="w-4 h-4"></i>
<a href="{% url 'px_sources:source_delete' source.pk %}" class="btn btn-danger"> {% trans "Delete" %}
<i class="bi bi-trash me-1"></i> {% trans "Delete" %}
</a> </a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Detail Cards --> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="row"> <!-- Source Details -->
<div class="col-12"> <div class="lg:col-span-2 space-y-6">
<div class="card"> <!-- Source Information -->
<div class="info-card animate-in">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0"> <h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i class="bi bi-info-circle me-2"></i>{% trans "Source Details" %} <i data-lucide="info" class="w-5 h-5"></i>
</h5> {% trans "Source Information" %}
</h2>
</div> </div>
<div class="card-body"> <div class="p-0">
<div class="row"> <table class="w-full info-table">
<div class="col-md-8"> <tbody>
<h5>{% trans "Source Details" %}</h5>
<table class="table table-borderless">
<tr> <tr>
<th width="30%">{% trans "Name (English)" %}</th> <th>{% trans "Name (English)" %}</th>
<td><strong>{{ source.name_en }}</strong></td> <td class="font-semibold">{{ source.name_en }}</td>
</tr> </tr>
<tr> <tr>
<th>{% trans "Name (Arabic)" %}</th> <th>{% trans "Name (Arabic)" %}</th>
@ -71,167 +157,207 @@
<th>{% trans "Description" %}</th> <th>{% trans "Description" %}</th>
<td>{{ source.description|default:"-"|linebreaks }}</td> <td>{{ source.description|default:"-"|linebreaks }}</td>
</tr> </tr>
{% if source.contact_email or source.contact_phone %}
<tr> <tr>
<th>{% trans "Status" %}</th> <th>{% trans "Contact" %}</th>
<td> <td>
{% if source.is_active %} {% if source.contact_email %}
<span class="badge bg-success">{% trans "Active" %}</span> <div class="flex items-center gap-2">
{% else %} <i data-lucide="mail" class="w-4 h-4 text-slate"></i>
<span class="badge bg-secondary">{% trans "Inactive" %}</span> <a href="mailto:{{ source.contact_email }}" class="text-blue hover:underline">{{ source.contact_email }}</a>
</div>
{% endif %}
{% if source.contact_phone %}
<div class="flex items-center gap-2 mt-1">
<i data-lucide="phone" class="w-4 h-4 text-slate"></i>
<a href="tel:{{ source.contact_phone }}" class="text-blue hover:underline">{{ source.contact_phone }}</a>
</div>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% endif %}
<tr> <tr>
<th>{% trans "Created" %}</th> <th>{% trans "Created" %}</th>
<td>{{ source.created_at|date:"Y-m-d H:i" }}</td> <td class="text-sm">{{ source.created_at|date:"Y-m-d H:i" }}</td>
</tr> </tr>
<tr> <tr>
<th>{% trans "Last Updated" %}</th> <th>{% trans "Last Updated" %}</th>
<td>{{ source.updated_at|date:"Y-m-d H:i" }}</td> <td class="text-sm">{{ source.updated_at|date:"Y-m-d H:i" }}</td>
</tr> </tr>
</tbody>
</table> </table>
</div> </div>
</div>
<div class="col-md-4"> <!-- Usage Statistics -->
<h5>{% trans "Quick Actions" %}</h5> <div class="info-card animate-in">
<div class="list-group"> <div class="card-header">
{% if request.user.is_px_admin %} <h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<a href="{% url 'px_sources:source_edit' source.pk %}" class="list-group-item list-group-item-action"> <i data-lucide="circle-help" class="w-5 h-5"></i>
<i class="fas fa-edit"></i> {% trans "Edit Source" %} {% trans "Usage Statistics (Last 30 Days)" %}
</a> </h2>
<a href="{% url 'px_sources:source_delete' source.pk %}" class="list-group-item list-group-item-action list-group-item-danger"> </div>
<i class="fas fa-trash"></i> {% trans "Delete Source" %} <div class="p-6">
</a> <div class="grid grid-cols-3 gap-4">
{% endif %} <div class="stat-card">
<div class="stat-value">{{ usage_stats.total_usage }}</div>
<div class="stat-label">{% trans "Total Usage" %}</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #10b981, #34d399);">
<div class="stat-value">{{ usage_stats.complaints }}</div>
<div class="stat-label">{% trans "Complaints" %}</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #f59e0b, #fbbf24);">
<div class="stat-value">{{ usage_stats.inquiries }}</div>
<div class="stat-label">{% trans "Inquiries" %}</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<hr> <!-- Usage Records -->
<div class="info-card animate-in">
<h5>{% trans "Recent Usage" %} ({{ usage_records|length }})</h5> <div class="card-header">
<h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i data-lucide="activity" class="w-5 h-5"></i>
{% trans "Recent Activity" %} ({{ usage_records|length }})
</h2>
</div>
<div class="p-0">
{% if usage_records %} {% if usage_records %}
<div class="table-responsive"> <div class="overflow-x-auto">
<table class="table table-striped table-sm"> <table class="w-full">
<thead> <thead>
<tr> <tr class="border-b-2 border-slate-200">
<th>{% trans "Date" %}</th> <th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Date" %}</th>
<th>{% trans "Content Type" %}</th> <th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Type" %}</th>
<th>{% trans "Object ID" %}</th> <th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Reference" %}</th>
<th>{% trans "Hospital" %}</th> <th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Hospital" %}</th>
<th>{% trans "User" %}</th> <th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "User" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100">
{% for record in usage_records %} {% for record in usage_records %}
<tr> <tr class="hover:bg-slate-50 transition">
<td>{{ record.created_at|date:"Y-m-d H:i" }}</td> <td class="py-3 px-4 text-sm text-slate">{{ record.created_at|date:"Y-m-d H:i" }}</td>
<td><code>{{ record.content_type.model }}</code></td> <td class="py-3 px-4">
<td>{{ record.object_id|truncatechars:20 }}</td> <span class="inline-flex items-center gap-1.5 px-2.5 py-1 {% if record.content_type.model == 'complaint' %}bg-red-100 text-red-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded-full text-xs font-bold">
<td>{{ record.hospital.name_en|default:"-" }}</td> <i data-lucide="{% if record.content_type.model == 'complaint' %}file-text{% else %}help-circle{% endif %}" class="w-3 h-3"></i>
<td>{{ record.user.get_full_name|default:"-" }}</td> {{ record.content_type.model|title }}
</span>
</td>
<td class="py-3 px-4 text-sm font-mono text-slate">{{ record.object_id|truncatechars:12 }}</td>
<td class="py-3 px-4 text-sm text-slate">{{ record.hospital.name|default:"-" }}</td>
<td class="py-3 px-4 text-sm text-navy">{{ record.user.get_full_name|default:"-" }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="text-muted">{% trans "No usage records found for this source." %}</p> <div class="text-center py-12">
{% endif %} <div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="activity" class="w-8 h-8 text-slate-400"></i>
</div> </div>
<p class="text-slate font-medium">{% trans "No usage records found" %}</p>
<p class="text-slate text-sm mt-1">{% trans "Activity will appear here once feedback is submitted" %}</p>
</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- Source Users Section (PX Admin only) --> <!-- Sidebar -->
{% comment %} {% if request.user.is_px_admin %} {% endcomment %} <div class="space-y-6">
<div class="row mt-4"> <!-- Quick Stats -->
<div class="col-12"> <div class="info-card animate-in">
<div class="card"> <div class="card-header">
<div class="card-header d-flex justify-content-between align-items-center"> <h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<h5 class="card-title mb-0"> <i data-lucide="trending-up" class="w-5 h-5"></i>
<i class="bi bi-people-fill me-2"></i> {% trans "Quick Stats" %}
{% trans "Source Users" %} ({{ source_users|length }}) </h2>
</h5> </div>
<a href="{% url 'px_sources:source_user_create' source.pk %}" class="btn btn-sm btn-primary"> <div class="p-6 space-y-4">
<i class="bi bi-plus-lg me-1"></i>{% trans "Add Source User" %} <div class="flex items-center justify-between">
</a> <span class="text-slate text-sm">{% trans "Total Complaints" %}</span>
<span class="font-bold text-navy text-lg">{{ source.total_complaints }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-slate text-sm">{% trans "Total Inquiries" %}</span>
<span class="font-bold text-navy text-lg">{{ source.total_inquiries }}</span>
</div>
<div class="pt-4 border-t border-slate-200">
<div class="flex items-center justify-between mb-2">
<span class="text-slate text-sm">{% trans "Source Users" %}</span>
<span class="font-bold text-navy text-lg">{{ source_users.count }}</span>
</div> </div>
<div class="card-body">
{% if source_users %} {% if source_users %}
<div class="table-responsive"> <div class="space-y-2 mt-3">
<table class="table table-hover"> {% for su in source_users|slice:":5" %}
<thead class="table-light"> <div class="flex items-center gap-2 text-sm">
<tr> <div class="w-8 h-8 rounded-full bg-blue/10 flex items-center justify-center">
<th>{% trans "User" %}</th> <i data-lucide="user" class="w-4 h-4 text-blue"></i>
<th>{% trans "Email" %}</th> </div>
<th>{% trans "Status" %}</th> <div class="flex-1">
<th>{% trans "Permissions" %}</th> <p class="font-medium text-navy">{{ su.user.get_full_name }}</p>
<th>{% trans "Created" %}</th> <p class="text-xs text-slate">{{ su.user.email }}</p>
<th>{% trans "Actions" %}</th> </div>
</tr>
</thead>
<tbody>
{% for su in source_users %}
<tr>
<td>
<strong>{{ su.user.get_full_name|default:"-" }}</strong>
</td>
<td>{{ su.user.email }}</td>
<td>
{% if su.is_active %} {% if su.is_active %}
<span class="badge bg-success">{% trans "Active" %}</span> <i data-lucide="check-circle" class="w-4 h-4 text-green-600"></i>
{% else %} {% else %}
<span class="badge bg-secondary">{% trans "Inactive" %}</span> <i data-lucide="x-circle" class="w-4 h-4 text-slate-400"></i>
{% endif %} {% endif %}
</td>
<td>
{% if su.can_create_complaints %}
<span class="badge bg-primary me-1">{% trans "Complaints" %}</span>
{% endif %}
{% if su.can_create_inquiries %}
<span class="badge bg-info">{% trans "Inquiries" %}</span>
{% endif %}
{% if not su.can_create_complaints and not su.can_create_inquiries %}
<span class="text-muted">{% trans "None" %}</span>
{% endif %}
</td>
<td>{{ su.created_at|date:"Y-m-d" }}</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{% url 'px_sources:source_user_edit' source.pk su.pk %}"
class="btn btn-outline-primary"
title="{% trans 'Edit' %}">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'px_sources:source_user_delete' source.pk su.pk %}"
class="btn btn-outline-danger"
title="{% trans 'Delete' %}">
<i class="bi bi-trash"></i>
</a>
</div> </div>
</td>
</tr>
{% endfor %} {% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-people fs-1 text-muted mb-3"></i>
<p class="text-muted mb-0">
{% trans "No source users assigned yet." %}
<a href="{% url 'px_sources:source_user_create' source.pk %}" class="text-primary">
{% trans "Add a source user" %}
</a>
{% trans "to get started." %}
</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- Actions -->
<div class="info-card animate-in">
<div class="card-header">
<h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i data-lucide="settings" class="w-5 h-5"></i>
{% trans "Manage" %}
</h2>
</div>
<div class="p-4 space-y-2">
{% if request.user.is_px_admin or request.user.is_hospital_admin %}
<a href="{% url 'px_sources:source_user_create' source.pk %}"
class="flex items-center gap-2 p-3 rounded-lg hover:bg-slate-50 transition text-navy font-medium">
<i data-lucide="user-plus" class="w-4 h-4"></i>
{% trans "Add Source User" %}
</a>
{% endif %}
<a href="{% url 'px_sources:source_edit' source.pk %}"
class="flex items-center gap-2 p-3 rounded-lg hover:bg-slate-50 transition text-navy font-medium">
<i data-lucide="edit" class="w-4 h-4"></i>
{% trans "Edit Source" %}
</a>
{% if source.is_active %}
<a href="{% url 'px_sources:source_toggle_status' source.pk %}"
class="flex items-center gap-2 p-3 rounded-lg hover:bg-slate-50 transition text-yellow-600 font-medium">
<i data-lucide="pause" class="w-4 h-4"></i>
{% trans "Deactivate" %}
</a>
{% else %}
<a href="{% url 'px_sources:source_toggle_status' source.pk %}"
class="flex items-center gap-2 p-3 rounded-lg hover:bg-slate-50 transition text-green-600 font-medium">
<i data-lucide="play" class="w-4 h-4"></i>
{% trans "Activate" %}
</a>
{% endif %}
</div>
</div>
</div>
</div> </div>
{% comment %} {% endif %} {% endcomment %}
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %} {% endblock %}

View File

@ -1,101 +1,289 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% load i18n %} {% load i18n %}
{% block title %}{% if source %}{% trans "Edit Source" %}{% else %}{% trans "Create Source" %}{% endif %}{% endblock %} {% block title %}{% if source %}{% trans "Edit Source" %}{% else %}{% trans "Create Source" %}{% endif %} - PX360{% endblock %}
{% block extra_css %}
<style>
:root {
--hh-navy: #005696;
--hh-blue: #007bbd;
--hh-light: #eef6fb;
--hh-slate: #64748b;
--hh-success: #10b981;
--hh-warning: #f59e0b;
}
.form-card {
background: white;
border-radius: 1rem;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.card-header {
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
padding: 1.25rem 1.75rem;
border-bottom: 1px solid #bae6fd;
border-radius: 1rem 1rem 0 0;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--hh-navy);
font-size: 0.9rem;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #cbd5e1;
border-radius: 0.75rem;
font-size: 0.95rem;
transition: all 0.2s;
background: white;
color: #1e293b;
font-family: 'Inter', sans-serif;
}
.form-input:focus {
outline: none;
border-color: var(--hh-blue);
box-shadow: 0 0 0 4px rgba(0, 123, 189, 0.1);
transform: translateY(-1px);
}
.form-input[readonly] {
background: #f1f5f9;
cursor: not-allowed;
}
textarea.form-input {
resize: vertical;
min-height: 120px;
}
.btn-primary {
background: linear-gradient(135deg, var(--hh-navy) 0%, var(--hh-blue) 100%);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.75rem;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 86, 150, 0.3);
}
.btn-secondary {
background: white;
color: #475569;
padding: 0.75rem 1.5rem;
border-radius: 0.75rem;
font-weight: 600;
border: 2px solid #e2e8f0;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #cbd5e1;
transform: translateY(-1px);
}
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: var(--hh-light);
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.checkbox-wrapper:hover {
background: #e0f2fe;
}
.checkbox-wrapper input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="px-4 py-6">
<!-- Page Header --> <!-- Breadcrumb -->
<div class="d-flex justify-content-between align-items-center mb-4"> <nav class="mb-4 animate-in">
<div> <ol class="flex items-center gap-2 text-sm text-slate">
<nav aria-label="breadcrumb"> <li><a href="{% url 'px_sources:source_list' %}" class="text-blue hover:text-navy font-medium">{% trans "PX Sources" %}</a></li>
<ol class="breadcrumb mb-2"> <li><i data-lucide="chevron-right" class="w-4 h-4 text-slate"></i></li>
<li class="breadcrumb-item"> <li class="text-navy font-semibold">
<a href="{% url 'px_sources:source_list' %}">{% trans "PX Sources" %}</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
{% if source %}{% trans "Edit Source" %}{% else %}{% trans "Create Source" %}{% endif %} {% if source %}{% trans "Edit Source" %}{% else %}{% trans "Create Source" %}{% endif %}
</li> </li>
</ol> </ol>
</nav> </nav>
<h2 class="mb-1">
<i class="bi bi-{% if source %}pencil-square{% else %}plus-circle{% endif %} text-warning me-2"></i> <!-- Page Header -->
<div class="flex flex-wrap justify-between items-center gap-4 mb-6 animate-in">
<div>
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<div class="w-10 h-10 bg-blue/10 rounded-xl flex items-center justify-center">
<i data-lucide="{% if source %}edit{% else %}plus-circle{% endif %}" class="w-5 h-5 text-blue"></i>
</div>
{% if source %}{% trans "Edit Source" %}{% else %}{% trans "Create Source" %}{% endif %} {% if source %}{% trans "Edit Source" %}{% else %}{% trans "Create Source" %}{% endif %}
</h2> </h1>
{% if source %} {% if source %}
<p class="text-muted mb-0">{{ source.name_en }}</p> <p class="text-slate mt-1">{{ source.name_en }} <span class="text-slate-400">({{ source.code }})</span></p>
{% endif %} {% endif %}
</div> </div>
<div> <a href="{% url 'px_sources:source_list' %}"
<a href="{% url 'px_sources:source_list' %}" class="btn btn-outline-secondary"> class="btn-secondary">
<i class="bi bi-arrow-left me-1"></i> {% trans "Back to List" %} <i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back to List" %}
</a> </a>
</div> </div>
</div>
<!-- Form Card --> <!-- Form Card -->
<div class="card"> <div class="form-card max-w-4xl animate-in">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0"> <h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i class="bi bi-form me-2"></i>{% trans "Source Information" %} <i data-lucide="file-text" class="w-5 h-5"></i>
</h5> {% trans "Source Information" %}
</h2>
</div> </div>
<div class="card-body"> <div class="p-6">
<form method="post" enctype="multipart/form-data"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div class="row"> <div class="grid grid-cols-1 md:grid-cols-3 gap-5 mb-5">
<div class="col-md-6"> <div class="md:col-span-1">
<div class="mb-3"> <label for="code" class="form-label">
<label for="name_en" class="form-label"> {% trans "Source Code" %}
{% trans "Name (English)" %} <span class="text-danger">*</span>
</label> </label>
<input type="text" class="form-control" id="name_en" name="name_en" <input type="text" id="code" name="code"
value="{{ source.code|default:'' }}"
placeholder="{% trans 'Auto-generated' %}"
class="form-input"
{% if source %}readonly{% endif %}>
{% if not source %}
<p class="text-xs text-slate mt-1">{% trans "Auto-generated from name if left blank" %}</p>
{% endif %}
</div>
<div class="md:col-span-2">
<label for="name_en" class="form-label">
{% trans "Name (English)" %} <span class="text-red-500">*</span>
</label>
<input type="text" id="name_en" name="name_en"
value="{{ source.name_en|default:'' }}" required value="{{ source.name_en|default:'' }}" required
placeholder="{% trans 'e.g., Patient Portal' %}"> placeholder="{% trans 'e.g., Patient Portal' %}"
class="form-input">
</div> </div>
</div> </div>
<div class="col-md-6">
<div class="mb-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5 mb-5">
<div>
<label for="name_ar" class="form-label"> <label for="name_ar" class="form-label">
{% trans "Name (Arabic)" %} {% trans "Name (Arabic)" %}
</label> </label>
<input type="text" class="form-control" id="name_ar" name="name_ar" <input type="text" id="name_ar" name="name_ar"
value="{{ source.name_ar|default:'' }}" dir="rtl" value="{{ source.name_ar|default:'' }}"
placeholder="{% trans 'e.g., بوابة المرضى' %}"> placeholder="{% trans 'e.g., بوابة المريض' %}"
dir="rtl"
class="form-input">
</div> </div>
<div>
<label for="source_type" class="form-label">
{% trans "Source Type" %}
</label>
<select id="source_type" name="source_type" class="form-input">
{% for value, label in source_types %}
<option value="{{ value }}" {% if source.source_type == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="form-group">
<label for="description" class="form-label"> <label for="description" class="form-label">
{% trans "Description" %} {% trans "Description" %}
</label> </label>
<textarea class="form-control" id="description" name="description" <textarea id="description" name="description" rows="4"
rows="4" placeholder="{% trans 'Describe this source channel...' %}">{{ source.description|default:'' }}</textarea> placeholder="{% trans 'Enter source description...' %}"
<small class="form-text text-muted"> class="form-input">{{ source.description|default:'' }}</textarea>
{% trans "Optional: Additional details about this source" %}
</small>
</div> </div>
<div class="mb-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5 mb-5">
<div class="form-check form-switch"> <div>
<input class="form-check-input" type="checkbox" id="is_active" name="is_active" <label for="contact_email" class="form-label">
{% if source.is_active|default:True %}checked{% endif %}> {% trans "Contact Email" %}
<label class="form-check-label" for="is_active"> </label>
{% trans "Active" %} <input type="email" id="contact_email" name="contact_email"
value="{{ source.contact_email|default:'' }}"
placeholder="contact@example.com"
class="form-input">
</div>
<div>
<label for="contact_phone" class="form-label">
{% trans "Contact Phone" %}
</label>
<input type="text" id="contact_phone" name="contact_phone"
value="{{ source.contact_phone|default:'' }}"
placeholder="+966 XX XXX XXXX"
class="form-input">
</div>
</div>
<div class="form-group">
<label class="checkbox-wrapper">
<input type="checkbox" id="is_active" name="is_active" value="true"
{% if source.is_active|default_if_none:True %}checked{% endif %}>
<div>
<span class="text-navy font-semibold">{% trans "Active" %}</span>
<p class="text-slate text-sm m-0">{% trans "Source is available for selection" %}</p>
</div>
</label> </label>
</div> </div>
<small class="form-text text-muted">
{% trans "Uncheck to deactivate this source (it won't appear in dropdowns)" %}
</small>
</div>
<div class="d-flex gap-2"> <div class="mt-8 pt-6 border-t border-slate-200 flex gap-3">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn-primary">
<i class="fas fa-save"></i> {% trans "Save" %} <i data-lucide="save" class="w-4 h-4"></i>
{% if source %}{% trans "Save Changes" %}{% else %}{% trans "Create Source" %}{% endif %}
</button> </button>
<a href="{% url 'px_sources:source_list' %}" class="btn btn-secondary"> <a href="{% url 'px_sources:source_list' %}" class="btn-secondary">
{% trans "Cancel" %} {% trans "Cancel" %}
</a> </a>
</div> </div>
@ -103,4 +291,30 @@
</div> </div>
</div> </div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
// Auto-generate code preview from name
const nameEnInput = document.getElementById('name_en');
const codeInput = document.getElementById('code');
if (nameEnInput && codeInput && !codeInput.value) {
nameEnInput.addEventListener('blur', function() {
const name = this.value.trim().toUpperCase();
if (name && !codeInput.value) {
const words = name.split(' ');
let code;
if (words.length >= 2) {
code = words.slice(0, 3).map(w => w.substring(0, 4)).join('-');
} else {
code = name.substring(0, 10).replace(' ', '-');
}
codeInput.value = code;
}
});
}
});
</script>
{% endblock %} {% endblock %}

View File

@ -1,44 +1,181 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% load i18n action_icons %} {% load i18n %}
{% block title %}{% trans "PX Sources" %}{% endblock %} {% block title %}{% trans "PX Sources" %} - PX360{% endblock %}
{% block extra_css %}
<style>
:root {
--hh-navy: #005696;
--hh-blue: #007bbd;
--hh-light: #eef6fb;
--hh-slate: #64748b;
--hh-success: #10b981;
--hh-warning: #f59e0b;
--hh-danger: #ef4444;
}
.page-header {
background: linear-gradient(135deg, var(--hh-navy) 0%, #0069a8 50%, var(--hh-blue) 100%);
color: white;
padding: 2rem 2.5rem;
border-radius: 1rem;
margin-bottom: 2rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
position: relative;
overflow: hidden;
}
.page-header::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
}
.data-card {
background: white;
border-radius: 1rem;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.data-card:hover {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.card-header {
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
padding: 1.25rem 1.75rem;
border-bottom: 1px solid #bae6fd;
border-radius: 1rem 1rem 0 0;
}
.data-table th {
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
padding: 0.875rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--hh-navy);
border-bottom: 2px solid #bae6fd;
}
.data-table td {
padding: 1rem;
border-bottom: 1px solid #f1f5f9;
color: #475569;
font-size: 0.875rem;
}
.data-table tbody tr {
transition: background-color 0.2s ease;
}
.data-table tbody tr:hover {
background-color: var(--hh-light);
}
.source-type-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.source-type-badge.internal {
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
color: #1e40af;
}
.source-type-badge.external {
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
color: #166534;
}
.source-type-badge.partner {
background: linear-gradient(135deg, #fef3c7, #fde68a);
color: #92400e;
}
.source-type-badge.government {
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
color: #3730a3;
}
.usage-stat {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--hh-slate);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="px-4 py-6">
<!-- Page Header --> <!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="page-header animate-in">
<div class="flex items-center justify-between">
<div> <div>
<h2 class="mb-1"> <h1 class="text-2xl font-bold mb-2">
<i class="bi bi-lightning-fill text-warning me-2"></i> <i data-lucide="radio" class="w-7 h-7 inline-block me-2"></i>
{% trans "PX Sources" %} {% trans "PX Sources" %}
</h2> </h1>
<p class="text-muted mb-0">{% trans "Manage patient experience source channels" %}</p> <p class="text-white/90">{% trans "Manage patient experience source channels" %}</p>
</div> </div>
<div> <a href="{% url 'px_sources:source_create' %}"
{% comment %} {% if request.user.is_px_admin %} {% endcomment %} class="inline-flex items-center gap-2 bg-white text-navy px-5 py-2.5 rounded-xl font-bold hover:bg-light transition shadow-lg">
<a href="{% url 'px_sources:source_create' %}" class="btn btn-primary"> <i data-lucide="plus" class="w-4 h-4"></i>
{% action_icon 'create' %} {% trans "Add Source" %} {% trans "Add Source" %}
</a> </a>
{% comment %} {% endif %} {% endcomment %}
</div> </div>
</div> </div>
<!-- Sources Card --> <!-- Sources Card -->
<div class="card"> <div class="data-card animate-in">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0"> <h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
{% action_icon 'filter' %} {% trans "Sources" %} <i data-lucide="database" class="w-5 h-5"></i>
</h5> {% trans "All Sources" %}
</h2>
</div> </div>
<div class="card-body">
<div class="p-6">
<!-- Filters --> <!-- Filters -->
<div class="row mb-3"> <div class="flex flex-wrap gap-3 mb-6">
<div class="col-md-4"> <div class="flex-1 min-w-[250px]">
<input type="text" id="search-input" class="form-control" <label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Search" %}</label>
placeholder="{% trans 'Search...' %}" value="{{ search }}"> <div class="relative">
<i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate"></i>
<input type="text" id="search-input"
class="w-full pl-10 pr-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue focus:ring-2 focus:ring-blue/20 transition"
placeholder="{% trans 'Search by name, code, or description...' %}" value="{{ search }}">
</div> </div>
<div class="col-md-3"> </div>
<select id="status-filter" class="form-select"> <div class="w-48">
<label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Status" %}</label>
<select id="status-filter"
class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white transition">
<option value="">{% trans "All Status" %}</option> <option value="">{% trans "All Status" %}</option>
<option value="true" {% if is_active == 'true' %}selected{% endif %}> <option value="true" {% if is_active == 'true' %}selected{% endif %}>
{% trans "Active" %} {% trans "Active" %}
@ -48,62 +185,125 @@
</option> </option>
</select> </select>
</div> </div>
<div class="col-md-2"> <div class="flex items-end">
<button id="apply-filters" class="btn btn-secondary w-100"> <button id="apply-filters"
{% action_icon 'filter' %} {% trans "Filter" %} class="inline-flex items-center gap-2 px-6 py-2.5 bg-navy text-white rounded-xl font-bold hover:bg-blue transition shadow-lg shadow-navy/25">
<i data-lucide="filter" class="w-4 h-4"></i>
{% trans "Filter" %}
</button> </button>
</div> </div>
</div> </div>
<!-- Sources Table --> <!-- Sources Table -->
<div class="table-responsive"> <div class="overflow-x-auto">
<table class="table table-hover"> <table class="w-full data-table">
<thead class="table-light"> <thead>
<tr> <tr>
<th>{% trans "Code" %}</th>
<th>{% trans "Name (EN)" %}</th> <th>{% trans "Name (EN)" %}</th>
<th>{% trans "Name (AR)" %}</th> <th>{% trans "Name (AR)" %}</th>
<th>{% trans "Description" %}</th> <th>{% trans "Type" %}</th>
<th>{% trans "Status" %}</th> <th>{% trans "Usage" %}</th>
<th>{% trans "Actions" %}</th> <th class="text-center">{% trans "Status" %}</th>
<th class="text-center">{% trans "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for source in sources %} {% for source in sources %}
<tr> <tr>
<td><strong>{{ source.name_en }}</strong></td>
<td dir="rtl">{{ source.name_ar|default:"-" }}</td>
<td class="text-muted">{{ source.description|default:"-"|truncatewords:10 }}</td>
<td> <td>
{% if source.is_active %} <span class="font-mono text-xs bg-slate-100 px-2 py-1 rounded">{{ source.code }}</span>
<span class="badge bg-success">{% trans "Active" %}</span>
{% else %}
<span class="badge bg-secondary">{% trans "Inactive" %}</span>
{% endif %}
</td> </td>
<td> <td>
<a href="{% url 'px_sources:source_detail' source.pk %}" <a href="{% url 'px_sources:source_detail' source.pk %}"
class="btn btn-sm btn-info" title="{% trans 'View' %}"> class="font-semibold text-navy hover:text-blue transition">
{% action_icon 'view' %} {{ source.name_en }}
</a> </a>
{% if request.user.is_px_admin %} </td>
<td dir="rtl">
<span class="text-slate">{{ source.name_ar|default:"-" }}</span>
</td>
<td>
<span class="source-type-badge {{ source.source_type }}">
{% if source.source_type == 'internal' %}
<i data-lucide="building" class="w-3 h-3"></i>
{% elif source.source_type == 'external' %}
<i data-lucide="globe" class="w-3 h-3"></i>
{% elif source.source_type == 'partner' %}
<i data-lucide="handshake" class="w-3 h-3"></i>
{% elif source.source_type == 'government' %}
<i data-lucide="landmark" class="w-3 h-3"></i>
{% else %}
<i data-lucide="circle" class="w-3 h-3"></i>
{% endif %}
{{ source.get_source_type_display }}
</span>
</td>
<td>
<div class="flex flex-col gap-1">
<span class="usage-stat">
<i data-lucide="file-text" class="w-3 h-3"></i>
{{ source.total_complaints }} {% trans "complaints" %}
</span>
<span class="usage-stat">
<i data-lucide="help-circle" class="w-3 h-3"></i>
{{ source.total_inquiries }} {% trans "inquiries" %}
</span>
</div>
</td>
<td class="text-center">
{% if source.is_active %}
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-green-100 text-green-700 rounded-full text-xs font-bold">
<i data-lucide="check-circle" class="w-3.5 h-3.5"></i>
{% trans "Active" %}
</span>
{% else %}
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 text-slate-600 rounded-full text-xs font-bold">
<i data-lucide="x-circle" class="w-3.5 h-3.5"></i>
{% trans "Inactive" %}
</span>
{% endif %}
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-1.5">
<a href="{% url 'px_sources:source_detail' source.pk %}"
class="p-2 text-blue hover:bg-blue-50 rounded-lg transition" title="{% trans 'View' %}">
<i data-lucide="eye" class="w-4 h-4"></i>
</a>
{% if user.is_px_admin or user.is_hospital_admin %}
<a href="{% url 'px_sources:source_edit' source.pk %}" <a href="{% url 'px_sources:source_edit' source.pk %}"
class="btn btn-sm btn-warning" title="{% trans 'Edit' %}"> class="p-2 text-navy hover:bg-navy/10 rounded-lg transition" title="{% trans 'Edit' %}">
{% action_icon 'edit' %} <i data-lucide="edit" class="w-4 h-4"></i>
</a> </a>
<a href="{% url 'px_sources:source_toggle_status' source.pk %}"
class="p-2 hover:bg-slate-100 rounded-lg transition"
title="{% if source.is_active %}{% trans 'Deactivate' %}{% else %}{% trans 'Activate' %}{% endif %}">
{% if source.is_active %}
<i data-lucide="pause" class="w-4 h-4 text-yellow-600"></i>
{% else %}
<i data-lucide="play" class="w-4 h-4 text-green-600"></i>
{% endif %}
</a>
{% if user.is_px_admin %}
<a href="{% url 'px_sources:source_delete' source.pk %}" <a href="{% url 'px_sources:source_delete' source.pk %}"
class="btn btn-sm btn-danger" title="{% trans 'Delete' %}"> class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition" title="{% trans 'Delete' %}">
{% action_icon 'delete' %} <i data-lucide="trash-2" class="w-4 h-4"></i>
</a> </a>
{% endif %} {% endif %}
{% endif %}
</div>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="5" class="text-center py-4"> <td colspan="7" class="py-12 text-center">
<p class="text-muted mb-2"> <div class="flex flex-col items-center">
<i class="bi bi-inbox fs-1"></i> <div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-4">
</p> <i data-lucide="database" class="w-8 h-8 text-slate-400"></i>
<p>{% trans "No sources found. Click 'Add Source' to create one." %}</p> </div>
<p class="text-slate font-medium">{% trans "No sources found" %}</p>
<p class="text-slate text-sm mt-1">{% trans "Add your first source to get started" %}</p>
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -116,22 +316,24 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Apply filters button lucide.createIcons();
// Filter functionality
document.getElementById('apply-filters').addEventListener('click', function() { document.getElementById('apply-filters').addEventListener('click', function() {
const search = document.getElementById('search-input').value; const search = document.getElementById('search-input').value;
const isActive = document.getElementById('status-filter').value; const status = document.getElementById('status-filter').value;
let url = new URL(window.location.href); let url = new URL(window.location.href);
if (search) url.searchParams.set('search', search); url.searchParams.set('search', search);
else url.searchParams.delete('search'); if (status) {
url.searchParams.set('is_active', status);
if (isActive) url.searchParams.set('is_active', isActive); } else {
else url.searchParams.delete('is_active'); url.searchParams.delete('is_active');
}
window.location.href = url.toString(); window.location.href = url.toString();
}); });
// Enter key on search input // Enter key to search
document.getElementById('search-input').addEventListener('keypress', function(e) { document.getElementById('search-input').addEventListener('keypress', function(e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
document.getElementById('apply-filters').click(); document.getElementById('apply-filters').click();

View File

@ -4,42 +4,53 @@
{% block title %}{% trans "My Complaints" %} - {{ source.name_en }}{% endblock %} {% block title %}{% trans "My Complaints" %} - {{ source.name_en }}{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="p-6">
<!-- Page Header --> <!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="flex flex-wrap justify-between items-center gap-4 mb-6">
<div> <div>
<h2 class="mb-1"> <h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<i class="bi bi-exclamation-triangle-fill text-warning me-2"></i> <i data-lucide="alert-triangle" class="w-8 h-8 text-yellow-500"></i>
{% trans "My Complaints" %} {% trans "My Complaints" %}
<span class="badge bg-primary">{{ complaints_count }}</span> <span class="inline-flex items-center px-3 py-1 bg-navy text-white rounded-full text-sm font-medium">{{ complaints_count }}</span>
</h2> </h1>
<p class="text-muted mb-0"> <p class="text-slate mt-1">
{% trans "View all complaints from your source" %} {% trans "View all complaints from your source" %}
</p> </p>
</div> </div>
{% if source_user.can_create_complaints %} {% if source_user.can_create_complaints %}
<a href="{% url 'complaints:complaint_create' %}" class="btn btn-primary"> <a href="{% url 'complaints:complaint_create' %}"
<i class="bi bi-plus-circle me-1"></i> {% trans "Create Complaint" %} class="inline-flex items-center gap-2 px-4 py-2 bg-navy text-white rounded-lg hover:bg-blue transition shadow-lg shadow-navy/20">
<i data-lucide="plus-circle" class="w-4 h-4"></i>
{% trans "Create Complaint" %}
</a> </a>
{% endif %} {% endif %}
</div> </div>
<!-- Filter Panel --> <!-- Filter Panel -->
<div class="card mb-4"> <div class="bg-white rounded-xl shadow-sm border border-slate-200 mb-6">
<div class="card-body"> <div class="p-4 border-b border-slate-200 bg-slate-50/50 rounded-t-xl">
<form method="get" class="row g-3"> <h2 class="text-sm font-semibold text-navy flex items-center gap-2">
<i data-lucide="filter" class="w-4 h-4 text-slate"></i>
{% trans "Filters" %}
</h2>
</div>
<div class="p-4">
<form method="get" class="grid grid-cols-1 md:grid-cols-12 gap-4">
<!-- Search --> <!-- Search -->
<div class="col-md-4"> <div class="md:col-span-4">
<label class="form-label">{% trans "Search" %}</label> <label class="block text-sm font-medium text-slate mb-1">{% trans "Search" %}</label>
<input type="text" class="form-control" name="search" <input type="text"
class="w-full px-3 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue focus:border-blue outline-none transition"
name="search"
placeholder="{% trans 'Title, patient name...' %}" placeholder="{% trans 'Title, patient name...' %}"
value="{{ search|default:'' }}"> value="{{ search|default:'' }}">
</div> </div>
<!-- Status --> <!-- Status -->
<div class="col-md-2"> <div class="md:col-span-2">
<label class="form-label">{% trans "Status" %}</label> <label class="block text-sm font-medium text-slate mb-1">{% trans "Status" %}</label>
<select class="form-select" name="status"> <select class="w-full px-3 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue focus:border-blue outline-none transition bg-white"
name="status">
<option value="">{% trans "All Statuses" %}</option> <option value="">{% trans "All Statuses" %}</option>
<option value="open" {% if status_filter == 'open' %}selected{% endif %}>{% trans "Open" %}</option> <option value="open" {% if status_filter == 'open' %}selected{% endif %}>{% trans "Open" %}</option>
<option value="in_progress" {% if status_filter == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option> <option value="in_progress" {% if status_filter == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
@ -49,9 +60,10 @@
</div> </div>
<!-- Priority --> <!-- Priority -->
<div class="col-md-2"> <div class="md:col-span-2">
<label class="form-label">{% trans "Priority" %}</label> <label class="block text-sm font-medium text-slate mb-1">{% trans "Priority" %}</label>
<select class="form-select" name="priority"> <select class="w-full px-3 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue focus:border-blue outline-none transition bg-white"
name="priority">
<option value="">{% trans "All Priorities" %}</option> <option value="">{% trans "All Priorities" %}</option>
<option value="low" {% if priority_filter == 'low' %}selected{% endif %}>{% trans "Low" %}</option> <option value="low" {% if priority_filter == 'low' %}selected{% endif %}>{% trans "Low" %}</option>
<option value="medium" {% if priority_filter == 'medium' %}selected{% endif %}>{% trans "Medium" %}</option> <option value="medium" {% if priority_filter == 'medium' %}selected{% endif %}>{% trans "Medium" %}</option>
@ -60,9 +72,10 @@
</div> </div>
<!-- Category --> <!-- Category -->
<div class="col-md-2"> <div class="md:col-span-2">
<label class="form-label">{% trans "Category" %}</label> <label class="block text-sm font-medium text-slate mb-1">{% trans "Category" %}</label>
<select class="form-select" name="category"> <select class="w-full px-3 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue focus:border-blue outline-none transition bg-white"
name="category">
<option value="">{% trans "All Categories" %}</option> <option value="">{% trans "All Categories" %}</option>
<option value="clinical_care" {% if category_filter == 'clinical_care' %}selected{% endif %}>{% trans "Clinical Care" %}</option> <option value="clinical_care" {% if category_filter == 'clinical_care' %}selected{% endif %}>{% trans "Clinical Care" %}</option>
<option value="staff_behavior" {% if category_filter == 'staff_behavior' %}selected{% endif %}>{% trans "Staff Behavior" %}</option> <option value="staff_behavior" {% if category_filter == 'staff_behavior' %}selected{% endif %}>{% trans "Staff Behavior" %}</option>
@ -75,14 +88,16 @@
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="col-md-2 d-flex align-items-end"> <div class="md:col-span-2 flex items-end">
<div class="d-flex gap-2 w-100"> <div class="flex gap-2 w-full">
<button type="submit" class="btn btn-primary flex-grow-1"> <button type="submit"
<i class="bi bi-search me-1"></i> {% trans "Filter" %} class="inline-flex items-center justify-center gap-2 px-4 py-2 bg-navy text-white rounded-lg hover:bg-blue transition flex-grow">
<i data-lucide="search" class="w-4 h-4"></i>
{% trans "Filter" %}
</button> </button>
<a href="{% url 'px_sources:source_user_complaint_list' %}" <a href="{% url 'px_sources:source_user_complaint_list' %}"
class="btn btn-outline-secondary"> class="inline-flex items-center justify-center p-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-slate">
<i class="bi bi-x-circle"></i> <i data-lucide="x-circle" class="w-4 h-4"></i>
</a> </a>
</div> </div>
</div> </div>
@ -91,83 +106,97 @@
</div> </div>
<!-- Complaints Table --> <!-- Complaints Table -->
<div class="card"> <div class="bg-white rounded-xl shadow-sm border border-slate-200">
<div class="card-body p-0"> <div class="p-4 border-b border-slate-200 bg-slate-50/50 rounded-t-xl">
<div class="table-responsive"> <h2 class="text-lg font-semibold text-navy flex items-center gap-2">
<table class="table table-hover mb-0"> <i data-lucide="file-text" class="w-5 h-5 text-slate"></i>
<thead class="table-light"> {% trans "Complaints List" %}
<tr> </h2>
<th>{% trans "ID" %}</th> </div>
<th>{% trans "Title" %}</th> <div class="overflow-x-auto">
<th>{% trans "Patient" %}</th> <table class="w-full">
<th>{% trans "Category" %}</th> <thead>
<th>{% trans "Status" %}</th> <tr class="border-b border-slate-200">
<th>{% trans "Priority" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "ID" %}</th>
<th>{% trans "Assigned To" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Title" %}</th>
<th>{% trans "Created" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Patient" %}</th>
<th>{% trans "Actions" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Category" %}</th>
<th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Status" %}</th>
<th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Priority" %}</th>
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Assigned To" %}</th>
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Created" %}</th>
<th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100">
{% for complaint in complaints %} {% for complaint in complaints %}
<tr> <tr class="hover:bg-slate-50 transition">
<td><code>{{ complaint.id|slice:":8" }}</code></td> <td class="py-3 px-4 text-sm font-mono text-slate">{{ complaint.id|slice:":8" }}</td>
<td>{{ complaint.title|truncatewords:8 }}</td> <td class="py-3 px-4">
<td> <a href="{% url 'complaints:complaint_detail' complaint.pk %}"
class="text-navy font-semibold hover:text-blue transition">
{{ complaint.title|truncatewords:8 }}
</a>
</td>
<td class="py-3 px-4">
{% if complaint.patient %} {% if complaint.patient %}
<strong>{{ complaint.patient.get_full_name }}</strong><br> <div class="font-medium text-slate">{{ complaint.patient.get_full_name }}</div>
<small class="text-muted">{% trans "MRN" %}: {{ complaint.patient.mrn }}</small> <div class="text-xs text-slate/70">{% trans "MRN" %}: {{ complaint.patient.mrn }}</div>
{% else %} {% else %}
<em class="text-muted">{% trans "Not specified" %}</em> <span class="text-slate/60 italic">{% trans "Not specified" %}</span>
{% endif %} {% endif %}
</td> </td>
<td><span class="badge bg-secondary">{{ complaint.get_category_display }}</span></td> <td class="py-3 px-4">
<td> <span class="inline-flex items-center px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs font-medium">
{{ complaint.get_category_display }}
</span>
</td>
<td class="py-3 px-4 text-center">
{% if complaint.status == 'open' %} {% if complaint.status == 'open' %}
<span class="badge bg-danger">{% trans "Open" %}</span> <span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">{% trans "Open" %}</span>
{% elif complaint.status == 'in_progress' %} {% elif complaint.status == 'in_progress' %}
<span class="badge bg-warning text-dark">{% trans "In Progress" %}</span> <span class="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-medium">{% trans "In Progress" %}</span>
{% elif complaint.status == 'resolved' %} {% elif complaint.status == 'resolved' %}
<span class="badge bg-success">{% trans "Resolved" %}</span> <span class="inline-flex items-center px-2 py-1 bg-green-100 text-green-700 rounded text-xs font-medium">{% trans "Resolved" %}</span>
{% else %} {% else %}
<span class="badge bg-secondary">{% trans "Closed" %}</span> <span class="inline-flex items-center px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs font-medium">{% trans "Closed" %}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td class="py-3 px-4 text-center">
{% if complaint.priority == 'high' %} {% if complaint.priority == 'high' %}
<span class="badge bg-danger">{% trans "High" %}</span> <span class="inline-flex items-center px-2 py-1 bg-red-100 text-red-700 rounded text-xs font-medium">{% trans "High" %}</span>
{% elif complaint.priority == 'medium' %} {% elif complaint.priority == 'medium' %}
<span class="badge bg-warning text-dark">{% trans "Medium" %}</span> <span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">{% trans "Medium" %}</span>
{% else %} {% else %}
<span class="badge bg-success">{% trans "Low" %}</span> <span class="inline-flex items-center px-2 py-1 bg-green-100 text-green-700 rounded text-xs font-medium">{% trans "Low" %}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td class="py-3 px-4 text-sm text-slate">
{% if complaint.assigned_to %} {% if complaint.assigned_to %}
{{ complaint.assigned_to.get_full_name }} {{ complaint.assigned_to.get_full_name }}
{% else %} {% else %}
<span class="text-muted"><em>{% trans "Unassigned" %}</em></span> <span class="text-slate/60 italic">{% trans "Unassigned" %}</span>
{% endif %} {% endif %}
</td> </td>
<td><small class="text-muted">{{ complaint.created_at|date:"Y-m-d" }}</small></td> <td class="py-3 px-4 text-sm text-slate">{{ complaint.created_at|date:"Y-m-d" }}</td>
<td> <td class="py-3 px-4 text-center">
<a href="{% url 'complaints:complaint_detail' complaint.pk %}" <a href="{% url 'complaints:complaint_detail' complaint.pk %}"
class="btn btn-sm btn-info" class="inline-flex items-center justify-center p-2 bg-blue text-white rounded-lg hover:bg-navy transition"
title="{% trans 'View' %}"> title="{% trans 'View' %}">
<i class="bi bi-eye"></i> <i data-lucide="eye" class="w-4 h-4"></i>
</a> </a>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="9" class="text-center py-5"> <td colspan="9" class="text-center py-12">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i> <i data-lucide="inbox" class="w-16 h-16 mx-auto text-slate/30 mb-4"></i>
<p class="text-muted mt-3"> <p class="text-slate mb-4">{% trans "No complaints found for your source." %}</p>
{% trans "No complaints found for your source." %}
</p>
{% if source_user.can_create_complaints %} {% if source_user.can_create_complaints %}
<a href="{% url 'complaints:complaint_create' %}" class="btn btn-primary"> <a href="{% url 'complaints:complaint_create' %}"
<i class="bi bi-plus-circle me-1"></i> {% trans "Create Complaint" %} class="inline-flex items-center gap-2 px-4 py-2 bg-navy text-white rounded-lg hover:bg-blue transition">
<i data-lucide="plus-circle" class="w-4 h-4"></i>
{% trans "Create Complaint" %}
</a> </a>
{% endif %} {% endif %}
</td> </td>
@ -177,31 +206,35 @@
</table> </table>
</div> </div>
</div> </div>
</div>
<!-- Pagination --> <!-- Pagination -->
{% if complaints.has_other_pages %} {% if complaints.has_other_pages %}
<nav aria-label="Complaints pagination" class="mt-4"> <nav aria-label="Complaints pagination" class="mt-6">
<ul class="pagination justify-content-center"> <ul class="flex justify-center items-center gap-2">
{% if complaints.has_previous %} {% if complaints.has_previous %}
<li class="page-item"> <li>
<a class="page-link" href="?page=1&search={{ search }}&status={{ status_filter }}&priority={{ priority_filter }}&category={{ category_filter }}"> <a class="inline-flex items-center justify-center w-10 h-10 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-slate"
<i class="bi bi-chevron-double-left"></i> href="?page=1&search={{ search }}&status={{ status_filter }}&priority={{ priority_filter }}&category={{ category_filter }}">
<i data-lucide="chevrons-left" class="w-4 h-4"></i>
</a> </a>
</li> </li>
<li class="page-item"> <li>
<a class="page-link" href="?page={{ complaints.previous_page_number }}&search={{ search }}&status={{ status_filter }}&priority={{ priority_filter }}&category={{ category_filter }}"> <a class="inline-flex items-center justify-center w-10 h-10 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-slate"
<i class="bi bi-chevron-left"></i> href="?page={{ complaints.previous_page_number }}&search={{ search }}&status={{ status_filter }}&priority={{ priority_filter }}&category={{ category_filter }}">
<i data-lucide="chevron-left" class="w-4 h-4"></i>
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% for num in complaints.paginator.page_range %} {% for num in complaints.paginator.page_range %}
{% if complaints.number == num %} {% if complaints.number == num %}
<li class="page-item active"><span class="page-link">{{ num }}</span></li> <li>
<span class="inline-flex items-center justify-center w-10 h-10 bg-navy text-white rounded-lg font-medium">{{ num }}</span>
</li>
{% elif num > complaints.number|add:'-3' and num < complaints.number|add:'3' %} {% elif num > complaints.number|add:'-3' and num < complaints.number|add:'3' %}
<li class="page-item"> <li>
<a class="page-link" href="?page={{ num }}&search={{ search }}&status={{ status_filter }}&priority={{ priority_filter }}&category={{ category_filter }}"> <a class="inline-flex items-center justify-center w-10 h-10 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-slate"
href="?page={{ num }}&search={{ search }}&status={{ status_filter }}&priority={{ priority_filter }}&category={{ category_filter }}">
{{ num }} {{ num }}
</a> </a>
</li> </li>
@ -209,14 +242,16 @@
{% endfor %} {% endfor %}
{% if complaints.has_next %} {% if complaints.has_next %}
<li class="page-item"> <li>
<a class="page-link" href="?page={{ complaints.next_page_number }}&search={{ search }}&status={{ status_filter }}&priority={{ priority_filter }}&category={{ category_filter }}"> <a class="inline-flex items-center justify-center w-10 h-10 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-slate"
<i class="bi bi-chevron-right"></i> href="?page={{ complaints.next_page_number }}&search={{ search }}&status={{ status_filter }}&priority={{ priority_filter }}&category={{ category_filter }}">
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</a> </a>
</li> </li>
<li class="page-item"> <li>
<a class="page-link" href="?page={{ complaints.paginator.num_pages }}&search={{ search }}&status={{ status_filter }}&priority={{ priority_filter }}&category={{ category_filter }}"> <a class="inline-flex items-center justify-center w-10 h-10 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-slate"
<i class="bi bi-chevron-double-right"></i> href="?page={{ complaints.paginator.num_pages }}&search={{ search }}&status={{ status_filter }}&priority={{ priority_filter }}&category={{ category_filter }}">
<i data-lucide="chevrons-right" class="w-4 h-4"></i>
</a> </a>
</li> </li>
{% endif %} {% endif %}
@ -224,4 +259,10 @@
</nav> </nav>
{% endif %} {% endif %}
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %} {% endblock %}

View File

@ -4,117 +4,139 @@
{% block title %}{% trans "Delete Source User" %} - {{ source.name_en }}{% endblock %} {% block title %}{% trans "Delete Source User" %} - {{ source.name_en }}{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="p-6">
<!-- Page Header --> <!-- Breadcrumb -->
<div class="d-flex justify-content-between align-items-center mb-4"> <nav class="mb-4">
<div> <ol class="flex items-center gap-2 text-sm text-slate">
<nav aria-label="breadcrumb"> <li><a href="{% url 'px_sources:source_list' %}" class="text-blue hover:text-navy">{% trans "PX Sources" %}</a></li>
<ol class="breadcrumb mb-2"> <li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li class="breadcrumb-item"> <li><a href="{% url 'px_sources:source_detail' source.pk %}" class="text-blue hover:text-navy">{{ source.name_en }}</a></li>
<a href="{% url 'px_sources:source_list' %}">{% trans "PX Sources" %}</a> <li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
</li> <li class="text-navy font-semibold">{% trans "Delete Source User" %}</li>
<li class="breadcrumb-item">
<a href="{% url 'px_sources:source_detail' source.pk %}">{{ source.name_en }}</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
{% trans "Delete Source User" %}
</li>
</ol> </ol>
</nav> </nav>
<h2 class="mb-1">
<i class="bi bi-exclamation-triangle text-danger me-2"></i> <!-- Page Header -->
<div class="flex flex-wrap justify-between items-center gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<i data-lucide="alert-triangle" class="w-8 h-8 text-red-500"></i>
{% trans "Delete Source User" %} {% trans "Delete Source User" %}
</h2> </h1>
<p class="text-muted mb-0"> <p class="text-slate mt-1">{{ source.name_en }}</p>
{{ source.name_en }}
</p>
</div> </div>
<div> <div>
<a href="{% url 'px_sources:source_detail' source.pk %}" class="btn btn-outline-secondary"> <a href="{% url 'px_sources:source_detail' source.pk %}"
<i class="bi bi-arrow-left me-1"></i> {% trans "Back to Source" %} class="inline-flex items-center gap-2 px-4 py-2 border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition">
<i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to Source" %}
</a> </a>
</div> </div>
</div> </div>
<!-- Confirmation Card --> <!-- Confirmation Card -->
<div class="row"> <div class="bg-white rounded-xl shadow-sm border border-red-200">
<div class="col-12"> <div class="p-4 border-b border-red-200 bg-red-500 rounded-t-xl">
<div class="card border-danger"> <h2 class="text-lg font-semibold text-white flex items-center gap-2">
<div class="card-header bg-danger text-white"> <i data-lucide="alert-triangle" class="w-5 h-5"></i>
<h5 class="card-title mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>
{% trans "Confirm Deletion" %} {% trans "Confirm Deletion" %}
</h5> </h2>
</div>
<div class="p-6">
<!-- Warning Alert -->
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex items-center gap-3">
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-600"></i>
<strong class="text-red-800">{% trans "Warning:" %}</strong>
<span class="text-red-700">{% trans "This action cannot be undone!" %}</span>
</div> </div>
<div class="card-body">
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>{% trans "Warning:" %}</strong> {% trans "This action cannot be undone!" %}
</div> </div>
<div class="mb-4"> <!-- User Details -->
<p>{% trans "Are you sure you want to remove the following source user?" %}</p> <div class="mb-6">
<p class="text-navy mb-4">{% trans "Are you sure you want to remove the following source user?" %}</p>
<div class="card bg-light"> <div class="bg-slate-50 rounded-xl border border-slate-200 p-4">
<div class="card-body"> <dl class="space-y-3 mb-0">
<dl class="row mb-0"> <div class="flex">
<dt class="col-sm-3">{% trans "User" %}:</dt> <dt class="w-32 text-sm font-semibold text-navy">{% trans "User" %}:</dt>
<dd class="col-sm-9"> <dd class="flex-1">
<strong>{{ source_user.user.email }}</strong> <strong class="text-navy">{{ source_user.user.email }}</strong>
{% if source_user.user.get_full_name %} {% if source_user.user.get_full_name %}
<br><small class="text-muted">{{ source_user.user.get_full_name }}</small> <br><small class="text-slate">{{ source_user.user.get_full_name }}</small>
{% endif %} {% endif %}
</dd> </dd>
</div>
<dt class="col-sm-3">{% trans "Source" %}:</dt> <div class="flex">
<dd class="col-sm-9">{{ source.name_en }}</dd> <dt class="w-32 text-sm font-semibold text-navy">{% trans "Source" %}:</dt>
<dd class="flex-1 text-navy">{{ source.name_en }}</dd>
</div>
<dt class="col-sm-3">{% trans "Status" %}:</dt> <div class="flex">
<dd class="col-sm-9"> <dt class="w-32 text-sm font-semibold text-navy">{% trans "Status" %}:</dt>
<dd class="flex-1">
{% if source_user.is_active %} {% if source_user.is_active %}
<span class="badge bg-success">{% trans "Active" %}</span> <span class="inline-flex items-center gap-1 px-2.5 py-1 bg-green-100 text-green-700 rounded-full text-xs font-semibold">
<i data-lucide="check-circle" class="w-3 h-3"></i>
{% trans "Active" %}
</span>
{% else %} {% else %}
<span class="badge bg-secondary">{% trans "Inactive" %}</span> <span class="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-100 text-slate-600 rounded-full text-xs font-semibold">
<i data-lucide="x-circle" class="w-3 h-3"></i>
{% trans "Inactive" %}
</span>
{% endif %} {% endif %}
</dd> </dd>
</div>
<dt class="col-sm-3">{% trans "Permissions" %}:</dt> <div class="flex">
<dd class="col-sm-9"> <dt class="w-32 text-sm font-semibold text-navy">{% trans "Permissions" %}:</dt>
<dd class="flex-1">
{% if source_user.can_create_complaints %} {% if source_user.can_create_complaints %}
<span class="badge bg-primary">{% trans "Complaints" %}</span> <span class="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-medium mr-1">{% trans "Complaints" %}</span>
{% endif %} {% endif %}
{% if source_user.can_create_inquiries %} {% if source_user.can_create_inquiries %}
<span class="badge bg-info">{% trans "Inquiries" %}</span> <span class="inline-flex items-center px-2 py-1 bg-cyan-100 text-cyan-700 rounded text-xs font-medium">{% trans "Inquiries" %}</span>
{% endif %} {% endif %}
{% if not source_user.can_create_complaints and not source_user.can_create_inquiries %} {% if not source_user.can_create_complaints and not source_user.can_create_inquiries %}
<span class="text-muted">{% trans "None" %}</span> <span class="text-slate text-sm">{% trans "None" %}</span>
{% endif %} {% endif %}
</dd> </dd>
</div>
</dl> </dl>
</div> </div>
</div> </div>
<!-- Info Alert -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-center gap-3">
<i data-lucide="info" class="w-5 h-5 text-blue-600"></i>
<span class="text-blue-700 text-sm">{% trans "The user will lose access to the source dashboard and will not be able to create complaints or inquiries from this source." %}</span>
</div>
</div> </div>
<div class="alert alert-info"> <!-- Action Buttons -->
<i class="bi bi-info-circle me-2"></i>
{% trans "The user will lose access to the source dashboard and will not be able to create complaints or inquiries from this source." %}
</div>
<form method="POST" novalidate> <form method="POST" novalidate>
{% csrf_token %} {% csrf_token %}
<div class="d-flex gap-2"> <div class="flex gap-3">
<button type="submit" class="btn btn-danger"> <button type="submit"
<i class="bi bi-trash me-1"></i> {% trans "Yes, Delete" %} class="inline-flex items-center gap-2 px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition shadow-lg shadow-red-500/20">
<i data-lucide="trash-2" class="w-4 h-4"></i> {% trans "Yes, Delete" %}
</button> </button>
<a href="{% url 'px_sources:source_detail' source.pk %}" class="btn btn-outline-secondary"> <a href="{% url 'px_sources:source_detail' source.pk %}"
<i class="bi bi-x-lg me-1"></i> {% trans "Cancel" %} class="inline-flex items-center gap-2 px-6 py-2 border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition">
<i data-lucide="x" class="w-4 h-4"></i> {% trans "Cancel" %}
</a> </a>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %} {% endblock %}

View File

@ -1,212 +1,205 @@
{% extends "layouts/source_user_base.html" %} {% extends "layouts/source_user_base.html" %}
{% load i18n action_icons %} {% load i18n %}
{% block title %}{% trans "Source User Dashboard" %} - {{ source.name_en }}{% endblock %} {% block title %}{% trans "Source User Dashboard" %} - {{ source.name_en }} - PX360{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="p-6">
<!-- Page Header --> <!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="flex flex-wrap justify-between items-center gap-4 mb-6">
<div> <div>
<h2 class="mb-1"> <h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<i class="bi bi-lightning-fill text-warning me-2"></i> <i data-lucide="radio" class="w-8 h-8 text-blue"></i>
{{ source.name_en }} {{ source.name_en }}
</h2> </h1>
<p class="text-muted mb-0"> <p class="text-slate mt-1">
{% trans "Welcome" %}, {{ request.user.get_full_name }}! {% trans "Welcome" %}, {{ request.user.get_full_name }}!
{% trans "You're managing feedback from this source." %} {% trans "You're managing feedback from this source." %}
</p> </p>
</div> </div>
<div class="flex gap-2">
<a href="{% url 'px_sources:source_user_complaint_list' %}"
class="inline-flex items-center gap-2 px-4 py-2 bg-navy text-white rounded-lg hover:bg-blue transition shadow-lg shadow-navy/20">
<i data-lucide="file-text" class="w-4 h-4"></i>
{% trans "All Complaints" %}
</a>
<a href="{% url 'px_sources:source_user_inquiry_list' %}"
class="inline-flex items-center gap-2 px-4 py-2 bg-blue text-white rounded-lg hover:bg-navy transition">
<i data-lucide="help-circle" class="w-4 h-4"></i>
{% trans "All Inquiries" %}
</a>
</div>
</div> </div>
<!-- Statistics Cards --> <!-- Statistics Cards -->
<div class="row mb-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="col-md-3"> <div class="bg-gradient-to-br from-navy to-blue rounded-xl p-4 text-white shadow-lg shadow-navy/20">
<div class="card bg-primary text-white"> <div class="flex items-center justify-between">
<div class="card-body"> <div>
<h6 class="card-title">{% trans "Total Complaints" %}</h6> <p class="text-white/80 text-sm">{% trans "Total Complaints" %}</p>
<h2 class="mb-0">{{ total_complaints }}</h2> <p class="text-2xl font-bold">{{ total_complaints }}</p>
</div>
<i data-lucide="file-text" class="w-10 h-10 text-white/30"></i>
</div> </div>
</div> </div>
<div class="bg-gradient-to-br from-yellow-500 to-orange-500 rounded-xl p-4 text-white shadow-lg shadow-yellow/20">
<div class="flex items-center justify-between">
<div>
<p class="text-white/80 text-sm">{% trans "Open Complaints" %}</p>
<p class="text-2xl font-bold">{{ open_complaints }}</p>
</div> </div>
<div class="col-md-3"> <i data-lucide="alert-circle" class="w-10 h-10 text-white/30"></i>
<div class="card bg-warning text-dark">
<div class="card-body">
<h6 class="card-title">{% trans "Open Complaints" %}</h6>
<h2 class="mb-0">{{ open_complaints }}</h2>
</div> </div>
</div> </div>
<div class="bg-gradient-to-br from-cyan-500 to-blue-500 rounded-xl p-4 text-white shadow-lg shadow-cyan/20">
<div class="flex items-center justify-between">
<div>
<p class="text-white/80 text-sm">{% trans "Total Inquiries" %}</p>
<p class="text-2xl font-bold">{{ total_inquiries }}</p>
</div> </div>
<div class="col-md-3"> <i data-lucide="help-circle" class="w-10 h-10 text-white/30"></i>
<div class="card bg-info text-white">
<div class="card-body">
<h6 class="card-title">{% trans "Total Inquiries" %}</h6>
<h2 class="mb-0">{{ total_inquiries }}</h2>
</div> </div>
</div> </div>
<div class="bg-gradient-to-br from-slate-500 to-slate-600 rounded-xl p-4 text-white shadow-lg shadow-slate/20">
<div class="flex items-center justify-between">
<div>
<p class="text-white/80 text-sm">{% trans "Open Inquiries" %}</p>
<p class="text-2xl font-bold">{{ open_inquiries }}</p>
</div> </div>
<div class="col-md-3"> <i data-lucide="message-circle" class="w-10 h-10 text-white/30"></i>
<div class="card bg-secondary text-white">
<div class="card-body">
<h6 class="card-title">{% trans "Open Inquiries" %}</h6>
<h2 class="mb-0">{{ open_inquiries }}</h2>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Recent Complaints -->
<!-- Complaints Table --> <div class="bg-white rounded-xl shadow-sm border border-slate-200 mb-6">
<div class="row mb-4"> <div class="p-4 border-b border-slate-200 bg-slate-50/50 rounded-t-xl flex justify-between items-center">
<div class="col-12"> <h2 class="text-lg font-semibold text-navy flex items-center gap-2">
<div class="card"> <i data-lucide="file-text" class="w-5 h-5 text-slate"></i>
<div class="card-header"> {% trans "Recent Complaints" %} ({{ complaints|length }})
<h5 class="card-title mb-0"> </h2>
{% action_icon 'filter' %} {% trans "Recent Complaints" %} ({{ complaints|length }})
</h5>
</div> </div>
<div class="card-body"> <div class="p-4">
<div class="table-responsive"> {% if complaints %}
<table class="table table-hover"> <div class="overflow-x-auto">
<thead class="table-light"> <table class="w-full">
<tr> <thead>
<th>{% trans "ID" %}</th> <tr class="border-b border-slate-200">
<th>{% trans "Title" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "ID" %}</th>
<th>{% trans "Patient" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Title" %}</th>
<th>{% trans "Category" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Patient" %}</th>
<th>{% trans "Status" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Category" %}</th>
<th>{% trans "Priority" %}</th> <th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Status" %}</th>
<th>{% trans "Created" %}</th> <th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Priority" %}</th>
<th>{% trans "Actions" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Created" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100">
{% for complaint in complaints %} {% for complaint in complaints|slice:":10" %}
<tr> <tr class="hover:bg-slate-50 transition">
<td><code>{{ complaint.id|slice:":8" }}</code></td> <td class="py-3 px-4 text-sm font-mono text-slate">{{ complaint.reference_number|default:complaint.id|truncatechars:12 }}</td>
<td>{{ complaint.title|truncatewords:8 }}</td> <td class="py-3 px-4">
<td>{{ complaint.patient.get_full_name }}</td>
<td>{{ complaint.get_category_display }}</td>
<td>
{% if complaint.status == 'open' %}
<span class="badge bg-danger">{% trans "Open" %}</span>
{% elif complaint.status == 'in_progress' %}
<span class="badge bg-warning text-dark">{% trans "In Progress" %}</span>
{% elif complaint.status == 'resolved' %}
<span class="badge bg-success">{% trans "Resolved" %}</span>
{% else %}
<span class="badge bg-secondary">{% trans "Closed" %}</span>
{% endif %}
</td>
<td>
{% if complaint.priority == 'high' %}
<span class="badge bg-danger">{% trans "High" %}</span>
{% elif complaint.priority == 'medium' %}
<span class="badge bg-warning text-dark">{% trans "Medium" %}</span>
{% else %}
<span class="badge bg-success">{% trans "Low" %}</span>
{% endif %}
</td>
<td>{{ complaint.created_at|date:"Y-m-d" }}</td>
<td>
<a href="{% url 'complaints:complaint_detail' complaint.pk %}" <a href="{% url 'complaints:complaint_detail' complaint.pk %}"
class="btn btn-sm btn-info" class="text-navy font-semibold hover:text-blue transition">
title="{% trans 'View' %}"> {{ complaint.title|truncatechars:40 }}
{% action_icon 'view' %}
</a> </a>
</td> </td>
</tr> <td class="py-3 px-4 text-sm text-slate">{{ complaint.patient.get_full_name|default:"-" }}</td>
{% empty %} <td class="py-3 px-4 text-sm text-slate">{{ complaint.category|default:"-" }}</td>
<tr> <td class="py-3 px-4 text-center">
<td colspan="8" class="text-center py-4"> {% if complaint.status == 'open' %}
<p class="text-muted mb-2"> <span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">{% trans "Open" %}</span>
<i class="bi bi-inbox fs-1"></i> {% elif complaint.status == 'in_progress' %}
</p> <span class="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-medium">{% trans "In Progress" %}</span>
<p>{% trans "No complaints found for this source." %}</p> {% elif complaint.status == 'resolved' %}
<span class="inline-flex items-center px-2 py-1 bg-green-100 text-green-700 rounded text-xs font-medium">{% trans "Resolved" %}</span>
{% else %}
<span class="inline-flex items-center px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs font-medium">{{ complaint.get_status_display }}</span>
{% endif %}
</td> </td>
<td class="py-3 px-4 text-center">
{% if complaint.priority == 'high' %}
<span class="inline-flex items-center px-2 py-1 bg-red-100 text-red-700 rounded text-xs font-medium">{% trans "High" %}</span>
{% elif complaint.priority == 'medium' %}
<span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">{% trans "Medium" %}</span>
{% else %}
<span class="inline-flex items-center px-2 py-1 bg-green-100 text-green-700 rounded text-xs font-medium">{% trans "Low" %}</span>
{% endif %}
</td>
<td class="py-3 px-4 text-sm text-slate">{{ complaint.created_at|date:"Y-m-d" }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %}
<div class="text-center py-8 text-slate">
<i data-lucide="file-text" class="w-12 h-12 mx-auto mb-3 opacity-30"></i>
<p>{% trans "No complaints found from this source." %}</p>
</div> </div>
</div> {% endif %}
</div> </div>
</div> </div>
<!-- Inquiries Table --> <!-- Recent Inquiries -->
<div class="row"> <div class="bg-white rounded-xl shadow-sm border border-slate-200">
<div class="col-12"> <div class="p-4 border-b border-slate-200 bg-slate-50/50 rounded-t-xl flex justify-between items-center">
<div class="card"> <h2 class="text-lg font-semibold text-navy flex items-center gap-2">
<div class="card-header"> <i data-lucide="help-circle" class="w-5 h-5 text-slate"></i>
<h5 class="card-title mb-0"> {% trans "Recent Inquiries" %} ({{ inquiries|length }})
{% action_icon 'filter' %} {% trans "Recent Inquiries" %} ({{ inquiries|length }}) </h2>
</h5>
</div> </div>
<div class="card-body"> <div class="p-4">
<div class="table-responsive"> {% if inquiries %}
<table class="table table-hover"> <div class="overflow-x-auto">
<thead class="table-light"> <table class="w-full">
<tr> <thead>
<th>{% trans "ID" %}</th> <tr class="border-b border-slate-200">
<th>{% trans "Subject" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "ID" %}</th>
<th>{% trans "Patient" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Subject" %}</th>
<th>{% trans "Category" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "From" %}</th>
<th>{% trans "Status" %}</th> <th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Status" %}</th>
<th>{% trans "Created" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Created" %}</th>
<th>{% trans "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100">
{% for inquiry in inquiries %} {% for inquiry in inquiries|slice:":10" %}
<tr> <tr class="hover:bg-slate-50 transition">
<td><code>{{ inquiry.id|slice:":8" }}</code></td> <td class="py-3 px-4 text-sm font-mono text-slate">{{ inquiry.reference_number|default:inquiry.id|truncatechars:12 }}</td>
<td>{{ inquiry.subject|truncatewords:8 }}</td> <td class="py-3 px-4">
<td> <span class="text-navy font-medium">{{ inquiry.subject|truncatechars:40 }}</span>
{% if inquiry.patient %}
{{ inquiry.patient.get_full_name }}
{% else %}
{{ inquiry.contact_name|default:"-" }}
{% endif %}
</td> </td>
<td>{{ inquiry.get_category_display }}</td> <td class="py-3 px-4 text-sm text-slate">{{ inquiry.name }}</td>
<td> <td class="py-3 px-4 text-center">
{% if inquiry.status == 'open' %} {% if inquiry.status == 'open' %}
<span class="badge bg-danger">{% trans "Open" %}</span> <span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">{% trans "Open" %}</span>
{% elif inquiry.status == 'in_progress' %} {% elif inquiry.status == 'in_progress' %}
<span class="badge bg-warning text-dark">{% trans "In Progress" %}</span> <span class="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-medium">{% trans "In Progress" %}</span>
{% elif inquiry.status == 'resolved' %} {% elif inquiry.status == 'resolved' %}
<span class="badge bg-success">{% trans "Resolved" %}</span> <span class="inline-flex items-center px-2 py-1 bg-green-100 text-green-700 rounded text-xs font-medium">{% trans "Resolved" %}</span>
{% else %} {% else %}
<span class="badge bg-secondary">{% trans "Closed" %}</span> <span class="inline-flex items-center px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs font-medium">{{ inquiry.get_status_display }}</span>
{% endif %} {% endif %}
</td> </td>
<td>{{ inquiry.created_at|date:"Y-m-d" }}</td> <td class="py-3 px-4 text-sm text-slate">{{ inquiry.created_at|date:"Y-m-d" }}</td>
<td>
<a href="{% url 'complaints:inquiry_detail' inquiry.pk %}"
class="btn btn-sm btn-info"
title="{% trans 'View' %}">
{% action_icon 'view' %}
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center py-4">
<p class="text-muted mb-2">
<i class="bi bi-inbox fs-1"></i>
</p>
<p>{% trans "No inquiries found for this source." %}</p>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %}
<div class="text-center py-8 text-slate">
<i data-lucide="help-circle" class="w-12 h-12 mx-auto mb-3 opacity-30"></i>
<p>{% trans "No inquiries found from this source." %}</p>
</div> </div>
</div> {% endif %}
</div> </div>
</div> </div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %} {% endblock %}

View File

@ -4,60 +4,60 @@
{% block title %}{% if source_user %}{% trans "Edit Source User" %}{% else %}{% trans "Create Source User" %}{% endif %} - {{ source.name_en }}{% endblock %} {% block title %}{% if source_user %}{% trans "Edit Source User" %}{% else %}{% trans "Create Source User" %}{% endif %} - {{ source.name_en }}{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="p-6">
<!-- Page Header --> <!-- Breadcrumb -->
<div class="d-flex justify-content-between align-items-center mb-4"> <nav class="mb-4">
<div> <ol class="flex items-center gap-2 text-sm text-slate">
<nav aria-label="breadcrumb"> <li><a href="{% url 'px_sources:source_list' %}" class="text-blue hover:text-navy">{% trans "PX Sources" %}</a></li>
<ol class="breadcrumb mb-2"> <li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li class="breadcrumb-item"> <li><a href="{% url 'px_sources:source_detail' source.pk %}" class="text-blue hover:text-navy">{{ source.name_en }}</a></li>
<a href="{% url 'px_sources:source_list' %}">{% trans "PX Sources" %}</a> <li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
</li> <li class="text-navy font-semibold">
<li class="breadcrumb-item">
<a href="{% url 'px_sources:source_detail' source.pk %}">{{ source.name_en }}</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
{% if source_user %}{% trans "Edit Source User" %}{% else %}{% trans "Create Source User" %}{% endif %} {% if source_user %}{% trans "Edit Source User" %}{% else %}{% trans "Create Source User" %}{% endif %}
</li> </li>
</ol> </ol>
</nav> </nav>
<h2 class="mb-1">
<!-- Page Header -->
<div class="flex flex-wrap justify-between items-center gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
{% if source_user %} {% if source_user %}
<i class="bi bi-person-gear me-2"></i>{% trans "Edit Source User" %} <i data-lucide="user-cog" class="w-8 h-8 text-blue"></i>{% trans "Edit Source User" %}
{% else %} {% else %}
<i class="bi bi-person-plus me-2"></i>{% trans "Create Source User" %} <i data-lucide="user-plus" class="w-8 h-8 text-blue"></i>{% trans "Create Source User" %}
{% endif %} {% endif %}
</h2> </h1>
<p class="text-muted mb-0"> <p class="text-slate mt-1">{{ source.name_en }}</p>
{{ source.name_en }}
</p>
</div> </div>
<div> <div>
<a href="{% url 'px_sources:source_detail' source.pk %}" class="btn btn-outline-secondary"> <a href="{% url 'px_sources:source_detail' source.pk %}"
<i class="bi bi-arrow-left me-1"></i> {% trans "Back to Source" %} class="inline-flex items-center gap-2 px-4 py-2 border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition">
<i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to Source" %}
</a> </a>
</div> </div>
</div> </div>
<!-- Form Card --> <!-- Form Card -->
<div class="row"> <div class="bg-white rounded-xl shadow-sm border border-slate-200 max-w-3xl">
<div class="col-12"> <div class="p-4 border-b border-slate-200 bg-slate-50/50 rounded-t-xl">
<div class="card"> <h2 class="text-lg font-semibold text-navy flex items-center gap-2">
<div class="card-header"> <i data-lucide="settings" class="w-5 h-5 text-slate"></i>
<h5 class="card-title mb-0"> {% trans "Source User Details" %}
<i class="bi bi-gear me-2"></i>{% trans "Source User Details" %} </h2>
</h5>
</div> </div>
<div class="card-body"> <div class="p-6">
<form method="POST" novalidate> <form method="POST" novalidate>
{% csrf_token %} {% csrf_token %}
{% if not source_user %} {% if not source_user %}
<!-- User Selection (only for new source users) --> <!-- User Selection (only for new source users) -->
<div class="row mb-3"> <div class="mb-4">
<div class="col-md-6"> <label for="id_user" class="block text-sm font-semibold text-navy mb-2">
<label for="id_user" class="form-label">{% trans "User" %} <span class="text-danger">*</span></label> {% trans "User" %} <span class="text-red-500">*</span>
<select name="user" id="id_user" class="form-select" required> </label>
<select name="user" id="id_user" required
class="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:border-blue focus:ring-2 focus:ring-blue/20 bg-white">
<option value="">{% trans "Select a user" %}</option> <option value="">{% trans "Select a user" %}</option>
{% for user in available_users %} {% for user in available_users %}
<option value="{{ user.id }}" {% if form.user.value == user.id %}selected{% endif %}> <option value="{{ user.id }}" {% if form.user.value == user.id %}selected{% endif %}>
@ -65,81 +65,86 @@
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
<div class="form-text"> <p class="text-slate text-sm mt-1">
{% trans "Select a user to assign as source user. A user can only manage one source." %} {% trans "Select a user to assign as source user. A user can only manage one source." %}
</div> </p>
</div>
</div> </div>
{% else %} {% else %}
<!-- User Display (for editing) --> <!-- User Display (for editing) -->
<div class="row mb-3"> <div class="mb-4">
<div class="col-md-6"> <label class="block text-sm font-semibold text-navy mb-2">{% trans "User" %}</label>
<label class="form-label">{% trans "User" %}</label> <input type="text"
<input type="text" class="form-control" value="{{ source_user.user.email }} {% if source_user.user.get_full_name %}({{ source_user.user.get_full_name }}){% endif %}" readonly> value="{{ source_user.user.email }} {% if source_user.user.get_full_name %}({{ source_user.user.get_full_name }}){% endif %}"
</div> readonly
class="w-full px-4 py-2 border border-slate-200 rounded-lg bg-slate-100 text-slate-600 cursor-not-allowed">
</div> </div>
{% endif %} {% endif %}
<!-- Status --> <!-- Status -->
<div class="row mb-3"> <div class="mb-6">
<div class="col-md-6"> <label class="block text-sm font-semibold text-navy mb-2">{% trans "Status" %}</label>
<label class="form-label">{% trans "Status" %}</label> <label class="flex items-center gap-3 cursor-pointer">
<div class="form-check form-switch"> <input type="checkbox" name="is_active" id="id_is_active"
<input class="form-check-input" type="checkbox" name="is_active" id="id_is_active" {% if source_user.is_active|default:True %}checked{% endif %}> {% if source_user.is_active|default:True %}checked{% endif %}
<label class="form-check-label" for="id_is_active"> class="w-5 h-5 text-navy border-slate-300 rounded focus:ring-blue">
{% trans "Active" %} <span class="text-navy font-medium">{% trans "Active" %}</span>
</label> </label>
</div> <p class="text-slate text-sm mt-1">
<div class="form-text">
{% trans "Inactive users will not be able to access their dashboard." %} {% trans "Inactive users will not be able to access their dashboard." %}
</div> </p>
</div>
</div> </div>
<hr> <div class="border-t border-slate-200 my-6"></div>
<!-- Permissions --> <!-- Permissions -->
<h5 class="mb-3">{% trans "Permissions" %}</h5> <h5 class="text-lg font-semibold text-navy mb-4">{% trans "Permissions" %}</h5>
<div class="row mb-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="col-md-6"> <div>
<div class="form-check"> <label class="flex items-center gap-3 cursor-pointer">
<input class="form-check-input" type="checkbox" name="can_create_complaints" id="id_can_create_complaints" {% if source_user.can_create_complaints|default:True %}checked{% endif %}> <input type="checkbox" name="can_create_complaints" id="id_can_create_complaints"
<label class="form-check-label" for="id_can_create_complaints"> {% if source_user.can_create_complaints|default:True %}checked{% endif %}
{% trans "Can create complaints" %} class="w-5 h-5 text-navy border-slate-300 rounded focus:ring-blue">
<span class="text-navy">{% trans "Can create complaints" %}</span>
</label>
</div>
<div>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="can_create_inquiries" id="id_can_create_inquiries"
{% if source_user.can_create_inquiries|default:True %}checked{% endif %}
class="w-5 h-5 text-navy border-slate-300 rounded focus:ring-blue">
<span class="text-navy">{% trans "Can create inquiries" %}</span>
</label> </label>
</div> </div>
</div> </div>
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="can_create_inquiries" id="id_can_create_inquiries" {% if source_user.can_create_inquiries|default:True %}checked{% endif %}>
<label class="form-check-label" for="id_can_create_inquiries">
{% trans "Can create inquiries" %}
</label>
</div>
</div>
</div>
<div class="alert alert-info"> <!-- Info Alert -->
<i class="bi bi-info-circle me-2"></i> <div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
{% trans "Permissions control what the source user can do in their dashboard. Uncheck to restrict access." %} <div class="flex items-center gap-3">
<i data-lucide="info" class="w-5 h-5 text-blue-600"></i>
<span class="text-blue-700 text-sm">{% trans "Permissions control what the source user can do in their dashboard. Uncheck to restrict access." %}</span>
</div>
</div> </div>
<!-- Submit Buttons --> <!-- Submit Buttons -->
<hr> <div class="border-t border-slate-200 pt-4 flex gap-3">
<button type="submit"
<div class="d-flex gap-2"> class="inline-flex items-center gap-2 px-6 py-2 bg-navy text-white rounded-lg hover:bg-blue transition shadow-lg shadow-navy/20">
<button type="submit" class="btn btn-primary"> <i data-lucide="check" class="w-4 h-4"></i> {% trans "Save" %}
<i class="bi bi-check-lg me-1"></i> {% trans "Save" %}
</button> </button>
<a href="{% url 'px_sources:source_detail' source.pk %}" class="btn btn-outline-secondary"> <a href="{% url 'px_sources:source_detail' source.pk %}"
<i class="bi bi-x-lg me-1"></i> {% trans "Cancel" %} class="inline-flex items-center gap-2 px-6 py-2 border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition">
<i data-lucide="x" class="w-4 h-4"></i> {% trans "Cancel" %}
</a> </a>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %} {% endblock %}

View File

@ -4,42 +4,53 @@
{% block title %}{% trans "My Inquiries" %} - {{ source.name_en }}{% endblock %} {% block title %}{% trans "My Inquiries" %} - {{ source.name_en }}{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="p-6">
<!-- Page Header --> <!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="flex flex-wrap justify-between items-center gap-4 mb-6">
<div> <div>
<h2 class="mb-1"> <h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<i class="bi bi-question-circle-fill text-info me-2"></i> <i data-lucide="help-circle" class="w-8 h-8 text-cyan-500"></i>
{% trans "My Inquiries" %} {% trans "My Inquiries" %}
<span class="badge bg-info">{{ inquiries_count }}</span> <span class="inline-flex items-center px-3 py-1 bg-blue text-white rounded-full text-sm font-medium">{{ inquiries_count }}</span>
</h2> </h1>
<p class="text-muted mb-0"> <p class="text-slate mt-1">
{% trans "View all inquiries from your source" %} {% trans "View all inquiries from your source" %}
</p> </p>
</div> </div>
{% if source_user.can_create_inquiries %} {% if source_user.can_create_inquiries %}
<a href="{% url 'complaints:inquiry_create' %}" class="btn btn-primary"> <a href="{% url 'complaints:inquiry_create' %}"
<i class="bi bi-plus-circle me-1"></i> {% trans "Create Inquiry" %} class="inline-flex items-center gap-2 px-4 py-2 bg-navy text-white rounded-lg hover:bg-blue transition shadow-lg shadow-navy/20">
<i data-lucide="plus-circle" class="w-4 h-4"></i>
{% trans "Create Inquiry" %}
</a> </a>
{% endif %} {% endif %}
</div> </div>
<!-- Filter Panel --> <!-- Filter Panel -->
<div class="card mb-4"> <div class="bg-white rounded-xl shadow-sm border border-slate-200 mb-6">
<div class="card-body"> <div class="p-4 border-b border-slate-200 bg-slate-50/50 rounded-t-xl">
<form method="get" class="row g-3"> <h2 class="text-sm font-semibold text-navy flex items-center gap-2">
<i data-lucide="filter" class="w-4 h-4 text-slate"></i>
{% trans "Filters" %}
</h2>
</div>
<div class="p-4">
<form method="get" class="grid grid-cols-1 md:grid-cols-12 gap-4">
<!-- Search --> <!-- Search -->
<div class="col-md-5"> <div class="md:col-span-5">
<label class="form-label">{% trans "Search" %}</label> <label class="block text-sm font-medium text-slate mb-1">{% trans "Search" %}</label>
<input type="text" class="form-control" name="search" <input type="text"
class="w-full px-3 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue focus:border-blue outline-none transition"
name="search"
placeholder="{% trans 'Subject, contact name...' %}" placeholder="{% trans 'Subject, contact name...' %}"
value="{{ search|default:'' }}"> value="{{ search|default:'' }}">
</div> </div>
<!-- Status --> <!-- Status -->
<div class="col-md-3"> <div class="md:col-span-3">
<label class="form-label">{% trans "Status" %}</label> <label class="block text-sm font-medium text-slate mb-1">{% trans "Status" %}</label>
<select class="form-select" name="status"> <select class="w-full px-3 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue focus:border-blue outline-none transition bg-white"
name="status">
<option value="">{% trans "All Statuses" %}</option> <option value="">{% trans "All Statuses" %}</option>
<option value="open" {% if status_filter == 'open' %}selected{% endif %}>{% trans "Open" %}</option> <option value="open" {% if status_filter == 'open' %}selected{% endif %}>{% trans "Open" %}</option>
<option value="in_progress" {% if status_filter == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option> <option value="in_progress" {% if status_filter == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
@ -49,9 +60,10 @@
</div> </div>
<!-- Category --> <!-- Category -->
<div class="col-md-2"> <div class="md:col-span-2">
<label class="form-label">{% trans "Category" %}</label> <label class="block text-sm font-medium text-slate mb-1">{% trans "Category" %}</label>
<select class="form-select" name="category"> <select class="w-full px-3 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue focus:border-blue outline-none transition bg-white"
name="category">
<option value="">{% trans "All Categories" %}</option> <option value="">{% trans "All Categories" %}</option>
<option value="clinical_care" {% if category_filter == 'clinical_care' %}selected{% endif %}>{% trans "Clinical Care" %}</option> <option value="clinical_care" {% if category_filter == 'clinical_care' %}selected{% endif %}>{% trans "Clinical Care" %}</option>
<option value="staff_behavior" {% if category_filter == 'staff_behavior' %}selected{% endif %}>{% trans "Staff Behavior" %}</option> <option value="staff_behavior" {% if category_filter == 'staff_behavior' %}selected{% endif %}>{% trans "Staff Behavior" %}</option>
@ -64,14 +76,16 @@
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="col-md-2 d-flex align-items-end"> <div class="md:col-span-2 flex items-end">
<div class="d-flex gap-2 w-100"> <div class="flex gap-2 w-full">
<button type="submit" class="btn btn-primary flex-grow-1"> <button type="submit"
<i class="bi bi-search me-1"></i> {% trans "Filter" %} class="inline-flex items-center justify-center gap-2 px-4 py-2 bg-navy text-white rounded-lg hover:bg-blue transition flex-grow">
<i data-lucide="search" class="w-4 h-4"></i>
{% trans "Filter" %}
</button> </button>
<a href="{% url 'px_sources:source_user_inquiry_list' %}" <a href="{% url 'px_sources:source_user_inquiry_list' %}"
class="btn btn-outline-secondary"> class="inline-flex items-center justify-center p-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-slate">
<i class="bi bi-x-circle"></i> <i data-lucide="x-circle" class="w-4 h-4"></i>
</a> </a>
</div> </div>
</div> </div>
@ -80,74 +94,88 @@
</div> </div>
<!-- Inquiries Table --> <!-- Inquiries Table -->
<div class="card"> <div class="bg-white rounded-xl shadow-sm border border-slate-200">
<div class="card-body p-0"> <div class="p-4 border-b border-slate-200 bg-slate-50/50 rounded-t-xl">
<div class="table-responsive"> <h2 class="text-lg font-semibold text-navy flex items-center gap-2">
<table class="table table-hover mb-0"> <i data-lucide="help-circle" class="w-5 h-5 text-slate"></i>
<thead class="table-light"> {% trans "Inquiries List" %}
<tr> </h2>
<th>{% trans "ID" %}</th> </div>
<th>{% trans "Subject" %}</th> <div class="overflow-x-auto">
<th>{% trans "Contact" %}</th> <table class="w-full">
<th>{% trans "Category" %}</th> <thead>
<th>{% trans "Status" %}</th> <tr class="border-b border-slate-200">
<th>{% trans "Assigned To" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "ID" %}</th>
<th>{% trans "Created" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Subject" %}</th>
<th>{% trans "Actions" %}</th> <th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Contact" %}</th>
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Category" %}</th>
<th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Status" %}</th>
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Assigned To" %}</th>
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Created" %}</th>
<th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100">
{% for inquiry in inquiries %} {% for inquiry in inquiries %}
<tr> <tr class="hover:bg-slate-50 transition">
<td><code>{{ inquiry.id|slice:":8" }}</code></td> <td class="py-3 px-4 text-sm font-mono text-slate">{{ inquiry.id|slice:":8" }}</td>
<td>{{ inquiry.subject|truncatewords:8 }}</td> <td class="py-3 px-4">
<td> <a href="{% url 'complaints:inquiry_detail' inquiry.pk %}"
class="text-navy font-semibold hover:text-blue transition">
{{ inquiry.subject|truncatewords:8 }}
</a>
</td>
<td class="py-3 px-4">
{% if inquiry.patient %} {% if inquiry.patient %}
<strong>{{ inquiry.patient.get_full_name }}</strong><br> <div class="font-medium text-slate">{{ inquiry.patient.get_full_name }}</div>
<small class="text-muted">{% trans "MRN" %}: {{ inquiry.patient.mrn }}</small> <div class="text-xs text-slate/70">{% trans "MRN" %}: {{ inquiry.patient.mrn }}</div>
{% else %} {% else %}
{{ inquiry.contact_name|default:"-" }}<br> <div class="font-medium text-slate">{{ inquiry.contact_name|default:"-" }}</div>
<small class="text-muted">{{ inquiry.contact_email|default:"-" }}</small> <div class="text-xs text-slate/70">{{ inquiry.contact_email|default:"-" }}</div>
{% endif %} {% endif %}
</td> </td>
<td><span class="badge bg-secondary">{{ inquiry.get_category_display }}</span></td> <td class="py-3 px-4">
<td> <span class="inline-flex items-center px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs font-medium">
{{ inquiry.get_category_display }}
</span>
</td>
<td class="py-3 px-4 text-center">
{% if inquiry.status == 'open' %} {% if inquiry.status == 'open' %}
<span class="badge bg-danger">{% trans "Open" %}</span> <span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">{% trans "Open" %}</span>
{% elif inquiry.status == 'in_progress' %} {% elif inquiry.status == 'in_progress' %}
<span class="badge bg-warning text-dark">{% trans "In Progress" %}</span> <span class="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-medium">{% trans "In Progress" %}</span>
{% elif inquiry.status == 'resolved' %} {% elif inquiry.status == 'resolved' %}
<span class="badge bg-success">{% trans "Resolved" %}</span> <span class="inline-flex items-center px-2 py-1 bg-green-100 text-green-700 rounded text-xs font-medium">{% trans "Resolved" %}</span>
{% else %} {% else %}
<span class="badge bg-secondary">{% trans "Closed" %}</span> <span class="inline-flex items-center px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs font-medium">{% trans "Closed" %}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td class="py-3 px-4 text-sm text-slate">
{% if inquiry.assigned_to %} {% if inquiry.assigned_to %}
{{ inquiry.assigned_to.get_full_name }} {{ inquiry.assigned_to.get_full_name }}
{% else %} {% else %}
<span class="text-muted"><em>{% trans "Unassigned" %}</em></span> <span class="text-slate/60 italic">{% trans "Unassigned" %}</span>
{% endif %} {% endif %}
</td> </td>
<td><small class="text-muted">{{ inquiry.created_at|date:"Y-m-d" }}</small></td> <td class="py-3 px-4 text-sm text-slate">{{ inquiry.created_at|date:"Y-m-d" }}</td>
<td> <td class="py-3 px-4 text-center">
<a href="{% url 'complaints:inquiry_detail' inquiry.pk %}" <a href="{% url 'complaints:inquiry_detail' inquiry.pk %}"
class="btn btn-sm btn-info" class="inline-flex items-center justify-center p-2 bg-blue text-white rounded-lg hover:bg-navy transition"
title="{% trans 'View' %}"> title="{% trans 'View' %}">
<i class="bi bi-eye"></i> <i data-lucide="eye" class="w-4 h-4"></i>
</a> </a>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="8" class="text-center py-5"> <td colspan="8" class="text-center py-12">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i> <i data-lucide="inbox" class="w-16 h-16 mx-auto text-slate/30 mb-4"></i>
<p class="text-muted mt-3"> <p class="text-slate mb-4">{% trans "No inquiries found for your source." %}</p>
{% trans "No inquiries found for your source." %}
</p>
{% if source_user.can_create_inquiries %} {% if source_user.can_create_inquiries %}
<a href="{% url 'complaints:inquiry_create' %}" class="btn btn-primary"> <a href="{% url 'complaints:inquiry_create' %}"
<i class="bi bi-plus-circle me-1"></i> {% trans "Create Inquiry" %} class="inline-flex items-center gap-2 px-4 py-2 bg-navy text-white rounded-lg hover:bg-blue transition">
<i data-lucide="plus-circle" class="w-4 h-4"></i>
{% trans "Create Inquiry" %}
</a> </a>
{% endif %} {% endif %}
</td> </td>
@ -157,31 +185,35 @@
</table> </table>
</div> </div>
</div> </div>
</div>
<!-- Pagination --> <!-- Pagination -->
{% if inquiries.has_other_pages %} {% if inquiries.has_other_pages %}
<nav aria-label="Inquiries pagination" class="mt-4"> <nav aria-label="Inquiries pagination" class="mt-6">
<ul class="pagination justify-content-center"> <ul class="flex justify-center items-center gap-2">
{% if inquiries.has_previous %} {% if inquiries.has_previous %}
<li class="page-item"> <li>
<a class="page-link" href="?page=1&search={{ search }}&status={{ status_filter }}&category={{ category_filter }}"> <a class="inline-flex items-center justify-center w-10 h-10 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-slate"
<i class="bi bi-chevron-double-left"></i> href="?page=1&search={{ search }}&status={{ status_filter }}&category={{ category_filter }}">
<i data-lucide="chevrons-left" class="w-4 h-4"></i>
</a> </a>
</li> </li>
<li class="page-item"> <li>
<a class="page-link" href="?page={{ inquiries.previous_page_number }}&search={{ search }}&status={{ status_filter }}&category={{ category_filter }}"> <a class="inline-flex items-center justify-center w-10 h-10 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-slate"
<i class="bi bi-chevron-left"></i> href="?page={{ inquiries.previous_page_number }}&search={{ search }}&status={{ status_filter }}&category={{ category_filter }}">
<i data-lucide="chevron-left" class="w-4 h-4"></i>
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% for num in inquiries.paginator.page_range %} {% for num in inquiries.paginator.page_range %}
{% if inquiries.number == num %} {% if inquiries.number == num %}
<li class="page-item active"><span class="page-link">{{ num }}</span></li> <li>
<span class="inline-flex items-center justify-center w-10 h-10 bg-navy text-white rounded-lg font-medium">{{ num }}</span>
</li>
{% elif num > inquiries.number|add:'-3' and num < inquiries.number|add:'3' %} {% elif num > inquiries.number|add:'-3' and num < inquiries.number|add:'3' %}
<li class="page-item"> <li>
<a class="page-link" href="?page={{ num }}&search={{ search }}&status={{ status_filter }}&category={{ category_filter }}"> <a class="inline-flex items-center justify-center w-10 h-10 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-slate"
href="?page={{ num }}&search={{ search }}&status={{ status_filter }}&category={{ category_filter }}">
{{ num }} {{ num }}
</a> </a>
</li> </li>
@ -189,14 +221,16 @@
{% endfor %} {% endfor %}
{% if inquiries.has_next %} {% if inquiries.has_next %}
<li class="page-item"> <li>
<a class="page-link" href="?page={{ inquiries.next_page_number }}&search={{ search }}&status={{ status_filter }}&category={{ category_filter }}"> <a class="inline-flex items-center justify-center w-10 h-10 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-slate"
<i class="bi bi-chevron-right"></i> href="?page={{ inquiries.next_page_number }}&search={{ search }}&status={{ status_filter }}&category={{ category_filter }}">
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</a> </a>
</li> </li>
<li class="page-item"> <li>
<a class="page-link" href="?page={{ inquiries.paginator.num_pages }}&search={{ search }}&status={{ status_filter }}&category={{ category_filter }}"> <a class="inline-flex items-center justify-center w-10 h-10 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-slate"
<i class="bi bi-chevron-double-right"></i> href="?page={{ inquiries.paginator.num_pages }}&search={{ search }}&status={{ status_filter }}&category={{ category_filter }}">
<i data-lucide="chevrons-right" class="w-4 h-4"></i>
</a> </a>
</li> </li>
{% endif %} {% endif %}
@ -204,4 +238,10 @@
</nav> </nav>
{% endif %} {% endif %}
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %} {% endblock %}

View File

@ -159,7 +159,7 @@
{% if response.question.question_type in 'multiple_choice,single_choice' %} {% if response.question.question_type in 'multiple_choice,single_choice' %}
<div class="mb-4"> <div class="mb-4">
<div class="bg-blue-50 rounded-xl p-4 mb-3"> <div class="bg-blue-50 rounded-xl p-4 mb-3">
<strong class="text-blue-700">{% trans "Response" %}:</strong> {{ response.choice_value }} <strong class="text-blue-700">{% trans "Response" %}:</strong> {{ response.choice_value }} {{ response.text_value }}
</div> </div>
{% if response.question.id in question_stats and question_stats|get_item:response.question.id.type == 'choice' %} {% if response.question.id in question_stats and question_stats|get_item:response.question.id.type == 'choice' %}