more changes
This commit is contained in:
parent
d07cb052f3
commit
b3d9bd17cb
5001
CALLS..csv
Normal file
5001
CALLS..csv
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
@ -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)
|
||||||
|
|||||||
@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
@ -586,4 +587,367 @@ 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
|
||||||
|
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
@ -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"
|
||||||
@ -2274,23 +2293,434 @@ class ComplaintAdverseActionAttachment(UUIDModel, TimeStampedModel):
|
|||||||
filename = models.CharField(max_length=255)
|
filename = models.CharField(max_length=255)
|
||||||
file_type = models.CharField(max_length=100, blank=True)
|
file_type = models.CharField(max_length=100, blank=True)
|
||||||
file_size = models.IntegerField(help_text=_("File size in bytes"))
|
file_size = models.IntegerField(help_text=_("File size in bytes"))
|
||||||
|
|
||||||
description = models.TextField(
|
description = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text=_("Description of what this attachment shows")
|
help_text=_("Description of what this attachment shows")
|
||||||
)
|
)
|
||||||
|
|
||||||
uploaded_by = models.ForeignKey(
|
uploaded_by = models.ForeignKey(
|
||||||
"accounts.User",
|
"accounts.User",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
related_name="adverse_action_attachments"
|
related_name="adverse_action_attachments"
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
verbose_name = _("Adverse Action Attachment")
|
verbose_name = _("Adverse Action Attachment")
|
||||||
verbose_name_plural = _("Adverse Action Attachments")
|
verbose_name_plural = _("Adverse Action Attachments")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
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):
|
def __str__(self):
|
||||||
return f"{self.adverse_action} - {self.filename}"
|
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
|
||||||
|
|||||||
0
apps/complaints/services/__init__.py
Normal file
0
apps/complaints/services/__init__.py
Normal file
243
apps/complaints/services/duplicate_detection.py
Normal file
243
apps/complaints/services/duplicate_detection.py
Normal 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)
|
||||||
@ -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
|
||||||
|
|||||||
@ -111,12 +111,34 @@ def request_explanation_form(request, pk):
|
|||||||
selected_manager_ids, request_message
|
selected_manager_ids, request_message
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(
|
# Check results and show appropriate message
|
||||||
request,
|
if results['staff_count'] == 0 and results['manager_count'] == 0:
|
||||||
_("Explanation requests sent successfully! Staff: {}, Managers notified: {}.").format(
|
if results['skipped_no_email'] > 0:
|
||||||
results['staff_count'], results['manager_count']
|
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(
|
||||||
|
request,
|
||||||
|
_("Explanation requests sent successfully! Staff: {}, Managers notified: {}.").format(
|
||||||
|
results['staff_count'], results['manager_count']
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
return redirect('complaints:complaint_detail', pk=complaint.pk)
|
return redirect('complaints:complaint_detail', pk=complaint.pk)
|
||||||
|
|
||||||
return render(request, 'complaints/request_explanation_form.html', {
|
return render(request, 'complaints/request_explanation_form.html', {
|
||||||
@ -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}
|
||||||
|
|||||||
265
apps/complaints/ui_views_templates.py
Normal file
265
apps/complaints/ui_views_templates.py
Normal 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'
|
||||||
|
)
|
||||||
|
})
|
||||||
@ -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"),
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
0
apps/physicians/management/__init__.py
Normal file
0
apps/physicians/management/__init__.py
Normal file
0
apps/physicians/management/commands/__init__.py
Normal file
0
apps/physicians/management/commands/__init__.py
Normal 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"))
|
||||||
@ -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
|
||||||
|
|||||||
@ -15,10 +15,20 @@ from apps.core.models import UUIDModel, TimeStampedModel
|
|||||||
class PXSource(UUIDModel, TimeStampedModel):
|
class PXSource(UUIDModel, TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
PX Source model for managing feedback origins.
|
PX Source model for managing feedback origins.
|
||||||
|
|
||||||
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,
|
||||||
@ -29,63 +39,153 @@ class PXSource(UUIDModel, TimeStampedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
help_text="Source name in Arabic"
|
help_text="Source name in Arabic"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Description
|
# Description
|
||||||
description = models.TextField(
|
description = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
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,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
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"""
|
||||||
if language == 'ar' and self.name_ar:
|
if language == 'ar' and self.name_ar:
|
||||||
return self.name_ar
|
return self.name_ar
|
||||||
return self.name_en
|
return self.name_en
|
||||||
|
|
||||||
def get_localized_description(self):
|
def get_localized_description(self):
|
||||||
"""Get localized description"""
|
"""Get localized description"""
|
||||||
return self.description
|
return self.description
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
"""Activate this source"""
|
"""Activate this source"""
|
||||||
if not self.is_active:
|
if not self.is_active:
|
||||||
self.is_active = True
|
self.is_active = True
|
||||||
self.save(update_fields=['is_active'])
|
self.save(update_fields=['is_active'])
|
||||||
|
|
||||||
def deactivate(self):
|
def deactivate(self):
|
||||||
"""Deactivate this source"""
|
"""Deactivate this source"""
|
||||||
if self.is_active:
|
if self.is_active:
|
||||||
self.is_active = False
|
self.is_active = False
|
||||||
self.save(update_fields=['is_active'])
|
self.save(update_fields=['is_active'])
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_active_sources(cls):
|
def get_active_sources(cls):
|
||||||
"""
|
"""
|
||||||
Get all active sources.
|
Get all active sources.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QuerySet of active PXSource objects
|
QuerySet of active PXSource objects
|
||||||
"""
|
"""
|
||||||
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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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,7 +55,8 @@ 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
|
||||||
fields = [
|
fields = [
|
||||||
@ -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,13 +82,15 @@ 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
|
||||||
fields = [
|
fields = [
|
||||||
'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,8 +119,9 @@ 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):
|
||||||
"""Get localized name based on request language"""
|
"""Get localized name based on request language"""
|
||||||
request = self.context.get('request')
|
request = self.context.get('request')
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -12,35 +12,41 @@ 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):
|
||||||
"""
|
"""
|
||||||
List all PX sources
|
List all PX sources
|
||||||
"""
|
"""
|
||||||
sources = PXSource.objects.all()
|
sources = PXSource.objects.all()
|
||||||
|
|
||||||
# Filter by active status
|
# Filter by active status
|
||||||
is_active = request.GET.get('is_active')
|
is_active = request.GET.get('is_active')
|
||||||
if is_active:
|
if is_active:
|
||||||
sources = sources.filter(is_active=is_active == 'true')
|
sources = sources.filter(is_active=is_active == 'true')
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
search = request.GET.get('search')
|
search = request.GET.get('search')
|
||||||
if search:
|
if search:
|
||||||
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')
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'sources': sources,
|
'sources': sources,
|
||||||
'is_active': is_active,
|
'is_active': is_active,
|
||||||
'search': search,
|
'search': search,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'px_sources/source_list.html', context)
|
return render(request, 'px_sources/source_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -53,21 +59,25 @@ def source_detail(request, pk):
|
|||||||
usage_records = source.usage_records.select_related(
|
usage_records = source.usage_records.select_related(
|
||||||
'content_type', 'hospital', 'user'
|
'content_type', 'hospital', 'user'
|
||||||
).order_by('-created_at')[:20]
|
).order_by('-created_at')[:20]
|
||||||
|
|
||||||
# Get source users for this source
|
# Get source users for this source
|
||||||
source_users = source.source_users.select_related('user').order_by('-created_at')
|
source_users = source.source_users.select_related('user').order_by('-created_at')
|
||||||
|
|
||||||
# Get available users (not already assigned to this source)
|
# Get available users (not already assigned to this source)
|
||||||
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,27 +86,33 @@ 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()
|
||||||
|
|
||||||
messages.success(request, _("Source created successfully!"))
|
messages.success(request, _("Source created successfully!"))
|
||||||
return redirect('px_sources:source_detail', pk=source.pk)
|
return redirect('px_sources:source_detail', pk=source.pk)
|
||||||
|
|
||||||
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,30 +122,35 @@ 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)
|
||||||
|
|
||||||
source = get_object_or_404(PXSource, pk=pk)
|
source = get_object_or_404(PXSource, pk=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()
|
||||||
|
|
||||||
messages.success(request, _("Source updated successfully!"))
|
messages.success(request, _("Source updated successfully!"))
|
||||||
return redirect('px_sources:source_detail', pk=source.pk)
|
return redirect('px_sources:source_detail', pk=source.pk)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(request, _("Error updating source: {}").format(str(e)))
|
messages.error(request, _("Error updating source: {}").format(str(e)))
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
36
config/celery_scheduler.py
Normal file
36
config/celery_scheduler.py
Normal 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()
|
||||||
@ -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 = {
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
BIN
db.sqlite3.tar.gz
Normal file
BIN
db.sqlite3.tar.gz
Normal file
Binary file not shown.
@ -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 %}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
@ -211,7 +465,7 @@
|
|||||||
<i data-lucide="building" class="w-3 h-3"></i>
|
<i data-lucide="building" class="w-3 h-3"></i>
|
||||||
{% trans "Department Breakdown" %}
|
{% trans "Department Breakdown" %}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 border-t border-l excel-border">
|
<div class="grid grid-cols-2 border-t border-l excel-border">
|
||||||
{% for dept in department_breakdowns %}
|
{% for dept in department_breakdowns %}
|
||||||
<div class="p-4 border-r border-b excel-border
|
<div class="p-4 border-r border-b excel-border
|
||||||
@ -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"> </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"> </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"> </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 }
|
||||||
},
|
},
|
||||||
@ -314,7 +729,7 @@
|
|||||||
y: { formatter: function(val) { return val + '%'; } }
|
y: { formatter: function(val) { return val + '%'; } }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const trendChart = new ApexCharts(trendChartEl, trendOptions);
|
const trendChart = new ApexCharts(trendChartEl, trendOptions);
|
||||||
trendChart.render();
|
trendChart.render();
|
||||||
@ -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'],
|
||||||
@ -343,7 +759,7 @@
|
|||||||
y: { formatter: function(val) { return val + '%'; } }
|
y: { formatter: function(val) { return val + '%'; } }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sourceChart = new ApexCharts(sourceChartEl, sourceOptions);
|
const sourceChart = new ApexCharts(sourceChartEl, sourceOptions);
|
||||||
sourceChart.render();
|
sourceChart.render();
|
||||||
@ -353,35 +769,144 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PDF Generation
|
// PDF Generation
|
||||||
function generatePDF() {
|
async function generatePDF() {
|
||||||
const element = document.getElementById('report-content');
|
const element = document.getElementById('report-content');
|
||||||
const opt = {
|
|
||||||
margin: [10, 10, 10, 10],
|
|
||||||
filename: '{{ report.kpi_id }}_{{ report.year }}_{{ report.month }}_{{ report.hospital.name|slugify }}.pdf',
|
|
||||||
image: { type: 'jpeg', quality: 0.98 },
|
|
||||||
html2canvas: {
|
|
||||||
scale: 2,
|
|
||||||
useCORS: true,
|
|
||||||
logging: false
|
|
||||||
},
|
|
||||||
jsPDF: {
|
|
||||||
unit: 'mm',
|
|
||||||
format: 'a4',
|
|
||||||
orientation: 'landscape'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show loading
|
|
||||||
const btn = document.querySelector('button[onclick="generatePDF()"]');
|
const btn = document.querySelector('button[onclick="generatePDF()"]');
|
||||||
const originalText = btn.innerHTML;
|
const originalText = btn.innerHTML;
|
||||||
|
|
||||||
|
// Show loading
|
||||||
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i> {% trans "Generating..." %}';
|
btn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i> {% trans "Generating..." %}';
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
|
||||||
html2pdf().set(opt).from(element).save().then(() => {
|
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 = {
|
||||||
|
margin: [10, 10, 10, 10],
|
||||||
|
filename: '{{ report.kpi_id }}_{{ report.year }}_{{ report.month }}_{{ report.hospital.name|slugify }}.pdf',
|
||||||
|
image: { type: 'jpeg', quality: 0.95 },
|
||||||
|
html2canvas: {
|
||||||
|
scale: 1.5,
|
||||||
|
useCORS: false, // Disable CORS to avoid tainted canvas issues
|
||||||
|
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: {
|
||||||
|
unit: 'mm',
|
||||||
|
format: 'a4',
|
||||||
|
orientation: 'landscape'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await html2pdf().set(opt).from(element).save();
|
||||||
|
|
||||||
|
} 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>
|
||||||
|
|||||||
276
templates/callcenter/call_records_list.html
Normal file
276
templates/callcenter/call_records_list.html
Normal 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>
|
||||||
226
templates/callcenter/import_call_records.html
Normal file
226
templates/callcenter/import_call_records.html
Normal 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 %}
|
||||||
@ -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,169 +171,192 @@
|
|||||||
</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">
|
||||||
<form method="get" class="flex flex-wrap gap-4">
|
<div class="card-header">
|
||||||
<div class="flex-1 min-w-[200px]">
|
<h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
|
||||||
<input type="text" name="search" value="{{ filters.search|default:'' }}"
|
<i data-lucide="filter" class="w-5 h-5"></i>
|
||||||
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent"
|
{% trans "Filters" %}
|
||||||
placeholder="{% trans 'Search by reference or description...' %}">
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="p-6">
|
||||||
<select name="status" class="px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent">
|
<form method="get" class="flex flex-wrap gap-4">
|
||||||
<option value="">{% trans "All Statuses" %}</option>
|
<div class="flex-1 min-w-[250px]">
|
||||||
{% for value, label in status_choices %}
|
<label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Search" %}</label>
|
||||||
<option value="{{ value }}" {% if filters.status == value %}selected{% endif %}>{{ label }}</option>
|
<input type="text" name="search" value="{{ filters.search|default:'' }}"
|
||||||
{% endfor %}
|
placeholder="{% trans 'Reference or description...' %}"
|
||||||
</select>
|
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="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 "Status" %}</label>
|
||||||
<option value="">{% trans "All Severities" %}</option>
|
<select name="status" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
|
||||||
{% for value, label in severity_choices %}
|
<option value="">{% trans "All Statuses" %}</option>
|
||||||
<option value="{{ value }}" {% if filters.severity == value %}selected{% endif %}>{{ label }}</option>
|
{% for value, label in status_choices %}
|
||||||
{% endfor %}
|
<option value="{{ value }}" {% if filters.status == value %}selected{% endif %}>{{ label }}</option>
|
||||||
</select>
|
{% endfor %}
|
||||||
</div>
|
</select>
|
||||||
<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">
|
<div>
|
||||||
<option value="">{% trans "All Types" %}</option>
|
<label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Severity" %}</label>
|
||||||
{% for value, label in action_type_choices %}
|
<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="{{ value }}" {% if filters.action_type == value %}selected{% endif %}>{{ label }}</option>
|
<option value="">{% trans "All Severities" %}</option>
|
||||||
{% endfor %}
|
{% for value, label in severity_choices %}
|
||||||
</select>
|
<option value="{{ value }}" {% if filters.severity == value %}selected{% endif %}>{{ label }}</option>
|
||||||
</div>
|
{% endfor %}
|
||||||
<button type="submit" class="px-4 py-2 bg-[#005696] text-white rounded-lg hover:bg-[#007bbd] transition flex items-center gap-2">
|
</select>
|
||||||
<i data-lucide="filter" class="w-4 h-4"></i>
|
</div>
|
||||||
{% trans "Filter" %}
|
<div>
|
||||||
</button>
|
<label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Type" %}</label>
|
||||||
</form>
|
<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>
|
||||||
|
{% for value, label in action_type_choices %}
|
||||||
|
<option value="{{ value }}" {% if filters.action_type == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</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>
|
||||||
</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="overflow-x-auto">
|
<div class="card-header flex items-center justify-between">
|
||||||
<table class="w-full">
|
<h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
|
||||||
<thead class="bg-gray-50">
|
<i data-lucide="shield-alert" class="w-5 h-5"></i>
|
||||||
<tr>
|
{% trans "All Adverse Actions" %} ({{ page_obj.paginator.count }})
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-[#64748b] uppercase">{% trans "Complaint" %}</th>
|
</h2>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-[#64748b] uppercase">{% trans "Type" %}</th>
|
<a href="#" class="btn-primary">
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-[#64748b] uppercase">{% trans "Severity" %}</th>
|
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-[#64748b] uppercase">{% trans "Date" %}</th>
|
{% trans "New Adverse Action" %}
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-[#64748b] uppercase">{% trans "Status" %}</th>
|
</a>
|
||||||
<th class="px-6 py-3 text-center text-xs font-medium text-[#64748b] uppercase">{% trans "Escalated" %}</th>
|
|
||||||
<th class="px-6 py-3 text-right text-xs font-medium text-[#64748b] uppercase">{% trans "Actions" %}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-100">
|
|
||||||
{% for action in page_obj %}
|
|
||||||
<tr class="hover:bg-gray-50">
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<a href="{% url 'complaints:complaint_detail' action.complaint.id %}" class="font-medium text-[#005696] hover:text-[#007bbd]">
|
|
||||||
{{ action.complaint.reference_number }}
|
|
||||||
</a>
|
|
||||||
<p class="text-sm text-[#64748b] truncate max-w-[200px]">{{ action.complaint.title }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<span class="text-sm text-gray-900">{{ action.get_action_type_display }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
{% if action.severity == 'critical' %}
|
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
|
||||||
{{ action.get_severity_display }}
|
|
||||||
</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 class="px-6 py-4 text-sm text-gray-900">
|
|
||||||
{{ action.incident_date|date:"Y-m-d" }}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
{% if action.status == 'reported' %}
|
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
|
|
||||||
{{ action.get_status_display }}
|
|
||||||
</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 class="px-6 py-4 text-center">
|
|
||||||
{% if action.is_escalated %}
|
|
||||||
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-500 mx-auto"></i>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-300">-</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-right">
|
|
||||||
<a href="{% url 'complaints:complaint_detail' action.complaint.id %}" class="text-[#005696] hover:text-[#007bbd] font-medium text-sm">
|
|
||||||
{% trans "View" %}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="7" class="px-6 py-8 text-center text-[#64748b]">
|
|
||||||
<i data-lucide="shield-check" class="w-12 h-12 mx-auto mb-3 text-gray-300"></i>
|
|
||||||
<p>{% trans "No adverse actions found." %}</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="p-0">
|
||||||
<!-- Pagination -->
|
{% if page_obj %}
|
||||||
{% if page_obj.has_other_pages %}
|
<div class="overflow-x-auto">
|
||||||
<div class="px-6 py-4 border-t border-gray-100 flex items-center justify-between">
|
<table class="w-full data-table">
|
||||||
<p class="text-sm text-[#64748b]">
|
<thead>
|
||||||
{% blocktrans with page_obj.number as page and page_obj.paginator.num_pages as total %}
|
<tr>
|
||||||
Page {{ page }} of {{ total }}
|
<th>{% trans "Complaint" %}</th>
|
||||||
{% endblocktrans %}
|
<th>{% trans "Type" %}</th>
|
||||||
</p>
|
<th>{% trans "Severity" %}</th>
|
||||||
<div class="flex gap-2">
|
<th>{% trans "Date" %}</th>
|
||||||
{% if page_obj.has_previous %}
|
<th class="text-center">{% trans "Status" %}</th>
|
||||||
<a href="?page={{ page_obj.previous_page_number }}&{{ filters.urlencode }}" class="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50">
|
<th class="text-center">{% trans "Escalated" %}</th>
|
||||||
<i data-lucide="chevron-left" class="w-4 h-4"></i>
|
<th class="text-right">{% trans "Actions" %}</th>
|
||||||
</a>
|
</tr>
|
||||||
{% endif %}
|
</thead>
|
||||||
{% if page_obj.has_next %}
|
<tbody class="divide-y divide-slate-100">
|
||||||
<a href="?page={{ page_obj.next_page_number }}&{{ filters.urlencode }}" class="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50">
|
{% for action in page_obj %}
|
||||||
<i data-lucide="chevron-right" class="w-4 h-4"></i>
|
<tr onclick="window.location='{% url 'complaints:adverse_action_edit' action.pk %}'">
|
||||||
</a>
|
<td>
|
||||||
{% endif %}
|
<a href="{% url 'complaints:complaint_detail' action.complaint.id %}"
|
||||||
|
class="font-semibold text-navy hover:text-blue transition">
|
||||||
|
{{ action.complaint.reference_number|truncatechars:15 }}
|
||||||
|
</a>
|
||||||
|
<p class="text-xs text-slate mt-1">{{ action.complaint.title|truncatechars:30 }}</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="text-sm font-medium text-slate-700">{{ action.get_action_type_display }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="severity-badge {{ action.severity }}">
|
||||||
|
<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 }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="text-sm text-slate">
|
||||||
|
<p>{{ action.incident_date|date:"Y-m-d" }}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="status-badge {{ action.status }}">
|
||||||
|
<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 }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if action.is_escalated %}
|
||||||
|
<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 %}
|
||||||
|
<span class="text-slate-400 text-sm">{% trans "No" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<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 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>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="py-12 text-center">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if page_obj.has_other_pages %}
|
||||||
|
<div class="p-4 border-t border-slate-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm text-slate">
|
||||||
|
{% 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 %}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<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 %}"
|
||||||
|
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
|
||||||
|
{% trans "Previous" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<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 %}"
|
||||||
|
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
|
||||||
|
{% trans "Next" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -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>
|
<div class="page-header animate-in">
|
||||||
<h1 class="text-3xl font-bold text-gray-800 mb-2">{% trans "Complaints Analytics" %}</h1>
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-gray-400">{% trans "Comprehensive complaints metrics and insights" %}</p>
|
<div>
|
||||||
</div>
|
<h1 class="text-2xl font-bold mb-2">
|
||||||
<div>
|
<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>
|
||||||
<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>
|
||||||
{% elif dashboard_summary.trend.percentage_change < 0 %}
|
<span class="text-red-500 font-bold">+{{ dashboard_summary.trend.percentage_change }}%</span>
|
||||||
<i data-lucide="trending-down" class="w-4 h-4 text-green-500"></i> {{ dashboard_summary.trend.percentage_change }}%
|
{% elif dashboard_summary.trend.percentage_change < 0 %}
|
||||||
{% else %}
|
<i data-lucide="trending-down" class="w-4 h-4 text-green-500"></i>
|
||||||
<i data-lucide="minus" class="w-4 h-4 text-gray-400"></i> 0%
|
<span class="text-green-500 font-bold">{{ dashboard_summary.trend.percentage_change }}%</span>
|
||||||
{% endif %}
|
|
||||||
{% trans "vs last period" %}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div class="bg-blue-100 p-3 rounded-xl">
|
|
||||||
<i data-lucide="activity" class="text-blue-500 w-6 h-6"></i>
|
|
||||||
</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="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-bold text-orange-600 uppercase mb-1">{% trans "Open" %}</div>
|
|
||||||
<div class="text-3xl font-bold text-gray-800">{{ dashboard_summary.status_counts.open }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-orange-100 p-3 rounded-xl">
|
|
||||||
<i data-lucide="folder-open" class="text-orange-500 w-6 h-6"></i>
|
|
||||||
</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="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-bold text-red-600 uppercase mb-1">{% trans "Overdue" %}</div>
|
|
||||||
<div class="text-3xl font-bold text-red-500">{{ dashboard_summary.status_counts.overdue }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-red-100 p-3 rounded-xl">
|
|
||||||
<i data-lucide="alert-triangle" class="text-red-500 w-6 h-6"></i>
|
|
||||||
</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="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-bold text-green-600 uppercase mb-1">{% trans "Resolved" %}</div>
|
|
||||||
<div class="text-3xl font-bold text-gray-800">{{ dashboard_summary.status_counts.resolved }}</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 class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
|
||||||
<!-- Complaints Trend -->
|
|
||||||
<div class="lg:col-span-2 bg-white rounded-2xl shadow-sm border border-gray-50 p-6">
|
|
||||||
<h3 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
|
|
||||||
<i data-lucide="trending-up" class="w-5 h-5"></i> {% trans "Complaints Trend" %}
|
|
||||||
</h3>
|
|
||||||
<div id="trendChart"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Top Categories -->
|
|
||||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 p-6">
|
|
||||||
<h3 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
|
|
||||||
<i data-lucide="pie-chart" class="w-5 h-5"></i> {% trans "Top Categories" %}
|
|
||||||
</h3>
|
|
||||||
<div id="categoryChart"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
|
||||||
<!-- SLA Compliance -->
|
|
||||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 p-6">
|
|
||||||
<h3 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
|
|
||||||
<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 class="grid grid-cols-2 gap-4 text-center">
|
|
||||||
<div class="bg-green-50 rounded-xl p-4">
|
|
||||||
<h4 class="text-2xl font-bold text-green-600">{{ sla_compliance.on_time }}</h4>
|
|
||||||
<small class="text-gray-500">{% trans "On Time" %}</small>
|
|
||||||
</div>
|
|
||||||
<div class="bg-red-50 rounded-xl p-4">
|
|
||||||
<h4 class="text-2xl font-bold text-red-500">{{ sla_compliance.overdue }}</h4>
|
|
||||||
<small class="text-gray-500">{% trans "Overdue" %}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Resolution Rate -->
|
|
||||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 p-6">
|
|
||||||
<h3 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
|
|
||||||
<i data-lucide="check-square" class="w-5 h-5"></i> {% trans "Resolution Metrics" %}
|
|
||||||
</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 class="h-3 bg-gray-100 rounded-full overflow-hidden">
|
|
||||||
<div class="h-full bg-green-500 rounded-full" style="width: {{ resolution_rate.resolution_rate }}%"></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 -->
|
|
||||||
{% if overdue_complaints %}
|
|
||||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-50">
|
|
||||||
<div class="p-6 border-b border-gray-100">
|
|
||||||
<h3 class="text-lg font-bold text-red-500 flex items-center gap-2">
|
|
||||||
<i data-lucide="alert-triangle" class="w-5 h-5"></i> {% trans "Overdue Complaints" %}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "ID" %}</th>
|
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Source" %}</th>
|
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Title" %}</th>
|
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Patient" %}</th>
|
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">{% trans "Severity" %}</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="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>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-50">
|
|
||||||
{% for complaint in overdue_complaints %}
|
|
||||||
<tr class="hover:bg-gray-50 transition">
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<a href="{% url 'complaints:complaint_detail' complaint.id %}" class="text-navy hover:underline">
|
|
||||||
<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 class="px-6 py-4">
|
|
||||||
{% if complaint.source_name %}
|
|
||||||
<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 %}
|
{% 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="minus" class="w-4 h-4 text-slate-400"></i>
|
||||||
<i data-lucide="user" class="w-3 h-3 inline mr-1"></i> {% trans "Patient" %}
|
<span class="text-slate-400 font-bold">0%</span>
|
||||||
</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
<span class="text-slate-500">{% trans "vs last period" %}</span>
|
||||||
<td class="px-6 py-4">
|
</div>
|
||||||
<span class="text-gray-700">{{ complaint.title|truncatechars:50 }}</span>
|
</div>
|
||||||
</td>
|
<div class="stat-icon blue">
|
||||||
<td class="px-6 py-4">
|
<i data-lucide="activity" class="text-blue-600 w-6 h-6"></i>
|
||||||
<span class="text-gray-700">{{ complaint.patient_full_name }}</span>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td class="px-6 py-4">
|
</div>
|
||||||
<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 %}">
|
|
||||||
{{ complaint.severity }}
|
<div class="stat-card orange animate-in">
|
||||||
</span>
|
<div class="flex justify-between items-start">
|
||||||
</td>
|
<div>
|
||||||
<td class="px-6 py-4">
|
<p class="text-xs font-bold text-orange-600 uppercase mb-1">{% trans "Open" %}</p>
|
||||||
<span class="text-red-500 font-semibold">{{ complaint.due_at|date:"Y-m-d H:i" }}</span>
|
<p class="text-3xl font-black text-navy">{{ dashboard_summary.status_counts.open }}</p>
|
||||||
</td>
|
</div>
|
||||||
<td class="px-6 py-4">
|
<div class="stat-icon orange">
|
||||||
<span class="text-gray-700">{{ complaint.assigned_to_full_name|default:"Unassigned" }}</span>
|
<i data-lucide="folder-open" class="text-orange-600 w-6 h-6"></i>
|
||||||
</td>
|
</div>
|
||||||
<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">
|
</div>
|
||||||
<i data-lucide="eye" class="w-4 h-4"></i>
|
|
||||||
</a>
|
<div class="stat-card red animate-in">
|
||||||
</td>
|
<div class="flex justify-between items-start">
|
||||||
</tr>
|
<div>
|
||||||
{% endfor %}
|
<p class="text-xs font-bold text-red-600 uppercase mb-1">{% trans "Overdue" %}</p>
|
||||||
</tbody>
|
<p class="text-3xl font-black text-red-500">{{ dashboard_summary.status_counts.overdue }}</p>
|
||||||
</table>
|
</div>
|
||||||
|
<div class="stat-icon red">
|
||||||
|
<i data-lucide="alert-triangle" class="text-red-600 w-6 h-6"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card green animate-in">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-bold text-green-600 uppercase mb-1">{% trans "Resolved" %}</p>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Row -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||||
|
<!-- Complaints Trend -->
|
||||||
|
<div class="lg:col-span-2 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="trending-up" class="w-5 h-5"></i>
|
||||||
|
{% trans "Complaints Trend" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div id="trendChart" style="min-height: 300px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top Categories -->
|
||||||
|
<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="pie-chart" class="w-5 h-5"></i>
|
||||||
|
{% trans "Top Categories" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<ul class="category-list">
|
||||||
|
{% for category in top_categories %}
|
||||||
|
<li class="category-item">
|
||||||
|
<div class="category-badge bg-blue-100 text-blue-600">
|
||||||
|
{{ forloop.counter }}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-semibold text-navy text-sm">{{ category.category__name_en|default:"Uncategorized" }}</p>
|
||||||
|
<p class="text-xs text-slate-500">{{ category.count }} complaints</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-bold text-navy">{{ category.percentage|floatformat:1 }}%</p>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Second Row -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
<!-- Department Distribution -->
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div id="departmentChart" style="min-height: 250px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Severity Breakdown -->
|
||||||
|
<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="circle-help" class="w-5 h-5"></i>
|
||||||
|
{% trans "Severity Breakdown" %}
|
||||||
|
</h3>
|
||||||
|
</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">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b-2 border-slate-200">
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Hospital" %}</th>
|
||||||
|
<th class="text-center py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Total" %}</th>
|
||||||
|
<th class="text-center py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Open" %}</th>
|
||||||
|
<th class="text-center py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Resolved" %}</th>
|
||||||
|
<th class="text-center py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Overdue" %}</th>
|
||||||
|
<th class="text-center py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Resolution Rate" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for hospital in hospital_performance %}
|
||||||
|
<tr class="hover:bg-slate-50 transition">
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<span class="font-semibold text-navy">{{ hospital.hospital__name }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4 text-center">
|
||||||
|
<span class="font-bold text-navy">{{ hospital.total }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4 text-center">
|
||||||
|
<span class="px-2.5 py-1 bg-blue-100 text-blue-700 rounded-full text-xs font-bold">{{ hospital.open }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4 text-center">
|
||||||
|
<span class="px-2.5 py-1 bg-green-100 text-green-700 rounded-full text-xs font-bold">{{ hospital.resolved }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4 text-center">
|
||||||
|
<span class="px-2.5 py-1 bg-red-100 text-red-700 rounded-full text-xs font-bold">{{ hospital.overdue }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4 text-center">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<div class="w-24 bg-slate-200 rounded-full h-2">
|
||||||
|
<div class="bg-green-500 h-2 rounded-full" style="width: {{ hospital.resolution_rate }}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="font-bold text-navy text-sm">{{ hospital.resolution_rate|floatformat:1 }}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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>
|
</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();
|
||||||
series: [{
|
|
||||||
name: '{% trans "Complaints" %}',
|
|
||||||
data: {{ trends.data|safe }}
|
|
||||||
}],
|
|
||||||
chart: {
|
|
||||||
type: 'line',
|
|
||||||
height: 320,
|
|
||||||
toolbar: {
|
|
||||||
show: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
stroke: {
|
|
||||||
curve: 'smooth',
|
|
||||||
width: 3
|
|
||||||
},
|
|
||||||
colors: ['#4bc0c0'],
|
|
||||||
xaxis: {
|
|
||||||
categories: {{ trends.labels|safe }},
|
|
||||||
labels: {
|
|
||||||
style: {
|
|
||||||
fontSize: '12px'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
min: 0,
|
|
||||||
forceNiceScale: true,
|
|
||||||
labels: {
|
|
||||||
style: {
|
|
||||||
fontSize: '12px'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
borderColor: '#e7e7e7',
|
|
||||||
strokeDashArray: 5
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
theme: 'light'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
var trendChart = new ApexCharts(document.querySelector("#trendChart"), trendOptions);
|
|
||||||
trendChart.render();
|
|
||||||
|
|
||||||
// Category Chart - ApexCharts
|
// Trend Chart
|
||||||
var categoryOptions = {
|
const trendOptions = {
|
||||||
series: [{% for cat in top_categories.categories %}{{ cat.count }}{% if not forloop.last %},{% endif %}{% endfor %}],
|
series: [{
|
||||||
chart: {
|
name: '{% trans "Complaints" %}',
|
||||||
type: 'donut',
|
data: {{ trend_data|safe }}
|
||||||
height: 360
|
}],
|
||||||
},
|
chart: {
|
||||||
labels: [{% for cat in top_categories.categories %}'{{ cat.category }}'{% if not forloop.last %},{% endif %}{% endfor %}],
|
type: 'area',
|
||||||
colors: ['#ff6384', '#36a2eb', '#ffce56', '#4bc0c0', '#9966ff', '#ff9f40'],
|
height: 300,
|
||||||
legend: {
|
fontFamily: 'Inter, sans-serif',
|
||||||
position: 'bottom',
|
toolbar: { show: false }
|
||||||
fontSize: '12px'
|
},
|
||||||
},
|
colors: ['#007bbd'],
|
||||||
dataLabels: {
|
dataLabels: { enabled: false },
|
||||||
enabled: true,
|
stroke: { curve: 'smooth', width: 3 },
|
||||||
formatter: function (val) {
|
fill: {
|
||||||
return val.toFixed(1) + "%"
|
type: 'gradient',
|
||||||
}
|
gradient: {
|
||||||
},
|
shadeIntensity: 1,
|
||||||
plotOptions: {
|
opacityFrom: 0.4,
|
||||||
pie: {
|
opacityTo: 0.1,
|
||||||
donut: {
|
|
||||||
size: '65%'
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
xaxis: {
|
||||||
tooltip: {
|
categories: {{ trend_labels|safe }},
|
||||||
theme: 'light'
|
labels: { style: { fontSize: '12px', colors: '#64748b' } }
|
||||||
}
|
},
|
||||||
};
|
yaxis: {
|
||||||
var categoryChart = new ApexCharts(document.querySelector("#categoryChart"), categoryOptions);
|
labels: { style: { fontSize: '12px', colors: '#64748b' } }
|
||||||
categoryChart.render();
|
},
|
||||||
|
grid: { borderColor: '#e2e8f0', strokeDashArray: 4 }
|
||||||
|
};
|
||||||
|
new ApexCharts(document.querySelector("#trendChart"), trendOptions).render();
|
||||||
|
|
||||||
|
{% if severity_data %}
|
||||||
|
// Severity Chart
|
||||||
|
const severityOptions = {
|
||||||
|
series: {{ severity_data|safe }},
|
||||||
|
chart: {
|
||||||
|
type: 'donut',
|
||||||
|
height: 250,
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
toolbar: { show: false }
|
||||||
|
},
|
||||||
|
labels: ['Low', 'Medium', 'High', 'Critical'],
|
||||||
|
colors: ['#10b981', '#f59e0b', '#ef4444', '#dc2626'],
|
||||||
|
plotOptions: {
|
||||||
|
pie: {
|
||||||
|
donut: {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
legend: { position: 'bottom', fontSize: '12px' }
|
||||||
|
};
|
||||||
|
new ApexCharts(document.querySelector("#severityChart"), severityOptions).render();
|
||||||
|
{% endif %}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -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,509 +93,360 @@
|
|||||||
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;
|
||||||
.info-label {
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #9ca3af;
|
border: none;
|
||||||
font-size: 0.75rem;
|
cursor: pointer;
|
||||||
text-transform: uppercase;
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
letter-spacing: 0.05em;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Back Button -->
|
<div class="px-4 py-6">
|
||||||
<div class="mb-6">
|
<!-- Back Button -->
|
||||||
{% if source_user %}
|
<div class="mb-6 animate-in">
|
||||||
<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">
|
{% if source_user %}
|
||||||
<i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to My Inquiries" %}
|
<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">
|
||||||
</a>
|
<i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to My Inquiries" %}
|
||||||
{% else %}
|
</a>
|
||||||
<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">
|
{% else %}
|
||||||
<i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to Inquiries" %}
|
<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">
|
||||||
</a>
|
<i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to Inquiries" %}
|
||||||
{% endif %}
|
</a>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Inquiry Header -->
|
|
||||||
<div class="bg-gradient-to-r from-cyan-500 to-teal-500 rounded-2xl p-6 text-white mb-6">
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
|
||||||
<h2 class="text-2xl font-bold">{{ inquiry.subject }}</h2>
|
|
||||||
<span class="px-3 py-1 bg-white/20 rounded-full text-sm font-semibold">
|
|
||||||
{% trans "Inquiry" %}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{% if inquiry.status == 'open' %}
|
|
||||||
<span class="px-3 py-1 bg-blue-500 rounded-full text-sm font-semibold">{% trans "Open" %}</span>
|
|
||||||
{% elif inquiry.status == 'in_progress' %}
|
|
||||||
<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 == 'low' %}
|
|
||||||
<span class="px-3 py-1 bg-green-200 text-green-800 rounded-full text-sm font-semibold">{% trans "Low" %}</span>
|
|
||||||
{% 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>
|
|
||||||
{% 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>
|
|
||||||
{% 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 %}
|
|
||||||
<span class="mx-2">|</span>
|
|
||||||
<span><strong>{% trans "Contact" %}:</strong> {{ inquiry.contact_name|default:inquiry.contact_email }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<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 %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab Navigation -->
|
<!-- Inquiry Header -->
|
||||||
<div class="bg-white rounded-t-2xl border-b border-gray-100 px-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="flex gap-1 overflow-x-auto" role="tablist">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<button class="tab-btn active" data-target="details" role="tab">
|
<div>
|
||||||
<i data-lucide="info" class="w-4 h-4"></i> {% trans "Details" %}
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||||
</button>
|
<h2 class="text-2xl font-bold">{{ inquiry.subject }}</h2>
|
||||||
<button class="tab-btn" data-target="timeline" role="tab">
|
<span class="px-3 py-1 bg-white/20 rounded-full text-sm font-semibold">
|
||||||
<i data-lucide="clock-history" class="w-4 h-4"></i> {% trans "Timeline" %} ({{ timeline.count }})
|
{% trans "Inquiry" %}
|
||||||
</button>
|
</span>
|
||||||
<button class="tab-btn" data-target="attachments" role="tab">
|
<span class="status-badge {{ inquiry.status }}">
|
||||||
<i data-lucide="paperclip" class="w-4 h-4"></i> {% trans "Attachments" %} ({{ attachments.count }})
|
<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>
|
||||||
</button>
|
{{ inquiry.get_status_display }}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
{% if inquiry.priority %}
|
||||||
|
<span class="priority-badge {{ inquiry.priority }}">
|
||||||
<!-- Tab Content -->
|
{% if inquiry.priority == 'low' %}
|
||||||
<div class="bg-white rounded-b-2xl border border-gray-50 shadow-sm mb-6">
|
<i data-lucide="arrow-down" class="w-3 h-3"></i>
|
||||||
<div class="p-6">
|
{% elif inquiry.priority == 'medium' %}
|
||||||
|
<i data-lucide="minus" class="w-3 h-3"></i>
|
||||||
<!-- Details Tab -->
|
{% elif inquiry.priority == 'high' %}
|
||||||
<div class="tab-panel active" id="details">
|
<i data-lucide="arrow-up" class="w-3 h-3"></i>
|
||||||
<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 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 %}
|
{% else %}
|
||||||
<span class="text-gray-400 text-sm">{% trans "N/A" %}</span>
|
<i data-lucide="zap" class="w-3 h-3"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{{ inquiry.get_priority_display }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="text-white/90 text-sm">{{ inquiry.message|truncatewords:30 }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="lg:text-right">
|
||||||
|
<div class="flex flex-wrap gap-2 justify-start lg:justify-end mb-3">
|
||||||
|
<span class="px-3 py-1 bg-white/20 rounded-full text-sm">
|
||||||
|
<i data-lucide="hash" class="w-3 h-3 inline-block mr-1"></i>
|
||||||
|
{{ inquiry.reference_number|truncatechars:15 }}
|
||||||
|
</span>
|
||||||
|
<span class="px-3 py-1 bg-white/20 rounded-full text-sm">
|
||||||
|
<i data-lucide="calendar" class="w-3 h-3 inline-block mr-1"></i>
|
||||||
|
{{ inquiry.created_at|date:"Y-m-d" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if can_respond %}
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-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="prose prose-sm max-w-none">
|
||||||
|
<p class="text-slate-700 leading-relaxed">{{ inquiry.message|linebreaks }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
<!-- Response -->
|
||||||
<div class="info-label mb-2">{% trans "Channel" %}</div>
|
{% if inquiry.response %}
|
||||||
{% if inquiry.channel %}
|
<div class="detail-card animate-in">
|
||||||
<span class="text-gray-700 font-medium">{{ inquiry.get_channel_display }}</span>
|
<div class="card-header bg-green-50 border-green-100">
|
||||||
{% else %}
|
<h3 class="text-lg font-bold text-green-800 flex items-center gap-2 m-0">
|
||||||
<span class="text-gray-400 text-sm">{% trans "N/A" %}</span>
|
<i data-lucide="circle-check" class="w-5 h-5"></i>
|
||||||
{% endif %}
|
{% trans "Response" %}
|
||||||
</div>
|
</h3>
|
||||||
|
</div>
|
||||||
<div>
|
<div class="p-6">
|
||||||
<div class="info-label mb-2">{% trans "Assigned To" %}</div>
|
<div class="flex items-center gap-3 mb-4 text-sm text-slate-600">
|
||||||
<span class="text-gray-700 font-medium">
|
<span class="flex items-center gap-1">
|
||||||
{% if inquiry.assigned_to %}{{ inquiry.assigned_to.get_full_name }}{% else %}<span class="text-gray-400">{% trans "Unassigned" %}</span>{% endif %}
|
<i data-lucide="user" class="w-4 h-4"></i>
|
||||||
|
{{ inquiry.responded_by.get_full_name|default:inquiry.responded_by.email }}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<i data-lucide="calendar" class="w-4 h-4"></i>
|
||||||
|
{{ inquiry.response_sent_at|date:"Y-m-d H:i" }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="prose prose-sm max-w-none">
|
||||||
|
<p class="text-slate-700 leading-relaxed">{{ inquiry.response|linebreaks }}</p>
|
||||||
<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>
|
||||||
|
|
||||||
{% if inquiry.response %}
|
|
||||||
<hr class="border-gray-200">
|
|
||||||
<div>
|
|
||||||
<div class="info-label mb-2">{% trans "Response" %}</div>
|
|
||||||
<div class="bg-green-50 border border-green-200 rounded-xl p-4">
|
|
||||||
<p class="text-gray-700 mb-3">{{ inquiry.response|linebreaks }}</p>
|
|
||||||
<small class="text-gray-500">
|
|
||||||
{% trans "Responded by" %} {{ inquiry.responded_by.get_full_name }} {% trans "on" %} {{ inquiry.responded_at|date:"M d, Y H:i" }}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<hr class="border-gray-200">
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<div class="info-label mb-2">{% trans "Created" %}</div>
|
|
||||||
<span class="text-gray-700 font-medium">{{ inquiry.created_at|date:"M d, Y H:i" }}</span>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<small class="text-gray-400">
|
|
||||||
{{ update.created_at|date:"M d, Y H:i" }}
|
|
||||||
</small>
|
|
||||||
</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>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</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 %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
|
<!-- Timeline -->
|
||||||
<!-- Attachments Tab -->
|
<div class="detail-card animate-in">
|
||||||
<div class="tab-panel hidden" id="attachments">
|
<div class="card-header">
|
||||||
<h3 class="text-xl font-bold text-gray-800 mb-6">{% trans "Attachments" %}</h3>
|
<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>
|
||||||
{% if attachments %}
|
{% trans "Timeline" %}
|
||||||
<div class="space-y-3">
|
</h3>
|
||||||
{% for attachment in attachments %}
|
</div>
|
||||||
<div class="bg-white border border-gray-200 rounded-xl p-4 flex justify-between items-center hover:shadow-md transition">
|
<div class="p-6">
|
||||||
<div>
|
<div class="timeline">
|
||||||
<div class="flex items-center gap-2 font-semibold text-gray-800 mb-1">
|
<!-- Created -->
|
||||||
<i data-lucide="file" class="w-5 h-5"></i> {{ attachment.filename }}
|
<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>
|
</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 %}
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
{% endfor %}
|
||||||
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Contact Information -->
|
||||||
|
<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="user" class="w-5 h-5"></i>
|
||||||
|
{% trans "Contact Information" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="info-label">{% trans "Name" %}</p>
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<p class="info-label">{% trans "Category" %}</p>
|
||||||
|
<p class="info-value">{{ inquiry.get_category_display|default:"-" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</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 %}
|
|
||||||
</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">
|
|
||||||
<i data-lucide="zap" class="w-5 h-5"></i> {% trans "Quick Actions" %}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<!-- Activate -->
|
|
||||||
<form method="post" action="{% url 'complaints:inquiry_activate' inquiry.id %}" class="mb-4">
|
|
||||||
{% csrf_token %}
|
|
||||||
{% if inquiry.assigned_to and inquiry.assigned_to == user %}
|
|
||||||
<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>
|
|
||||||
<i data-lucide="check-circle" class="w-5 h-5"></i> {% trans "Activated (Assigned to You)" %}
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<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="zap" class="w-5 h-5"></i> {% trans "Activate" %}
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</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>
|
</div>
|
||||||
{% endif %}
|
<form method="post" action="{% url 'complaints:inquiry_respond' inquiry.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
<!-- Add Note -->
|
<div class="p-6">
|
||||||
<div class="bg-white rounded-2xl border border-gray-50 shadow-sm p-6">
|
<div class="mb-4">
|
||||||
<h4 class="font-bold text-gray-800 mb-4 flex items-center gap-2">
|
<label class="block text-sm font-semibold text-navy mb-2">
|
||||||
<i data-lucide="message-circle" class="w-5 h-5"></i> {% trans "Add Note" %}
|
{% trans "Response" %} <span class="text-red-500">*</span>
|
||||||
</h4>
|
</label>
|
||||||
|
<textarea name="response" rows="5" required
|
||||||
<form method="post" action="{% url 'complaints:inquiry_add_note' inquiry.id %}">
|
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"
|
||||||
{% csrf_token %}
|
placeholder="{% trans 'Enter your response to the inquiry...' %}"></textarea>
|
||||||
<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>
|
</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>
|
||||||
</div>
|
<div class="p-6 border-t border-slate-200 flex gap-3">
|
||||||
|
<button type="submit" class="btn-primary flex-1 justify-center">
|
||||||
<!-- Assignment Info -->
|
<i data-lucide="send" class="w-4 h-4"></i>
|
||||||
<div class="bg-white rounded-2xl border border-gray-50 shadow-sm p-6">
|
{% trans "Send Response" %}
|
||||||
<h4 class="font-bold text-gray-800 mb-4 flex items-center gap-2">
|
</button>
|
||||||
<i data-lucide="user-check" class="w-5 h-5"></i> {% trans "Assignment Info" %}
|
<button type="button" onclick="closeRespondModal()" class="btn-secondary">
|
||||||
</h4>
|
{% trans "Cancel" %}
|
||||||
|
</button>
|
||||||
<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>
|
</form>
|
||||||
</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'));
|
function showRespondModal() {
|
||||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
|
document.getElementById('respondModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
// Add active class to clicked tab
|
|
||||||
btn.classList.add('active');
|
function closeRespondModal() {
|
||||||
|
document.getElementById('respondModal').classList.add('hidden');
|
||||||
// Show corresponding panel
|
}
|
||||||
const target = btn.dataset.target;
|
|
||||||
document.getElementById(target).classList.remove('hidden');
|
// Close modal on escape key
|
||||||
});
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeRespondModal();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
{% endblock %}
|
||||||
<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 %}
|
|
||||||
|
|||||||
@ -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="p-0">
|
||||||
<div class="row">
|
<!-- Organization Section -->
|
||||||
<div class="col-lg-8">
|
<div class="form-section">
|
||||||
<!-- Organization Information -->
|
<h3 class="section-title">
|
||||||
<div class="form-section">
|
<i data-lucide="building" class="w-5 h-5"></i>
|
||||||
<h5 class="form-section-title">
|
{% trans "Organization" %}
|
||||||
<i class="bi bi-hospital me-2"></i>{{ _("Organization") }}
|
</h3>
|
||||||
</h5>
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
<div class="mb-3">
|
<div class="form-group">
|
||||||
{{ form.hospital.label_tag }}
|
<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="mb-3">
|
|
||||||
{{ form.patient.label_tag }}
|
|
||||||
{{ form.patient }}
|
|
||||||
{% for error in form.patient.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
{{ form.contact_name.label_tag }}
|
<div class="form-group">
|
||||||
|
<label for="{{ form.contact_name.id_for_label }}" class="form-label">
|
||||||
|
{{ form.contact_name.label }} <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
{{ 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>
|
||||||
{{ form.contact_phone }}
|
</label>
|
||||||
{% for error in form.contact_phone.errors %}
|
{{ form.contact_phone }}
|
||||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
{% for error in form.contact_phone.errors %}
|
||||||
{% endfor %}
|
<p class="form-error">
|
||||||
</div>
|
<i data-lucide="alert-circle" class="w-4 h-4"></i>
|
||||||
<div class="col-md-6 mb-3">
|
{{ error }}
|
||||||
{{ form.contact_email.label_tag }}
|
</p>
|
||||||
{{ form.contact_email }}
|
{% endfor %}
|
||||||
{% for error in form.contact_email.errors %}
|
</div>
|
||||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
|
||||||
{% endfor %}
|
<div class="form-group md:col-span-2">
|
||||||
</div>
|
<label for="{{ form.contact_email.id_for_label }}" class="form-label">
|
||||||
|
{{ form.contact_email.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.contact_email }}
|
||||||
|
{% for error in form.contact_email.errors %}
|
||||||
|
<p class="form-error">
|
||||||
|
<i data-lucide="alert-circle" class="w-4 h-4"></i>
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
</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">
|
|
||||||
{{ form.category.label_tag }}
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
<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.subject }}
|
{{ form.priority.label }}
|
||||||
{% for error in form.subject.errors %}
|
</label>
|
||||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
{{ 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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="form-group">
|
||||||
{{ form.message.label_tag }}
|
<label for="{{ form.subject.id_for_label }}" class="form-label">
|
||||||
{{ form.message }}
|
{{ form.subject.label }} <span class="text-red-500">*</span>
|
||||||
{% for error in form.message.errors %}
|
</label>
|
||||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
{{ form.subject }}
|
||||||
{% endfor %}
|
{% for error in form.subject.errors %}
|
||||||
</div>
|
<p class="form-error">
|
||||||
|
<i data-lucide="alert-circle" class="w-4 h-4"></i>
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.message.id_for_label }}" class="form-label">
|
||||||
|
{{ form.message.label }} <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.message }}
|
||||||
|
{% for error in form.message.errors %}
|
||||||
|
<p class="form-error">
|
||||||
|
<i data-lucide="alert-circle" class="w-4 h-4"></i>
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
</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">
|
</button>
|
||||||
<i class="bi bi-info-circle me-2"></i>{{ _("Help")}}
|
<a href="{% url 'complaints:inquiry_list' %}" class="btn-secondary">
|
||||||
</h6>
|
{% trans "Cancel" %}
|
||||||
<p class="mb-0 small">
|
</a>
|
||||||
{{ _("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>
|
|
||||||
{% if source_user %}
|
|
||||||
<a href="{% url 'px_sources:source_user_inquiry_list' %}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-x-circle me-2"></i>{{ _("Cancel") }}
|
|
||||||
</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) {
|
|
||||||
departmentSelect.innerHTML = '<option value="">{{ _("Select department")}}</option>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
// Add error class to inputs with errors
|
||||||
const patientSelect = document.getElementById('{{ form.patient.id_for_label }}');
|
document.querySelectorAll('.form-error').forEach(function(errorEl) {
|
||||||
if (patientSelect) {
|
const input = errorEl.parentElement.querySelector('.form-input');
|
||||||
patientSelect.addEventListener('change', function() {
|
if (input) {
|
||||||
const selectedOption = this.options[this.selectedIndex];
|
input.classList.add('error');
|
||||||
if (!selectedOption || selectedOption.value === '') {
|
|
||||||
document.getElementById('patient-results').style.display = 'none';
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -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;
|
.page-header {
|
||||||
}
|
background: linear-gradient(135deg, var(--hh-navy) 0%, #0069a8 50%, var(--hh-blue) 100%);
|
||||||
.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 {
|
|
||||||
padding: 4px 12px;
|
|
||||||
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>
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="mb-1">
|
<div>
|
||||||
<i class="bi bi-question-circle-fill text-info me-2"></i>
|
<h1 class="text-2xl font-bold mb-2">
|
||||||
{{ _("Inquiries Console")}}
|
<i data-lucide="help-circle" class="w-7 h-7 inline-block me-2"></i>
|
||||||
</h2>
|
{% trans "Inquiries Console" %}
|
||||||
<p class="text-muted mb-0">{{ _("Manage patient inquiries and requests")}}</p>
|
</h1>
|
||||||
</div>
|
<p class="text-white/90">{% trans "Manage patient inquiries and requests" %}</p>
|
||||||
<div>
|
</div>
|
||||||
<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> {{ _("New Inquiry")}}
|
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 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 class="text-primary">
|
|
||||||
<i class="bi bi-list-ul" style="font-size: 2rem;"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-xs font-bold text-slate uppercase tracking-wider">{% trans "Total Inquiries" %}</p>
|
||||||
|
<p class="text-2xl font-black text-navy mt-1">{{ stats.total }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="stat-card">
|
||||||
<div class="card stat-card border-info">
|
<div class="stat-icon green">
|
||||||
<div class="card-body">
|
<i data-lucide="check-circle" class="w-6 h-6 text-green-600"></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>
|
||||||
|
<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="col-md-3">
|
<div class="stat-card">
|
||||||
<div class="card stat-card border-warning">
|
<div class="stat-icon orange">
|
||||||
<div class="card-body">
|
<i data-lucide="clock" class="w-6 h-6 text-orange-600"></i>
|
||||||
<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>
|
||||||
|
<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-success">
|
<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 "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">
|
placeholder="{% trans 'Subject, contact name...' %}"
|
||||||
<label class="form-label">{% trans "Search" %}</label>
|
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">
|
||||||
<input type="text" class="form-control" name="search"
|
|
||||||
placeholder="{% trans 'Subject, contact name...' %}"
|
|
||||||
value="{{ filters.search }}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">{% trans "Status" %}</label>
|
|
||||||
<select class="form-select" name="status">
|
|
||||||
<option value="">{{ _("All Statuses")}}</option>
|
|
||||||
{% for value, label in status_choices %}
|
|
||||||
<option value="{{ value }}" {% if filters.status == value %}selected{% endif %}>
|
|
||||||
{{ label }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Priority -->
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">{% trans "Priority" %}</label>
|
|
||||||
<select class="form-select" name="priority">
|
|
||||||
<option value="">{{ _("All Priorities")}}</option>
|
|
||||||
<option value="low" {% if filters.priority == 'low' %}selected{% endif %}>{{ _("Low") }}</option>
|
|
||||||
<option value="medium" {% if filters.priority == 'medium' %}selected{% endif %}>{{ _("Medium") }}</option>
|
|
||||||
<option value="high" {% if filters.priority == 'high' %}selected{% endif %}>{{ _("High") }}</option>
|
|
||||||
<option value="urgent" {% if filters.priority == 'urgent' %}selected{% endif %}>{{ _("Urgent") }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Category -->
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">{% trans "Category" %}</label>
|
|
||||||
<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>
|
||||||
|
<div>
|
||||||
<div class="mt-3 d-flex gap-2">
|
<label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Status" %}</label>
|
||||||
<button type="submit" class="btn btn-primary">
|
<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">
|
||||||
<i class="bi bi-search me-1"></i> {{ _("Apply Filters")}}
|
<option value="">{% trans "All Status" %}</option>
|
||||||
|
<option value="open" {% if filters.status == 'open' %}selected{% endif %}>{% trans "Open" %}</option>
|
||||||
|
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
|
||||||
|
<option value="resolved" {% if filters.status == 'resolved' %}selected{% endif %}>{% trans "Resolved" %}</option>
|
||||||
|
<option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>{% trans "Closed" %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Category" %}</label>
|
||||||
|
<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">
|
||||||
|
<option value="">{% trans "All Categories" %}</option>
|
||||||
|
<option value="general" {% if filters.category == 'general' %}selected{% endif %}>{% trans "General" %}</option>
|
||||||
|
<option value="services" {% if filters.category == 'services' %}selected{% endif %}>{% trans "Services" %}</option>
|
||||||
|
<option value="appointments" {% if filters.category == 'appointments' %}selected{% endif %}>{% trans "Appointments" %}</option>
|
||||||
|
<option value="billing" {% if filters.category == 'billing' %}selected{% endif %}>{% trans "Billing" %}</option>
|
||||||
|
<option value="medical" {% if filters.category == 'medical' %}selected{% endif %}>{% trans "Medical Records" %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<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">
|
||||||
|
<i data-lucide="search" class="w-4 h-4 inline-block me-2"></i>
|
||||||
|
{% trans "Filter" %}
|
||||||
</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>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if inquiries.has_other_pages %}
|
||||||
|
<div class="p-4 border-t border-slate-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm text-slate">
|
||||||
|
{% blocktrans with start=inquiries.start_index end=inquiries.end_index total=inquiries.paginator.count %}
|
||||||
|
Showing {{ start }} to {{ end }} of {{ total }} inquiries
|
||||||
|
{% 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>
|
||||||
|
{% endif %}
|
||||||
|
{% if inquiries.has_next %}
|
||||||
|
<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 %}"
|
||||||
|
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
|
||||||
|
{% trans "Next" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% 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="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 %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
{% if page_obj.has_other_pages %}
|
|
||||||
<nav aria-label="Inquiries pagination" class="mt-4">
|
|
||||||
<ul class="pagination justify-content-center">
|
|
||||||
{% if page_obj.has_previous %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="?page=1{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
|
|
||||||
<i class="bi bi-chevron-double-left"></i>
|
|
||||||
</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 %}
|
|
||||||
|
|
||||||
{% for num in page_obj.paginator.page_range %}
|
|
||||||
{% if page_obj.number == num %}
|
|
||||||
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
|
|
||||||
{% 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>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% if page_obj.has_next %}
|
|
||||||
<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 %}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
|
||||||
</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 %}
|
||||||
|
|||||||
@ -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>
|
||||||
@ -171,7 +215,7 @@ function selectResolution(lang) {
|
|||||||
const textarea = document.getElementById('resolutionTextarea');
|
const textarea = document.getElementById('resolutionTextarea');
|
||||||
const radioEn = document.getElementById('resEn');
|
const radioEn = document.getElementById('resEn');
|
||||||
const radioAr = document.getElementById('resAr');
|
const radioAr = document.getElementById('resAr');
|
||||||
|
|
||||||
if (lang === 'en') {
|
if (lang === 'en') {
|
||||||
radioEn.checked = true;
|
radioEn.checked = true;
|
||||||
radioAr.checked = false;
|
radioAr.checked = false;
|
||||||
@ -183,7 +227,7 @@ function selectResolution(lang) {
|
|||||||
textarea.value = generatedResolutions.ar;
|
textarea.value = generatedResolutions.ar;
|
||||||
textarea.dir = 'rtl';
|
textarea.dir = 'rtl';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Highlight selected card
|
// Highlight selected card
|
||||||
const cards = document.querySelectorAll('#aiResolutionSelection > div');
|
const cards = document.querySelectorAll('#aiResolutionSelection > div');
|
||||||
cards.forEach((card, index) => {
|
cards.forEach((card, index) => {
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
</div>
|
<i data-lucide="search" class="w-10 h-10 text-white -rotate-3"></i>
|
||||||
<h1 class="text-3xl font-bold text-gray-800 mb-2">
|
|
||||||
{% trans "Track Your Complaint" %}
|
|
||||||
</h1>
|
|
||||||
<p class="text-gray-500 text-lg">
|
|
||||||
{% trans "Enter your reference number to check the status of your complaint" %}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1 class="text-4xl font-extrabold text-navy mb-3 tracking-tight">
|
||||||
<form method="POST" class="max-w-lg mx-auto">
|
{% trans "Track Your Complaint" %}
|
||||||
{% csrf_token %}
|
</h1>
|
||||||
<div class="mb-4">
|
<p class="text-slate/80 text-lg max-w-md mx-auto">
|
||||||
<input
|
{% trans "Enter your reference number below to see real-time updates on your request." %}
|
||||||
type="text"
|
|
||||||
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"
|
|
||||||
placeholder="{% trans 'e.g., CMP-20240101-123456' %}"
|
|
||||||
value="{{ reference_number }}"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</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">
|
|
||||||
<i data-lucide="search" class="inline w-5 h-5 mr-2"></i>
|
|
||||||
{% trans "Track Complaint" %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<p class="text-center text-gray-400 text-sm mt-4">
|
|
||||||
{% trans "Your reference number was provided when you submitted your complaint" %}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Message -->
|
<form method="POST" class="max-w-lg mx-auto relative">
|
||||||
{% if error_message %}
|
{% csrf_token %}
|
||||||
<div class="bg-yellow-50 border border-yellow-200 rounded-2xl p-6 mb-8 flex items-start gap-4">
|
<div class="relative group">
|
||||||
<div class="text-yellow-500 flex-shrink-0">
|
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||||
<i data-lucide="alert-triangle" class="w-6 h-6"></i>
|
<i data-lucide="hash" class="w-5 h-5 text-slate/40 group-focus-within:text-blue transition-colors"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="reference_number"
|
||||||
|
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' %}"
|
||||||
|
value="{{ reference_number }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<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">
|
||||||
<strong class="text-yellow-800 block mb-1">{% trans "Not Found" %}</strong>
|
<i data-lucide="crosshair" class="w-5 h-5"></i>
|
||||||
<span class="text-yellow-700">{{ error_message }}</span>
|
{% trans "Track Status" %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-center text-slate/50 text-xs mt-6 uppercase tracking-widest font-semibold">
|
||||||
|
<i data-lucide="info" class="w-3 h-3 inline mr-1"></i>
|
||||||
|
{% trans "Found in your confirmation email" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error_message %}
|
||||||
|
<div class="bg-rose-50 border border-rose-100 rounded-2xl p-6 mb-8 flex items-center gap-4 animate-shake">
|
||||||
|
<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-circle" class="w-6 h-6"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-rose-900">{% trans "Reference Not Found" %}</h3>
|
||||||
|
<p class="text-rose-700/80 text-sm">{{ error_message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if complaint %}
|
||||||
|
<div class="animate-slide-up" style="animation-delay: 0.1s">
|
||||||
|
<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 justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-bold text-slate/40 uppercase tracking-widest block mb-1">{% trans "Case Reference" %}</span>
|
||||||
|
<h2 class="text-3xl font-black text-navy">{{ complaint.reference_number }}</h2>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="mt-8 h-2 w-full bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-navy transition-all duration-1000"
|
||||||
|
style="width: {% if complaint.status == 'resolved' %}100%{% elif complaint.status == 'in_progress' %}50%{% else %}15%{% endif %}">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Complaint Details -->
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
|
||||||
{% if complaint %}
|
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm transition-hover hover:shadow-md">
|
||||||
<div class="bg-white rounded-3xl shadow-xl p-8 md:p-12">
|
<i data-lucide="calendar" class="w-5 h-5 text-blue mb-3"></i>
|
||||||
<!-- Header -->
|
<span class="block text-xs font-bold text-slate/50 uppercase">{% trans "Submitted" %}</span>
|
||||||
<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">
|
<p class="font-bold text-navy">{{ complaint.created_at|date:"M d, Y" }}</p>
|
||||||
<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">
|
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm transition-hover hover:shadow-md">
|
||||||
<i data-lucide="hash" class="w-6 h-6 text-gray-400"></i>
|
<i data-lucide="building" class="w-5 h-5 text-blue mb-3"></i>
|
||||||
{{ complaint.reference_number }}
|
<span class="block text-xs font-bold text-slate/50 uppercase">{% trans "Department" %}</span>
|
||||||
</h2>
|
<p class="font-bold text-navy truncate">{{ complaint.department.name|default:"General" }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center md:text-right">
|
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm transition-hover hover:shadow-md relative overflow-hidden">
|
||||||
<span class="px-4 py-2 rounded-full text-sm font-bold uppercase tracking-wide
|
<i data-lucide="clock" class="w-5 h-5 {% if complaint.is_overdue %}text-rose-500{% else %}text-blue{% endif %} mb-3"></i>
|
||||||
{% if complaint.status == 'open' %}bg-yellow-100 text-yellow-700
|
<span class="block text-xs font-bold text-slate/50 uppercase">{% trans "SLA Deadline" %}</span>
|
||||||
{% elif complaint.status == 'in_progress' %}bg-blue-100 text-blue-700
|
<p class="font-bold text-navy">{{ complaint.due_at|date:"M d, H:i" }}</p>
|
||||||
{% elif complaint.status == 'partially_resolved' %}bg-orange-100 text-orange-700
|
{% if complaint.is_overdue %}
|
||||||
{% elif complaint.status == 'resolved' %}bg-green-100 text-green-700
|
<span class="absolute top-2 right-2 flex h-2 w-2">
|
||||||
{% elif complaint.status == 'closed' %}bg-gray-100 text-gray-700
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
|
||||||
{% elif complaint.status == 'cancelled' %}bg-red-100 text-red-700
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-rose-500"></span>
|
||||||
{% endif %}">
|
|
||||||
{{ complaint.get_status_display }}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SLA Information -->
|
|
||||||
<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="flex items-center gap-4">
|
|
||||||
<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 class="flex-1">
|
|
||||||
<h4 class="font-bold text-gray-800 mb-1">
|
|
||||||
{% if complaint.is_overdue %}
|
|
||||||
{% trans "Response Overdue" %}
|
|
||||||
{% else %}
|
|
||||||
{% trans "Expected Response Time" %}
|
|
||||||
{% 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>
|
|
||||||
|
|
||||||
<!-- Information Grid -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
|
||||||
<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="calendar" class="w-4 h-4"></i>
|
|
||||||
{% trans "Submitted On" %}
|
|
||||||
</div>
|
|
||||||
<div class="font-semibold text-gray-800">
|
|
||||||
{{ 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 %}
|
{% 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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-3xl shadow-lg border border-slate-100 p-8 md:p-10">
|
||||||
|
<h3 class="text-2xl font-bold text-navy mb-10 flex items-center gap-3">
|
||||||
|
<div class="p-2 bg-navy text-white rounded-lg">
|
||||||
|
<i data-lucide="list-checks" class="w-5 h-5"></i>
|
||||||
|
</div>
|
||||||
|
{% trans "Resolution Journey" %}
|
||||||
|
</h3>
|
||||||
|
|
||||||
<!-- Timeline -->
|
|
||||||
{% if public_updates %}
|
{% if public_updates %}
|
||||||
<div class="mt-8">
|
<div class="space-y-1">
|
||||||
<h3 class="text-xl font-bold text-gray-800 mb-6 flex items-center gap-2">
|
{% for update in public_updates %}
|
||||||
<i data-lucide="history" class="w-6 h-6 text-blue-500"></i>
|
<div class="timeline-item flex gap-6 pb-10 relative">
|
||||||
{% trans "Complaint Timeline" %}
|
<div class="timeline-dot shrink-0 relative z-10">
|
||||||
</h3>
|
<div class="w-12 h-12 rounded-2xl flex items-center justify-center shadow-sm border-2 border-white
|
||||||
<div class="relative pl-8">
|
{% if update.update_type == 'status_change' %}bg-amber-100 text-amber-600
|
||||||
<!-- Timeline Line -->
|
{% elif update.update_type == 'resolution' %}bg-emerald-100 text-emerald-600
|
||||||
<div class="absolute left-3 top-0 bottom-0 w-0.5 bg-gray-200"></div>
|
{% 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>
|
||||||
{% for update in public_updates %}
|
|
||||||
<div class="relative pb-6 last:pb-0">
|
|
||||||
<!-- Timeline Dot -->
|
|
||||||
<div class="absolute left-[-1.3rem] top-1 w-4 h-4 rounded-full border-2 border-white
|
|
||||||
{% if update.update_type == 'status_change' %}bg-orange-500 shadow-[0_0_0_2px_#f97316]
|
|
||||||
{% elif update.update_type == 'resolution' %}bg-green-500 shadow-[0_0_0_2px_#22c55e]
|
|
||||||
{% else %}bg-blue-500 shadow-[0_0_0_2px_#3b82f6]{% endif %}">
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="text-sm text-gray-500 mb-1">
|
<div class="flex-1 pt-1">
|
||||||
{{ update.created_at|date:"F j, Y" }} at {{ update.created_at|time:"g:i A" }}
|
<div class="flex flex-col md:flex-row md:items-center justify-between mb-2">
|
||||||
</div>
|
<h4 class="font-black text-navy text-lg">
|
||||||
<div class="font-semibold text-gray-800 mb-2">
|
{% if update.update_type == 'status_change' %}{% trans "Status Updated" %}
|
||||||
{% if update.update_type == 'status_change' %}
|
{% elif update.update_type == 'resolution' %}{% trans "Final Resolution" %}
|
||||||
{% trans "Status Updated" %}
|
{% else %}{% trans "Update Received" %}{% endif %}
|
||||||
{% elif update.update_type == 'resolution' %}
|
</h4>
|
||||||
{% trans "Resolution Added" %}
|
<time class="text-sm font-medium text-slate/40">{{ update.created_at|date:"F j, Y • g:i A" }}</time>
|
||||||
{% elif update.update_type == 'communication' %}
|
|
||||||
{% trans "Update" %}
|
|
||||||
{% else %}
|
|
||||||
{{ 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>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</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 %}
|
||||||
219
templates/complaints/templates/template_list.html
Normal file
219
templates/complaints/templates/template_list.html
Normal 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 %}
|
||||||
@ -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 %}
|
||||||
@ -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" %}
|
||||||
|
|||||||
@ -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,89 +19,142 @@
|
|||||||
</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 %}
|
||||||
{% for hospital in hospitals %}
|
<!-- Hospital Cards Grid -->
|
||||||
<div class="block cursor-pointer hover:bg-light/30 transition group relative">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<input type="radio"
|
{% for hospital in hospitals %}
|
||||||
id="hospital_{{ hospital.id }}"
|
{% with hospital_id_str=hospital.id|stringformat:"s" %}
|
||||||
name="hospital_id"
|
<div
|
||||||
value="{{ hospital.id }}"
|
class="hospital-card group relative cursor-pointer rounded-2xl border-2 transition-all duration-200
|
||||||
{% if hospital.id == selected_hospital_id %}checked{% endif %}
|
{% if hospital_id_str == selected_hospital_id %}
|
||||||
class="peer"
|
border-blue bg-blue-50/50 shadow-md selected
|
||||||
style="position: absolute; opacity: 0; width: 0; height: 0;">
|
{% else %}
|
||||||
|
border-slate-200 bg-white hover:border-blue/50 hover:-translate-y-1
|
||||||
<label for="hospital_{{ hospital.id }}" class="block p-6">
|
{% endif %}"
|
||||||
<div class="flex items-start gap-4">
|
onclick="selectHospital('{{ hospital_id_str }}')"
|
||||||
<!-- Radio Button -->
|
data-hospital-id="{{ hospital_id_str }}"
|
||||||
<div class="flex-shrink-0 mt-1">
|
>
|
||||||
<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">
|
<input
|
||||||
<div class="w-2.5 h-2.5 bg-white rounded-full opacity-0 peer-checked:opacity-100 transition-opacity"></div>
|
type="radio"
|
||||||
</div>
|
id="hospital_{{ hospital.id }}"
|
||||||
</div>
|
name="hospital_id"
|
||||||
|
value="{{ hospital.id }}"
|
||||||
<!-- Hospital Info -->
|
{% if hospital_id_str == selected_hospital_id %}checked{% endif %}
|
||||||
<div class="flex-1 min-w-0">
|
class="hospital-radio sr-only"
|
||||||
<div class="flex items-start justify-between gap-4">
|
>
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="text-lg font-bold text-navy mb-1 group-hover:text-blue transition">
|
<!-- Card Content -->
|
||||||
{{ hospital.name }}
|
<div class="p-6">
|
||||||
</h3>
|
<!-- Selection Indicator -->
|
||||||
{% if hospital.city %}
|
<div class="absolute top-4 right-4">
|
||||||
<p class="text-slate text-sm flex items-center gap-1.5">
|
<div class="selection-indicator w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all
|
||||||
<i data-lucide="map-pin" class="w-3.5 h-3.5"></i>
|
{% if hospital_id_str == selected_hospital_id %}
|
||||||
{{ hospital.city }}
|
border-blue bg-blue
|
||||||
{% if hospital.country %}, {{ hospital.country }}{% endif %}
|
{% else %}
|
||||||
</p>
|
border-slate-300
|
||||||
{% endif %}
|
{% endif %}">
|
||||||
</div>
|
<i data-lucide="check" class="w-3.5 h-3.5 text-white transition-opacity
|
||||||
|
{% if hospital_id_str == selected_hospital_id %}
|
||||||
<!-- Selected Badge -->
|
opacity-100
|
||||||
{% if hospital.id == selected_hospital_id %}
|
{% else %}
|
||||||
<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">
|
opacity-0
|
||||||
<i data-lucide="check-circle-2" class="w-3.5 h-3.5 mr-1.5"></i>
|
{% endif %}"></i>
|
||||||
{% trans "Selected" %}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{% empty %}
|
|
||||||
<div class="p-12 text-center">
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-semibold text-navy mb-2">
|
|
||||||
{% trans "No Hospitals Available" %}
|
|
||||||
</h3>
|
|
||||||
<p class="text-slate text-sm">
|
|
||||||
{% trans "No hospitals found in the system. Please contact your administrator." %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Hospital Icon -->
|
||||||
<div class="p-6 bg-slate-50 border-t border-slate-200">
|
<div class="hospital-icon w-14 h-14 rounded-xl flex items-center justify-center mb-4 transition-all
|
||||||
<div class="flex flex-col sm:flex-row justify-between items-center gap-4">
|
{% if hospital_id_str == selected_hospital_id %}
|
||||||
<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">
|
bg-gradient-to-br from-blue to-navy ring-4 ring-blue/20
|
||||||
<i data-lucide="arrow-left" class="w-5 h-5"></i>
|
{% else %}
|
||||||
{% trans "Back to Dashboard" %}
|
bg-slate-100 group-hover:bg-blue-50
|
||||||
</a>
|
{% endif %}">
|
||||||
<button type="submit" class="w-full sm:w-auto px-8 py-3 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition flex items-center justify-center gap-2 shadow-lg shadow-blue/20">
|
<i data-lucide="building-2" class="w-7 h-7 transition-colors
|
||||||
<i data-lucide="check" class="w-5 h-5"></i>
|
{% if hospital_id_str == selected_hospital_id %}
|
||||||
{% trans "Continue" %}
|
text-white
|
||||||
</button>
|
{% 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 }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
|
{% if hospital.city %}
|
||||||
|
<div class="flex items-center gap-2 text-slate text-sm">
|
||||||
|
<i data-lucide="map-pin" class="w-4 h-4 text-slate/70"></i>
|
||||||
|
<span>
|
||||||
|
{{ hospital.city }}
|
||||||
|
{% if hospital.country %}, {{ hospital.country }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</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 -->
|
||||||
|
<div class="selected-badge mt-4 pt-4 border-t transition-all
|
||||||
|
{% if hospital_id_str == selected_hospital_id %}
|
||||||
|
block border-blue/20
|
||||||
|
{% 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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{% endwith %}
|
||||||
</div>
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- 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">
|
||||||
|
<i data-lucide="alert-triangle" class="w-8 h-8 text-amber-500"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-navy mb-2">
|
||||||
|
{% trans "No Hospitals Available" %}
|
||||||
|
</h3>
|
||||||
|
<p class="text-slate text-sm">
|
||||||
|
{% trans "No hospitals found in the system. Please contact your administrator." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
{% 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">
|
||||||
|
<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>
|
||||||
|
{% trans "Back to Dashboard" %}
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="w-full sm:w-auto px-8 py-3 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition flex items-center justify-center gap-2 shadow-lg shadow-blue/20">
|
||||||
|
<i data-lucide="check" class="w-5 h-5"></i>
|
||||||
|
{% trans "Continue" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
<!-- 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 %}
|
||||||
|
|||||||
@ -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>
|
<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>
|
||||||
|
</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>
|
||||||
|
<div class="p-3 bg-blue-50 rounded-xl">
|
||||||
|
<i data-lucide="users" class="w-6 h-6 text-blue"></i>
|
||||||
</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>
|
</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="card stat-card">
|
||||||
<div class="bg-white/20 p-3 rounded-xl">
|
<div class="flex items-start justify-between">
|
||||||
<i data-lucide="alert-triangle" class="w-6 h-6"></i>
|
<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="p-3 bg-red-50 rounded-xl">
|
||||||
|
<i data-lucide="alert-triangle" class="w-6 h-6 text-red-500"></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>
|
</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="card stat-card">
|
||||||
<div class="bg-white/20 p-3 rounded-xl">
|
<div class="flex items-start justify-between">
|
||||||
<i data-lucide="message-circle" class="w-6 h-6"></i>
|
<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>
|
||||||
<h3 class="text-sm font-medium opacity-90">{% trans "Total Inquiries" %}</h3>
|
|
||||||
</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="card stat-card">
|
||||||
<div class="bg-white/20 p-3 rounded-xl">
|
<div class="flex items-start justify-between">
|
||||||
<i data-lucide="check-circle" class="w-6 h-6"></i>
|
<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>
|
||||||
<h3 class="text-sm font-medium opacity-90">{% trans "Resolution Rate" %}</h3>
|
|
||||||
</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">
|
||||||
{% trans "Complaint Source Breakdown" %}
|
<i data-lucide="pie-chart" class="w-4 h-4"></i>
|
||||||
</h3>
|
{% trans "Complaint Source Breakdown" %}
|
||||||
|
</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">
|
||||||
{% trans "Complaint Status Distribution" %}
|
<i data-lucide="bar-chart-2" class="w-4 h-4"></i>
|
||||||
</h3>
|
{% trans "Complaint Status Distribution" %}
|
||||||
|
</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">
|
||||||
{% trans "Complaint Activation Time" %}
|
<i data-lucide="clock" class="w-4 h-4"></i>
|
||||||
</h3>
|
{% trans "Complaint Activation Time" %}
|
||||||
<p class="text-sm text-gray-500 mb-4">{% trans "Time from creation to assignment" %}</p>
|
</h3>
|
||||||
|
<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">
|
||||||
{% trans "Complaint Response Time" %}
|
<i data-lucide="gauge" class="w-4 h-4"></i>
|
||||||
</h3>
|
{% trans "Complaint Response Time" %}
|
||||||
<p class="text-sm text-gray-500 mb-4">{% trans "Time to first response/update" %}</p>
|
</h3>
|
||||||
|
<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">
|
||||||
{% trans "Inquiry Status Distribution" %}
|
<i data-lucide="bar-chart-2" class="w-4 h-4"></i>
|
||||||
</h3>
|
{% trans "Inquiry Status Distribution" %}
|
||||||
|
</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">
|
||||||
{% trans "Inquiry Response Time" %}
|
<i data-lucide="gauge" class="w-4 h-4"></i>
|
||||||
</h3>
|
{% trans "Inquiry Response Time" %}
|
||||||
<p class="text-sm text-gray-500 mb-4">{% trans "Time to first response/update" %}</p>
|
</h3>
|
||||||
|
<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>
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav class="mb-4">
|
||||||
|
<ol class="flex items-center gap-2 text-sm text-slate">
|
||||||
|
<li><a href="{% url 'px_sources:source_list' %}" class="text-blue hover:text-navy">{% trans "PX Sources" %}</a></li>
|
||||||
|
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||||
|
<li><a href="{% url 'px_sources:source_detail' source.pk %}" class="text-blue hover:text-navy">{{ source.name_en }}</a></li>
|
||||||
|
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||||
|
<li class="text-navy font-semibold">{% trans "Delete" %}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- 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>
|
||||||
<nav aria-label="breadcrumb">
|
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
|
||||||
<ol class="breadcrumb mb-2">
|
<i data-lucide="alert-triangle" class="w-8 h-8 text-red-500"></i>
|
||||||
<li class="breadcrumb-item">
|
|
||||||
<a href="{% url 'px_sources:source_list' %}">{% trans "PX Sources" %}</a>
|
|
||||||
</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>
|
|
||||||
</nav>
|
|
||||||
<h2 class="mb-1">
|
|
||||||
<i class="bi bi-exclamation-triangle-fill text-danger me-2"></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>
|
||||||
<div class="card-body">
|
<div class="p-6">
|
||||||
<div class="alert alert-warning">
|
<!-- Warning Alert -->
|
||||||
<h4><i class="fas fa-exclamation-circle"></i> {% trans "Warning" %}</h4>
|
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||||
<p>{% trans "Are you sure you want to delete this source? This action cannot be undone." %}</p>
|
<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>
|
||||||
|
|
||||||
<div class="table-responsive mb-4">
|
<!-- Source Info Table -->
|
||||||
<table class="table table-bordered">
|
<div class="overflow-x-auto mb-6">
|
||||||
<tr>
|
<table class="w-full border border-slate-200 rounded-lg">
|
||||||
<th width="30%">{% trans "Name (English)" %}</th>
|
<tbody class="divide-y divide-slate-200">
|
||||||
<td><strong>{{ source.name_en }}</strong></td>
|
<tr>
|
||||||
</tr>
|
<th class="py-3 px-4 text-left text-sm font-semibold text-navy bg-slate-50 w-1/3">{% trans "Name (English)" %}</th>
|
||||||
<tr>
|
<td class="py-3 px-4 text-navy font-semibold">{{ source.name_en }}</td>
|
||||||
<th>{% trans "Name (Arabic)" %}</th>
|
</tr>
|
||||||
<td dir="rtl">{{ source.name_ar|default:"-" }}</td>
|
<tr>
|
||||||
</tr>
|
<th class="py-3 px-4 text-left text-sm font-semibold text-navy bg-slate-50">{% trans "Name (Arabic)" %}</th>
|
||||||
<tr>
|
<td class="py-3 px-4 text-navy" dir="rtl">{{ source.name_ar|default:"-" }}</td>
|
||||||
<th>{% trans "Description" %}</th>
|
</tr>
|
||||||
<td>{{ source.description|default:"-"|truncatewords:20 }}</td>
|
<tr>
|
||||||
</tr>
|
<th class="py-3 px-4 text-left text-sm font-semibold text-navy bg-slate-50">{% trans "Description" %}</th>
|
||||||
<tr>
|
<td class="py-3 px-4 text-slate">{{ source.description|default:"-"|truncatewords:20 }}</td>
|
||||||
<th>{% trans "Status" %}</th>
|
</tr>
|
||||||
<td>
|
<tr>
|
||||||
{% if source.is_active %}
|
<th class="py-3 px-4 text-left text-sm font-semibold text-navy bg-slate-50">{% trans "Status" %}</th>
|
||||||
<span class="badge bg-success">{% trans "Active" %}</span>
|
<td class="py-3 px-4">
|
||||||
{% else %}
|
{% if source.is_active %}
|
||||||
<span class="badge bg-secondary">{% trans "Inactive" %}</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">
|
||||||
{% endif %}
|
<i data-lucide="check-circle" class="w-3 h-3"></i>
|
||||||
</td>
|
{% trans "Active" %}
|
||||||
</tr>
|
</span>
|
||||||
<tr>
|
{% else %}
|
||||||
<th>{% trans "Usage Count" %}</th>
|
<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">
|
||||||
<td>
|
<i data-lucide="x-circle" class="w-3 h-3"></i>
|
||||||
{% if usage_count > 0 %}
|
{% trans "Inactive" %}
|
||||||
<span class="badge bg-danger">{{ usage_count }}</span>
|
</span>
|
||||||
{% else %}
|
{% endif %}
|
||||||
<span class="badge bg-success">0</span>
|
</td>
|
||||||
{% endif %}
|
</tr>
|
||||||
</td>
|
<tr>
|
||||||
</tr>
|
<th class="py-3 px-4 text-left text-sm font-semibold text-navy bg-slate-50">{% trans "Usage Count" %}</th>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
{% if usage_count > 0 %}
|
||||||
|
<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 %}
|
||||||
|
<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 %}
|
||||||
|
</td>
|
||||||
|
</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>
|
||||||
{% endblock %}
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
lucide.createIcons();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@ -1,237 +1,363 @@
|
|||||||
{% 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">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav class="mb-4 animate-in">
|
||||||
|
<ol class="flex items-center gap-2 text-sm text-slate">
|
||||||
|
<li><a href="{% url 'px_sources:source_list' %}" class="text-blue hover:text-navy font-medium">{% trans "PX Sources" %}</a></li>
|
||||||
|
<li><i data-lucide="chevron-right" class="w-4 h-4 text-slate"></i></li>
|
||||||
|
<li class="text-navy font-semibold">{{ source.name_en }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="flex flex-wrap justify-between items-start gap-4 mb-6 animate-in">
|
||||||
<div>
|
<div>
|
||||||
<nav aria-label="breadcrumb">
|
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
|
||||||
<ol class="breadcrumb mb-2">
|
<div class="w-10 h-10 bg-blue/10 rounded-xl flex items-center justify-center">
|
||||||
<li class="breadcrumb-item">
|
<i data-lucide="radio" class="w-6 h-6 text-blue"></i>
|
||||||
<a href="{% url 'px_sources:source_list' %}">{% trans "PX Sources" %}</a>
|
</div>
|
||||||
</li>
|
|
||||||
<li class="breadcrumb-item active" aria-current="page">
|
|
||||||
{{ source.name_en }}
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
<h2 class="mb-1">
|
|
||||||
<i class="bi bi-lightning-fill text-warning me-2"></i>
|
|
||||||
{{ source.name_en }}
|
{{ source.name_en }}
|
||||||
</h2>
|
</h1>
|
||||||
<p class="text-muted mb-0">
|
<div class="mt-2 flex items-center gap-3">
|
||||||
{% if source.is_active %}
|
<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">
|
||||||
<span class="badge bg-success">{% trans "Active" %}</span>
|
<i data-lucide="{% if source.is_active %}check-circle{% else %}x-circle{% endif %}" class="w-3.5 h-3.5"></i>
|
||||||
{% else %}
|
{% if source.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}
|
||||||
<span class="badge bg-secondary">{% trans "Inactive" %}</span>
|
</span>
|
||||||
{% endif %}
|
<span class="font-mono text-xs bg-slate-100 px-2 py-1 rounded">{{ source.code }}</span>
|
||||||
</p>
|
<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>
|
||||||
<div>
|
<div class="flex gap-2">
|
||||||
<a href="{% url 'px_sources:source_list' %}" class="btn btn-outline-secondary me-2">
|
<a href="{% url 'px_sources:source_list' %}"
|
||||||
<i class="bi bi-arrow-left me-1"></i> {% trans "Back to 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>
|
<tr>
|
||||||
<table class="table table-borderless">
|
<th>{% trans "Name (English)" %}</th>
|
||||||
<tr>
|
<td class="font-semibold">{{ source.name_en }}</td>
|
||||||
<th width="30%">{% trans "Name (English)" %}</th>
|
</tr>
|
||||||
<td><strong>{{ source.name_en }}</strong></td>
|
<tr>
|
||||||
</tr>
|
<th>{% trans "Name (Arabic)" %}</th>
|
||||||
<tr>
|
<td dir="rtl">{{ source.name_ar|default:"-" }}</td>
|
||||||
<th>{% trans "Name (Arabic)" %}</th>
|
</tr>
|
||||||
<td dir="rtl">{{ source.name_ar|default:"-" }}</td>
|
<tr>
|
||||||
</tr>
|
<th>{% trans "Description" %}</th>
|
||||||
<tr>
|
<td>{{ source.description|default:"-"|linebreaks }}</td>
|
||||||
<th>{% trans "Description" %}</th>
|
</tr>
|
||||||
<td>{{ source.description|default:"-"|linebreaks }}</td>
|
{% if source.contact_email or source.contact_phone %}
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<th>{% trans "Contact" %}</th>
|
||||||
<th>{% trans "Status" %}</th>
|
<td>
|
||||||
<td>
|
{% if source.contact_email %}
|
||||||
{% if source.is_active %}
|
<div class="flex items-center gap-2">
|
||||||
<span class="badge bg-success">{% trans "Active" %}</span>
|
<i data-lucide="mail" class="w-4 h-4 text-slate"></i>
|
||||||
{% else %}
|
<a href="mailto:{{ source.contact_email }}" class="text-blue hover:underline">{{ source.contact_email }}</a>
|
||||||
<span class="badge bg-secondary">{% trans "Inactive" %}</span>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
{% if source.contact_phone %}
|
||||||
</tr>
|
<div class="flex items-center gap-2 mt-1">
|
||||||
<tr>
|
<i data-lucide="phone" class="w-4 h-4 text-slate"></i>
|
||||||
<th>{% trans "Created" %}</th>
|
<a href="tel:{{ source.contact_phone }}" class="text-blue hover:underline">{{ source.contact_phone }}</a>
|
||||||
<td>{{ source.created_at|date:"Y-m-d H:i" }}</td>
|
</div>
|
||||||
</tr>
|
{% endif %}
|
||||||
<tr>
|
</td>
|
||||||
<th>{% trans "Last Updated" %}</th>
|
</tr>
|
||||||
<td>{{ source.updated_at|date:"Y-m-d H:i" }}</td>
|
{% endif %}
|
||||||
</tr>
|
<tr>
|
||||||
</table>
|
<th>{% trans "Created" %}</th>
|
||||||
</div>
|
<td class="text-sm">{{ source.created_at|date:"Y-m-d H:i" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Last Updated" %}</th>
|
||||||
|
<td class="text-sm">{{ source.updated_at|date:"Y-m-d H:i" }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Usage Records -->
|
||||||
|
<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="activity" class="w-5 h-5"></i>
|
||||||
|
{% trans "Recent Activity" %} ({{ usage_records|length }})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-0">
|
||||||
|
{% if usage_records %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b-2 border-slate-200">
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Date" %}</th>
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Type" %}</th>
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Reference" %}</th>
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "Hospital" %}</th>
|
||||||
|
<th class="text-left py-3 px-4 text-xs font-bold text-slate uppercase">{% trans "User" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for record in usage_records %}
|
||||||
|
<tr class="hover:bg-slate-50 transition">
|
||||||
|
<td class="py-3 px-4 text-sm text-slate">{{ record.created_at|date:"Y-m-d H:i" }}</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<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">
|
||||||
|
<i data-lucide="{% if record.content_type.model == 'complaint' %}file-text{% else %}help-circle{% endif %}" class="w-3 h-3"></i>
|
||||||
|
{{ 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>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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="activity" class="w-8 h-8 text-slate-400"></i>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<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="trending-up" class="w-5 h-5"></i>
|
||||||
|
{% trans "Quick Stats" %}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<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>
|
||||||
|
{% if source_users %}
|
||||||
|
<div class="space-y-2 mt-3">
|
||||||
|
{% for su in source_users|slice:":5" %}
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-blue/10 flex items-center justify-center">
|
||||||
|
<i data-lucide="user" class="w-4 h-4 text-blue"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium text-navy">{{ su.user.get_full_name }}</p>
|
||||||
|
<p class="text-xs text-slate">{{ su.user.email }}</p>
|
||||||
|
</div>
|
||||||
|
{% if su.is_active %}
|
||||||
|
<i data-lucide="check-circle" class="w-4 h-4 text-green-600"></i>
|
||||||
|
{% else %}
|
||||||
|
<i data-lucide="x-circle" class="w-4 h-4 text-slate-400"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h5>{% trans "Recent Usage" %} ({{ usage_records|length }})</h5>
|
|
||||||
{% if usage_records %}
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Date" %}</th>
|
|
||||||
<th>{% trans "Content Type" %}</th>
|
|
||||||
<th>{% trans "Object ID" %}</th>
|
|
||||||
<th>{% trans "Hospital" %}</th>
|
|
||||||
<th>{% trans "User" %}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for record in usage_records %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ record.created_at|date:"Y-m-d H:i" }}</td>
|
|
||||||
<td><code>{{ record.content_type.model }}</code></td>
|
|
||||||
<td>{{ record.object_id|truncatechars:20 }}</td>
|
|
||||||
<td>{{ record.hospital.name_en|default:"-" }}</td>
|
|
||||||
<td>{{ record.user.get_full_name|default:"-" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-muted">{% trans "No usage records found for this source." %}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Source Users Section (PX Admin only) -->
|
<!-- Actions -->
|
||||||
{% comment %} {% if request.user.is_px_admin %} {% endcomment %}
|
<div class="info-card animate-in">
|
||||||
<div class="row mt-4">
|
<div class="card-header">
|
||||||
<div class="col-12">
|
<h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
|
||||||
<div class="card">
|
<i data-lucide="settings" class="w-5 h-5"></i>
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
{% trans "Manage" %}
|
||||||
<h5 class="card-title mb-0">
|
</h2>
|
||||||
<i class="bi bi-people-fill me-2"></i>
|
</div>
|
||||||
{% trans "Source Users" %} ({{ source_users|length }})
|
<div class="p-4 space-y-2">
|
||||||
</h5>
|
{% if request.user.is_px_admin or request.user.is_hospital_admin %}
|
||||||
<a href="{% url 'px_sources:source_user_create' source.pk %}" class="btn btn-sm btn-primary">
|
<a href="{% url 'px_sources:source_user_create' source.pk %}"
|
||||||
<i class="bi bi-plus-lg me-1"></i>{% trans "Add Source User" %}
|
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>
|
</a>
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if source_users %}
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "User" %}</th>
|
|
||||||
<th>{% trans "Email" %}</th>
|
|
||||||
<th>{% trans "Status" %}</th>
|
|
||||||
<th>{% trans "Permissions" %}</th>
|
|
||||||
<th>{% trans "Created" %}</th>
|
|
||||||
<th>{% trans "Actions" %}</th>
|
|
||||||
</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 %}
|
|
||||||
<span class="badge bg-success">{% trans "Active" %}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-secondary">{% trans "Inactive" %}</span>
|
|
||||||
{% 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>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-5">
|
<a href="{% url 'px_sources:source_toggle_status' source.pk %}"
|
||||||
<i class="bi bi-people fs-1 text-muted mb-3"></i>
|
class="flex items-center gap-2 p-3 rounded-lg hover:bg-slate-50 transition text-green-600 font-medium">
|
||||||
<p class="text-muted mb-0">
|
<i data-lucide="play" class="w-4 h-4"></i>
|
||||||
{% trans "No source users assigned yet." %}
|
{% trans "Activate" %}
|
||||||
<a href="{% url 'px_sources:source_user_create' source.pk %}" class="text-primary">
|
</a>
|
||||||
{% trans "Add a source user" %}
|
|
||||||
</a>
|
|
||||||
{% trans "to get started." %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% comment %} {% endif %} {% endcomment %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
lucide.createIcons();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -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 %}
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
<h2 class="mb-1">
|
|
||||||
<i class="bi bi-{% if source %}pencil-square{% else %}plus-circle{% endif %} text-warning me-2"></i>
|
|
||||||
{% if source %}{% trans "Edit Source" %}{% else %}{% trans "Create Source" %}{% endif %}
|
{% if source %}{% trans "Edit Source" %}{% else %}{% trans "Create Source" %}{% endif %}
|
||||||
</h2>
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 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 %}
|
||||||
|
</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>
|
||||||
</a>
|
{% trans "Back to List" %}
|
||||||
</div>
|
</a>
|
||||||
</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" id="code" name="code"
|
||||||
<input type="text" class="form-control" id="name_en" name="name_en"
|
value="{{ source.code|default:'' }}"
|
||||||
value="{{ source.name_en|default:'' }}" required
|
placeholder="{% trans 'Auto-generated' %}"
|
||||||
placeholder="{% trans 'e.g., Patient Portal' %}">
|
class="form-input"
|
||||||
</div>
|
{% 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>
|
||||||
<div class="col-md-6">
|
<div class="md:col-span-2">
|
||||||
<div class="mb-3">
|
<label for="name_en" class="form-label">
|
||||||
<label for="name_ar" class="form-label">
|
{% trans "Name (English)" %} <span class="text-red-500">*</span>
|
||||||
{% trans "Name (Arabic)" %}
|
</label>
|
||||||
</label>
|
<input type="text" id="name_en" name="name_en"
|
||||||
<input type="text" class="form-control" id="name_ar" name="name_ar"
|
value="{{ source.name_en|default:'' }}" required
|
||||||
value="{{ source.name_ar|default:'' }}" dir="rtl"
|
placeholder="{% trans 'e.g., Patient Portal' %}"
|
||||||
placeholder="{% trans 'e.g., بوابة المرضى' %}">
|
class="form-input">
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
||||||
|
{% trans "Name (Arabic)" %}
|
||||||
|
</label>
|
||||||
|
<input type="text" id="name_ar" name="name_ar"
|
||||||
|
value="{{ source.name_ar|default:'' }}"
|
||||||
|
placeholder="{% trans 'e.g., بوابة المريض' %}"
|
||||||
|
dir="rtl"
|
||||||
|
class="form-input">
|
||||||
|
</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 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">
|
|
||||||
{% trans "Active" %}
|
|
||||||
</label>
|
</label>
|
||||||
|
<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>
|
||||||
<small class="form-text text-muted">
|
|
||||||
{% trans "Uncheck to deactivate this source (it won't appear in dropdowns)" %}
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
<div class="form-group">
|
||||||
<button type="submit" class="btn btn-primary">
|
<label class="checkbox-wrapper">
|
||||||
<i class="fas fa-save"></i> {% trans "Save" %}
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 pt-6 border-t border-slate-200 flex gap-3">
|
||||||
|
<button type="submit" class="btn-primary">
|
||||||
|
<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>
|
||||||
{% endblock %}
|
|
||||||
|
<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 %}
|
||||||
|
|||||||
@ -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>
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="mb-1">
|
<div>
|
||||||
<i class="bi bi-lightning-fill text-warning me-2"></i>
|
<h1 class="text-2xl font-bold mb-2">
|
||||||
{% trans "PX Sources" %}
|
<i data-lucide="radio" class="w-7 h-7 inline-block me-2"></i>
|
||||||
</h2>
|
{% trans "PX Sources" %}
|
||||||
<p class="text-muted mb-0">{% trans "Manage patient experience source channels" %}</p>
|
</h1>
|
||||||
</div>
|
<p class="text-white/90">{% trans "Manage patient experience source channels" %}</p>
|
||||||
<div>
|
</div>
|
||||||
{% comment %} {% if request.user.is_px_admin %} {% endcomment %}
|
<a href="{% url 'px_sources:source_create' %}"
|
||||||
<a href="{% url 'px_sources:source_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">
|
||||||
{% action_icon 'create' %} {% trans "Add Source" %}
|
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||||
|
{% 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>
|
||||||
<div class="col-md-3">
|
<div class="w-48">
|
||||||
<select id="status-filter" class="form-select">
|
<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>
|
|
||||||
{% if request.user.is_px_admin %}
|
|
||||||
<a href="{% url 'px_sources:source_edit' source.pk %}"
|
|
||||||
class="btn btn-sm btn-warning" title="{% trans 'Edit' %}">
|
|
||||||
{% action_icon 'edit' %}
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'px_sources:source_delete' source.pk %}"
|
|
||||||
class="btn btn-sm btn-danger" title="{% trans 'Delete' %}">
|
|
||||||
{% action_icon 'delete' %}
|
|
||||||
</a>
|
</a>
|
||||||
|
</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 %}
|
{% endif %}
|
||||||
</td>
|
</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 %}"
|
||||||
|
class="p-2 text-navy hover:bg-navy/10 rounded-lg transition" title="{% trans 'Edit' %}">
|
||||||
|
<i data-lucide="edit" class="w-4 h-4"></i>
|
||||||
|
</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 %}"
|
||||||
|
class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition" title="{% trans 'Delete' %}">
|
||||||
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</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();
|
||||||
|
|||||||
@ -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">
|
||||||
<!-- Search -->
|
<i data-lucide="filter" class="w-4 h-4 text-slate"></i>
|
||||||
<div class="col-md-4">
|
{% trans "Filters" %}
|
||||||
<label class="form-label">{% trans "Search" %}</label>
|
</h2>
|
||||||
<input type="text" class="form-control" name="search"
|
</div>
|
||||||
placeholder="{% trans 'Title, patient name...' %}"
|
<div class="p-4">
|
||||||
value="{{ search|default:'' }}">
|
<form method="get" class="grid grid-cols-1 md:grid-cols-12 gap-4">
|
||||||
</div>
|
<!-- Search -->
|
||||||
|
<div class="md:col-span-4">
|
||||||
|
<label class="block text-sm font-medium text-slate mb-1">{% trans "Search" %}</label>
|
||||||
|
<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...' %}"
|
||||||
|
value="{{ search|default:'' }}">
|
||||||
|
</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,117 +106,135 @@
|
|||||||
</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>
|
||||||
</tr>
|
<th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Status" %}</th>
|
||||||
</thead>
|
<th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Priority" %}</th>
|
||||||
<tbody>
|
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Assigned To" %}</th>
|
||||||
{% for complaint in complaints %}
|
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Created" %}</th>
|
||||||
<tr>
|
<th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Actions" %}</th>
|
||||||
<td><code>{{ complaint.id|slice:":8" }}</code></td>
|
</tr>
|
||||||
<td>{{ complaint.title|truncatewords:8 }}</td>
|
</thead>
|
||||||
<td>
|
<tbody class="divide-y divide-slate-100">
|
||||||
{% if complaint.patient %}
|
{% for complaint in complaints %}
|
||||||
<strong>{{ complaint.patient.get_full_name }}</strong><br>
|
<tr class="hover:bg-slate-50 transition">
|
||||||
<small class="text-muted">{% trans "MRN" %}: {{ complaint.patient.mrn }}</small>
|
<td class="py-3 px-4 text-sm font-mono text-slate">{{ complaint.id|slice:":8" }}</td>
|
||||||
{% else %}
|
<td class="py-3 px-4">
|
||||||
<em class="text-muted">{% trans "Not specified" %}</em>
|
<a href="{% url 'complaints:complaint_detail' complaint.pk %}"
|
||||||
{% endif %}
|
class="text-navy font-semibold hover:text-blue transition">
|
||||||
</td>
|
{{ complaint.title|truncatewords:8 }}
|
||||||
<td><span class="badge bg-secondary">{{ complaint.get_category_display }}</span></td>
|
</a>
|
||||||
<td>
|
</td>
|
||||||
{% if complaint.status == 'open' %}
|
<td class="py-3 px-4">
|
||||||
<span class="badge bg-danger">{% trans "Open" %}</span>
|
{% if complaint.patient %}
|
||||||
{% elif complaint.status == 'in_progress' %}
|
<div class="font-medium text-slate">{{ complaint.patient.get_full_name }}</div>
|
||||||
<span class="badge bg-warning text-dark">{% trans "In Progress" %}</span>
|
<div class="text-xs text-slate/70">{% trans "MRN" %}: {{ complaint.patient.mrn }}</div>
|
||||||
{% elif complaint.status == 'resolved' %}
|
{% else %}
|
||||||
<span class="badge bg-success">{% trans "Resolved" %}</span>
|
<span class="text-slate/60 italic">{% trans "Not specified" %}</span>
|
||||||
{% else %}
|
{% endif %}
|
||||||
<span class="badge bg-secondary">{% trans "Closed" %}</span>
|
</td>
|
||||||
{% endif %}
|
<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">
|
||||||
<td>
|
{{ complaint.get_category_display }}
|
||||||
{% if complaint.priority == 'high' %}
|
</span>
|
||||||
<span class="badge bg-danger">{% trans "High" %}</span>
|
</td>
|
||||||
{% elif complaint.priority == 'medium' %}
|
<td class="py-3 px-4 text-center">
|
||||||
<span class="badge bg-warning text-dark">{% trans "Medium" %}</span>
|
{% if complaint.status == 'open' %}
|
||||||
{% else %}
|
<span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">{% trans "Open" %}</span>
|
||||||
<span class="badge bg-success">{% trans "Low" %}</span>
|
{% elif complaint.status == 'in_progress' %}
|
||||||
{% endif %}
|
<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>
|
||||||
</td>
|
{% elif complaint.status == 'resolved' %}
|
||||||
<td>
|
<span class="inline-flex items-center px-2 py-1 bg-green-100 text-green-700 rounded text-xs font-medium">{% trans "Resolved" %}</span>
|
||||||
{% if complaint.assigned_to %}
|
{% else %}
|
||||||
{{ complaint.assigned_to.get_full_name }}
|
<span class="inline-flex items-center px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs font-medium">{% trans "Closed" %}</span>
|
||||||
{% else %}
|
{% endif %}
|
||||||
<span class="text-muted"><em>{% trans "Unassigned" %}</em></span>
|
</td>
|
||||||
{% endif %}
|
<td class="py-3 px-4 text-center">
|
||||||
</td>
|
{% if complaint.priority == 'high' %}
|
||||||
<td><small class="text-muted">{{ complaint.created_at|date:"Y-m-d" }}</small></td>
|
<span class="inline-flex items-center px-2 py-1 bg-red-100 text-red-700 rounded text-xs font-medium">{% trans "High" %}</span>
|
||||||
<td>
|
{% elif complaint.priority == 'medium' %}
|
||||||
<a href="{% url 'complaints:complaint_detail' complaint.pk %}"
|
<span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">{% trans "Medium" %}</span>
|
||||||
class="btn btn-sm btn-info"
|
{% else %}
|
||||||
title="{% trans 'View' %}">
|
<span class="inline-flex items-center px-2 py-1 bg-green-100 text-green-700 rounded text-xs font-medium">{% trans "Low" %}</span>
|
||||||
<i class="bi bi-eye"></i>
|
{% endif %}
|
||||||
</a>
|
</td>
|
||||||
</td>
|
<td class="py-3 px-4 text-sm text-slate">
|
||||||
</tr>
|
{% if complaint.assigned_to %}
|
||||||
{% empty %}
|
{{ complaint.assigned_to.get_full_name }}
|
||||||
<tr>
|
{% else %}
|
||||||
<td colspan="9" class="text-center py-5">
|
<span class="text-slate/60 italic">{% trans "Unassigned" %}</span>
|
||||||
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
|
{% endif %}
|
||||||
<p class="text-muted mt-3">
|
</td>
|
||||||
{% trans "No complaints found for your source." %}
|
<td class="py-3 px-4 text-sm text-slate">{{ complaint.created_at|date:"Y-m-d" }}</td>
|
||||||
</p>
|
<td class="py-3 px-4 text-center">
|
||||||
{% if source_user.can_create_complaints %}
|
<a href="{% url 'complaints:complaint_detail' complaint.pk %}"
|
||||||
<a href="{% url 'complaints:complaint_create' %}" class="btn btn-primary">
|
class="inline-flex items-center justify-center p-2 bg-blue text-white rounded-lg hover:bg-navy transition"
|
||||||
<i class="bi bi-plus-circle me-1"></i> {% trans "Create Complaint" %}
|
title="{% trans 'View' %}">
|
||||||
</a>
|
<i data-lucide="eye" class="w-4 h-4"></i>
|
||||||
{% endif %}
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% empty %}
|
||||||
</tbody>
|
<tr>
|
||||||
</table>
|
<td colspan="9" class="text-center py-12">
|
||||||
</div>
|
<i data-lucide="inbox" class="w-16 h-16 mx-auto text-slate/30 mb-4"></i>
|
||||||
|
<p class="text-slate mb-4">{% trans "No complaints found for your source." %}</p>
|
||||||
|
{% if source_user.can_create_complaints %}
|
||||||
|
<a href="{% url 'complaints:complaint_create' %}"
|
||||||
|
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>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</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>
|
||||||
{% endblock %}
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
lucide.createIcons();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@ -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">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav class="mb-4">
|
||||||
|
<ol class="flex items-center gap-2 text-sm text-slate">
|
||||||
|
<li><a href="{% url 'px_sources:source_list' %}" class="text-blue hover:text-navy">{% trans "PX Sources" %}</a></li>
|
||||||
|
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||||
|
<li><a href="{% url 'px_sources:source_detail' source.pk %}" class="text-blue hover:text-navy">{{ source.name_en }}</a></li>
|
||||||
|
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||||
|
<li class="text-navy font-semibold">{% trans "Delete Source User" %}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- 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>
|
||||||
<nav aria-label="breadcrumb">
|
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
|
||||||
<ol class="breadcrumb mb-2">
|
<i data-lucide="alert-triangle" class="w-8 h-8 text-red-500"></i>
|
||||||
<li class="breadcrumb-item">
|
|
||||||
<a href="{% url 'px_sources:source_list' %}">{% trans "PX Sources" %}</a>
|
|
||||||
</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>
|
|
||||||
</nav>
|
|
||||||
<h2 class="mb-1">
|
|
||||||
<i class="bi bi-exclamation-triangle text-danger me-2"></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">
|
{% trans "Confirm Deletion" %}
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
</h2>
|
||||||
{% trans "Confirm Deletion" %}
|
</div>
|
||||||
</h5>
|
<div class="p-6">
|
||||||
</div>
|
<!-- Warning Alert -->
|
||||||
<div class="card-body">
|
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||||
<div class="alert alert-danger">
|
<div class="flex items-center gap-3">
|
||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-600"></i>
|
||||||
<strong>{% trans "Warning:" %}</strong> {% trans "This action cannot be undone!" %}
|
<strong class="text-red-800">{% trans "Warning:" %}</strong>
|
||||||
</div>
|
<span class="text-red-700">{% trans "This action cannot be undone!" %}</span>
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<p>{% trans "Are you sure you want to remove the following source user?" %}</p>
|
|
||||||
|
|
||||||
<div class="card bg-light">
|
|
||||||
<div class="card-body">
|
|
||||||
<dl class="row mb-0">
|
|
||||||
<dt class="col-sm-3">{% trans "User" %}:</dt>
|
|
||||||
<dd class="col-sm-9">
|
|
||||||
<strong>{{ source_user.user.email }}</strong>
|
|
||||||
{% if source_user.user.get_full_name %}
|
|
||||||
<br><small class="text-muted">{{ source_user.user.get_full_name }}</small>
|
|
||||||
{% endif %}
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-3">{% trans "Source" %}:</dt>
|
|
||||||
<dd class="col-sm-9">{{ source.name_en }}</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-3">{% trans "Status" %}:</dt>
|
|
||||||
<dd class="col-sm-9">
|
|
||||||
{% if source_user.is_active %}
|
|
||||||
<span class="badge bg-success">{% trans "Active" %}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-secondary">{% trans "Inactive" %}</span>
|
|
||||||
{% endif %}
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-3">{% trans "Permissions" %}:</dt>
|
|
||||||
<dd class="col-sm-9">
|
|
||||||
{% if source_user.can_create_complaints %}
|
|
||||||
<span class="badge bg-primary">{% trans "Complaints" %}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if source_user.can_create_inquiries %}
|
|
||||||
<span class="badge bg-info">{% trans "Inquiries" %}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if not source_user.can_create_complaints and not source_user.can_create_inquiries %}
|
|
||||||
<span class="text-muted">{% trans "None" %}</span>
|
|
||||||
{% endif %}
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<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>
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="submit" class="btn btn-danger">
|
|
||||||
<i class="bi bi-trash me-1"></i> {% trans "Yes, Delete" %}
|
|
||||||
</button>
|
|
||||||
<a href="{% url 'px_sources:source_detail' source.pk %}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-x-lg me-1"></i> {% trans "Cancel" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- User Details -->
|
||||||
|
<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="bg-slate-50 rounded-xl border border-slate-200 p-4">
|
||||||
|
<dl class="space-y-3 mb-0">
|
||||||
|
<div class="flex">
|
||||||
|
<dt class="w-32 text-sm font-semibold text-navy">{% trans "User" %}:</dt>
|
||||||
|
<dd class="flex-1">
|
||||||
|
<strong class="text-navy">{{ source_user.user.email }}</strong>
|
||||||
|
{% if source_user.user.get_full_name %}
|
||||||
|
<br><small class="text-slate">{{ source_user.user.get_full_name }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<dt class="w-32 text-sm font-semibold text-navy">{% trans "Source" %}:</dt>
|
||||||
|
<dd class="flex-1 text-navy">{{ source.name_en }}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<dt class="w-32 text-sm font-semibold text-navy">{% trans "Status" %}:</dt>
|
||||||
|
<dd class="flex-1">
|
||||||
|
{% if source_user.is_active %}
|
||||||
|
<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 %}
|
||||||
|
<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 %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<dt class="w-32 text-sm font-semibold text-navy">{% trans "Permissions" %}:</dt>
|
||||||
|
<dd class="flex-1">
|
||||||
|
{% if source_user.can_create_complaints %}
|
||||||
|
<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 %}
|
||||||
|
{% if source_user.can_create_inquiries %}
|
||||||
|
<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 %}
|
||||||
|
{% if not source_user.can_create_complaints and not source_user.can_create_inquiries %}
|
||||||
|
<span class="text-slate text-sm">{% trans "None" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button type="submit"
|
||||||
|
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>
|
||||||
|
<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">
|
||||||
|
<i data-lucide="x" class="w-4 h-4"></i> {% trans "Cancel" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
lucide.createIcons();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@ -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>
|
</div>
|
||||||
|
<i data-lucide="file-text" class="w-10 h-10 text-white/30"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<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="card bg-warning text-dark">
|
<div class="flex items-center justify-between">
|
||||||
<div class="card-body">
|
<div>
|
||||||
<h6 class="card-title">{% trans "Open Complaints" %}</h6>
|
<p class="text-white/80 text-sm">{% trans "Open Complaints" %}</p>
|
||||||
<h2 class="mb-0">{{ open_complaints }}</h2>
|
<p class="text-2xl font-bold">{{ open_complaints }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<i data-lucide="alert-circle" class="w-10 h-10 text-white/30"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<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="card bg-info text-white">
|
<div class="flex items-center justify-between">
|
||||||
<div class="card-body">
|
<div>
|
||||||
<h6 class="card-title">{% trans "Total Inquiries" %}</h6>
|
<p class="text-white/80 text-sm">{% trans "Total Inquiries" %}</p>
|
||||||
<h2 class="mb-0">{{ total_inquiries }}</h2>
|
<p class="text-2xl font-bold">{{ total_inquiries }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<i data-lucide="help-circle" class="w-10 h-10 text-white/30"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<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="card bg-secondary text-white">
|
<div class="flex items-center justify-between">
|
||||||
<div class="card-body">
|
<div>
|
||||||
<h6 class="card-title">{% trans "Open Inquiries" %}</h6>
|
<p class="text-white/80 text-sm">{% trans "Open Inquiries" %}</p>
|
||||||
<h2 class="mb-0">{{ open_inquiries }}</h2>
|
<p class="text-2xl font-bold">{{ open_inquiries }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<i data-lucide="message-circle" class="w-10 h-10 text-white/30"></i>
|
||||||
</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 }})
|
</div>
|
||||||
</h5>
|
<div class="p-4">
|
||||||
</div>
|
{% if complaints %}
|
||||||
<div class="card-body">
|
<div class="overflow-x-auto">
|
||||||
<div class="table-responsive">
|
<table class="w-full">
|
||||||
<table class="table table-hover">
|
<thead>
|
||||||
<thead class="table-light">
|
<tr class="border-b border-slate-200">
|
||||||
<tr>
|
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "ID" %}</th>
|
||||||
<th>{% trans "ID" %}</th>
|
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Title" %}</th>
|
||||||
<th>{% trans "Title" %}</th>
|
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Patient" %}</th>
|
||||||
<th>{% trans "Patient" %}</th>
|
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Category" %}</th>
|
||||||
<th>{% trans "Category" %}</th>
|
<th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Status" %}</th>
|
||||||
<th>{% trans "Status" %}</th>
|
<th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Priority" %}</th>
|
||||||
<th>{% trans "Priority" %}</th>
|
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Created" %}</th>
|
||||||
<th>{% trans "Created" %}</th>
|
</tr>
|
||||||
<th>{% trans "Actions" %}</th>
|
</thead>
|
||||||
</tr>
|
<tbody class="divide-y divide-slate-100">
|
||||||
</thead>
|
{% for complaint in complaints|slice:":10" %}
|
||||||
<tbody>
|
<tr class="hover:bg-slate-50 transition">
|
||||||
{% for complaint in complaints %}
|
<td class="py-3 px-4 text-sm font-mono text-slate">{{ complaint.reference_number|default:complaint.id|truncatechars:12 }}</td>
|
||||||
<tr>
|
<td class="py-3 px-4">
|
||||||
<td><code>{{ complaint.id|slice:":8" }}</code></td>
|
<a href="{% url 'complaints:complaint_detail' complaint.pk %}"
|
||||||
<td>{{ complaint.title|truncatewords:8 }}</td>
|
class="text-navy font-semibold hover:text-blue transition">
|
||||||
<td>{{ complaint.patient.get_full_name }}</td>
|
{{ complaint.title|truncatechars:40 }}
|
||||||
<td>{{ complaint.get_category_display }}</td>
|
</a>
|
||||||
<td>
|
</td>
|
||||||
{% if complaint.status == 'open' %}
|
<td class="py-3 px-4 text-sm text-slate">{{ complaint.patient.get_full_name|default:"-" }}</td>
|
||||||
<span class="badge bg-danger">{% trans "Open" %}</span>
|
<td class="py-3 px-4 text-sm text-slate">{{ complaint.category|default:"-" }}</td>
|
||||||
{% elif complaint.status == 'in_progress' %}
|
<td class="py-3 px-4 text-center">
|
||||||
<span class="badge bg-warning text-dark">{% trans "In Progress" %}</span>
|
{% if complaint.status == 'open' %}
|
||||||
{% elif complaint.status == 'resolved' %}
|
<span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">{% trans "Open" %}</span>
|
||||||
<span class="badge bg-success">{% trans "Resolved" %}</span>
|
{% elif complaint.status == 'in_progress' %}
|
||||||
{% else %}
|
<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>
|
||||||
<span class="badge bg-secondary">{% trans "Closed" %}</span>
|
{% elif complaint.status == 'resolved' %}
|
||||||
{% endif %}
|
<span class="inline-flex items-center px-2 py-1 bg-green-100 text-green-700 rounded text-xs font-medium">{% trans "Resolved" %}</span>
|
||||||
</td>
|
{% else %}
|
||||||
<td>
|
<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>
|
||||||
{% if complaint.priority == 'high' %}
|
{% endif %}
|
||||||
<span class="badge bg-danger">{% trans "High" %}</span>
|
</td>
|
||||||
{% elif complaint.priority == 'medium' %}
|
<td class="py-3 px-4 text-center">
|
||||||
<span class="badge bg-warning text-dark">{% trans "Medium" %}</span>
|
{% if complaint.priority == 'high' %}
|
||||||
{% else %}
|
<span class="inline-flex items-center px-2 py-1 bg-red-100 text-red-700 rounded text-xs font-medium">{% trans "High" %}</span>
|
||||||
<span class="badge bg-success">{% trans "Low" %}</span>
|
{% elif complaint.priority == 'medium' %}
|
||||||
{% endif %}
|
<span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">{% trans "Medium" %}</span>
|
||||||
</td>
|
{% else %}
|
||||||
<td>{{ complaint.created_at|date:"Y-m-d" }}</td>
|
<span class="inline-flex items-center px-2 py-1 bg-green-100 text-green-700 rounded text-xs font-medium">{% trans "Low" %}</span>
|
||||||
<td>
|
{% endif %}
|
||||||
<a href="{% url 'complaints:complaint_detail' complaint.pk %}"
|
</td>
|
||||||
class="btn btn-sm btn-info"
|
<td class="py-3 px-4 text-sm text-slate">{{ complaint.created_at|date:"Y-m-d" }}</td>
|
||||||
title="{% trans 'View' %}">
|
</tr>
|
||||||
{% action_icon 'view' %}
|
{% endfor %}
|
||||||
</a>
|
</tbody>
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="8" class="text-center py-4">
|
|
||||||
<p class="text-muted mb-2">
|
|
||||||
<i class="bi bi-inbox fs-1"></i>
|
|
||||||
</p>
|
|
||||||
<p>{% trans "No complaints found for this source." %}</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
|
{% 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="p-4">
|
||||||
<div class="card-body">
|
{% if inquiries %}
|
||||||
<div class="table-responsive">
|
<div class="overflow-x-auto">
|
||||||
<table class="table table-hover">
|
<table class="w-full">
|
||||||
<thead class="table-light">
|
<thead>
|
||||||
<tr>
|
<tr class="border-b border-slate-200">
|
||||||
<th>{% trans "ID" %}</th>
|
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "ID" %}</th>
|
||||||
<th>{% trans "Subject" %}</th>
|
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Subject" %}</th>
|
||||||
<th>{% trans "Patient" %}</th>
|
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "From" %}</th>
|
||||||
<th>{% trans "Category" %}</th>
|
<th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Status" %}</th>
|
||||||
<th>{% trans "Status" %}</th>
|
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Created" %}</th>
|
||||||
<th>{% trans "Created" %}</th>
|
</tr>
|
||||||
<th>{% trans "Actions" %}</th>
|
</thead>
|
||||||
</tr>
|
<tbody class="divide-y divide-slate-100">
|
||||||
</thead>
|
{% for inquiry in inquiries|slice:":10" %}
|
||||||
<tbody>
|
<tr class="hover:bg-slate-50 transition">
|
||||||
{% for inquiry in inquiries %}
|
<td class="py-3 px-4 text-sm font-mono text-slate">{{ inquiry.reference_number|default:inquiry.id|truncatechars:12 }}</td>
|
||||||
<tr>
|
<td class="py-3 px-4">
|
||||||
<td><code>{{ inquiry.id|slice:":8" }}</code></td>
|
<span class="text-navy font-medium">{{ inquiry.subject|truncatechars:40 }}</span>
|
||||||
<td>{{ inquiry.subject|truncatewords:8 }}</td>
|
</td>
|
||||||
<td>
|
<td class="py-3 px-4 text-sm text-slate">{{ inquiry.name }}</td>
|
||||||
{% if inquiry.patient %}
|
<td class="py-3 px-4 text-center">
|
||||||
{{ inquiry.patient.get_full_name }}
|
{% if inquiry.status == 'open' %}
|
||||||
{% else %}
|
<span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">{% trans "Open" %}</span>
|
||||||
{{ inquiry.contact_name|default:"-" }}
|
{% elif inquiry.status == 'in_progress' %}
|
||||||
{% endif %}
|
<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>
|
||||||
</td>
|
{% elif inquiry.status == 'resolved' %}
|
||||||
<td>{{ inquiry.get_category_display }}</td>
|
<span class="inline-flex items-center px-2 py-1 bg-green-100 text-green-700 rounded text-xs font-medium">{% trans "Resolved" %}</span>
|
||||||
<td>
|
{% else %}
|
||||||
{% if inquiry.status == 'open' %}
|
<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>
|
||||||
<span class="badge bg-danger">{% trans "Open" %}</span>
|
{% endif %}
|
||||||
{% elif inquiry.status == 'in_progress' %}
|
</td>
|
||||||
<span class="badge bg-warning text-dark">{% trans "In Progress" %}</span>
|
<td class="py-3 px-4 text-sm text-slate">{{ inquiry.created_at|date:"Y-m-d" }}</td>
|
||||||
{% elif inquiry.status == 'resolved' %}
|
</tr>
|
||||||
<span class="badge bg-success">{% trans "Resolved" %}</span>
|
{% endfor %}
|
||||||
{% else %}
|
</tbody>
|
||||||
<span class="badge bg-secondary">{% trans "Closed" %}</span>
|
</table>
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ 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>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
lucide.createIcons();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@ -4,142 +4,147 @@
|
|||||||
{% 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">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav class="mb-4">
|
||||||
|
<ol class="flex items-center gap-2 text-sm text-slate">
|
||||||
|
<li><a href="{% url 'px_sources:source_list' %}" class="text-blue hover:text-navy">{% trans "PX Sources" %}</a></li>
|
||||||
|
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||||
|
<li><a href="{% url 'px_sources:source_detail' source.pk %}" class="text-blue hover:text-navy">{{ source.name_en }}</a></li>
|
||||||
|
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
|
||||||
|
<li class="text-navy font-semibold">
|
||||||
|
{% if source_user %}{% trans "Edit Source User" %}{% else %}{% trans "Create Source User" %}{% endif %}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- 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>
|
||||||
<nav aria-label="breadcrumb">
|
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
|
||||||
<ol class="breadcrumb mb-2">
|
|
||||||
<li class="breadcrumb-item">
|
|
||||||
<a href="{% url 'px_sources:source_list' %}">{% trans "PX Sources" %}</a>
|
|
||||||
</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">
|
|
||||||
{% if source_user %}{% trans "Edit Source User" %}{% else %}{% trans "Create Source User" %}{% endif %}
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
<h2 class="mb-1">
|
|
||||||
{% 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 class="p-6">
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% if not source_user %}
|
||||||
|
<!-- User Selection (only for new source users) -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="id_user" class="block text-sm font-semibold text-navy mb-2">
|
||||||
|
{% trans "User" %} <span class="text-red-500">*</span>
|
||||||
|
</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>
|
||||||
|
{% for user in available_users %}
|
||||||
|
<option value="{{ user.id }}" {% if form.user.value == user.id %}selected{% endif %}>
|
||||||
|
{{ user.email }} {% if user.get_full_name %}({{ user.get_full_name }}){% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="text-slate text-sm mt-1">
|
||||||
|
{% trans "Select a user to assign as source user. A user can only manage one source." %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
{% else %}
|
||||||
<form method="POST" novalidate>
|
<!-- User Display (for editing) -->
|
||||||
{% csrf_token %}
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-semibold text-navy mb-2">{% trans "User" %}</label>
|
||||||
{% if not source_user %}
|
<input type="text"
|
||||||
<!-- User Selection (only for new source users) -->
|
value="{{ source_user.user.email }} {% if source_user.user.get_full_name %}({{ source_user.user.get_full_name }}){% endif %}"
|
||||||
<div class="row mb-3">
|
readonly
|
||||||
<div class="col-md-6">
|
class="w-full px-4 py-2 border border-slate-200 rounded-lg bg-slate-100 text-slate-600 cursor-not-allowed">
|
||||||
<label for="id_user" class="form-label">{% trans "User" %} <span class="text-danger">*</span></label>
|
|
||||||
<select name="user" id="id_user" class="form-select" required>
|
|
||||||
<option value="">{% trans "Select a user" %}</option>
|
|
||||||
{% for user in available_users %}
|
|
||||||
<option value="{{ user.id }}" {% if form.user.value == user.id %}selected{% endif %}>
|
|
||||||
{{ user.email }} {% if user.get_full_name %}({{ user.get_full_name }}){% endif %}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<div class="form-text">
|
|
||||||
{% trans "Select a user to assign as source user. A user can only manage one source." %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<!-- User Display (for editing) -->
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">{% trans "User" %}</label>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">{% trans "Status" %}</label>
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" name="is_active" id="id_is_active" {% if source_user.is_active|default:True %}checked{% endif %}>
|
|
||||||
<label class="form-check-label" for="id_is_active">
|
|
||||||
{% trans "Active" %}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">
|
|
||||||
{% trans "Inactive users will not be able to access their dashboard." %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<!-- Permissions -->
|
|
||||||
<h5 class="mb-3">{% trans "Permissions" %}</h5>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="form-check">
|
|
||||||
<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 %}>
|
|
||||||
<label class="form-check-label" for="id_can_create_complaints">
|
|
||||||
{% trans "Can create complaints" %}
|
|
||||||
</label>
|
|
||||||
</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">
|
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
|
||||||
{% trans "Permissions control what the source user can do in their dashboard. Uncheck to restrict access." %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submit Buttons -->
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="bi bi-check-lg me-1"></i> {% trans "Save" %}
|
|
||||||
</button>
|
|
||||||
<a href="{% url 'px_sources:source_detail' source.pk %}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-x-lg me-1"></i> {% trans "Cancel" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-semibold text-navy mb-2">{% trans "Status" %}</label>
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox" name="is_active" id="id_is_active"
|
||||||
|
{% if source_user.is_active|default:True %}checked{% endif %}
|
||||||
|
class="w-5 h-5 text-navy border-slate-300 rounded focus:ring-blue">
|
||||||
|
<span class="text-navy font-medium">{% trans "Active" %}</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-slate text-sm mt-1">
|
||||||
|
{% trans "Inactive users will not be able to access their dashboard." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-slate-200 my-6"></div>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
<h5 class="text-lg font-semibold text-navy mb-4">{% trans "Permissions" %}</h5>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox" name="can_create_complaints" id="id_can_create_complaints"
|
||||||
|
{% if source_user.can_create_complaints|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 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>
|
||||||
|
</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 "Permissions control what the source user can do in their dashboard. Uncheck to restrict access." %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="border-t border-slate-200 pt-4 flex gap-3">
|
||||||
|
<button type="submit"
|
||||||
|
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">
|
||||||
|
<i data-lucide="check" class="w-4 h-4"></i> {% trans "Save" %}
|
||||||
|
</button>
|
||||||
|
<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">
|
||||||
|
<i data-lucide="x" class="w-4 h-4"></i> {% trans "Cancel" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
lucide.createIcons();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@ -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">
|
||||||
<!-- Search -->
|
<i data-lucide="filter" class="w-4 h-4 text-slate"></i>
|
||||||
<div class="col-md-5">
|
{% trans "Filters" %}
|
||||||
<label class="form-label">{% trans "Search" %}</label>
|
</h2>
|
||||||
<input type="text" class="form-control" name="search"
|
</div>
|
||||||
placeholder="{% trans 'Subject, contact name...' %}"
|
<div class="p-4">
|
||||||
value="{{ search|default:'' }}">
|
<form method="get" class="grid grid-cols-1 md:grid-cols-12 gap-4">
|
||||||
</div>
|
<!-- Search -->
|
||||||
|
<div class="md:col-span-5">
|
||||||
|
<label class="block text-sm font-medium text-slate mb-1">{% trans "Search" %}</label>
|
||||||
|
<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...' %}"
|
||||||
|
value="{{ search|default:'' }}">
|
||||||
|
</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,108 +94,126 @@
|
|||||||
</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>
|
||||||
</tr>
|
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Category" %}</th>
|
||||||
</thead>
|
<th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Status" %}</th>
|
||||||
<tbody>
|
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Assigned To" %}</th>
|
||||||
{% for inquiry in inquiries %}
|
<th class="text-left py-3 px-4 text-sm font-semibold text-navy">{% trans "Created" %}</th>
|
||||||
<tr>
|
<th class="text-center py-3 px-4 text-sm font-semibold text-navy">{% trans "Actions" %}</th>
|
||||||
<td><code>{{ inquiry.id|slice:":8" }}</code></td>
|
</tr>
|
||||||
<td>{{ inquiry.subject|truncatewords:8 }}</td>
|
</thead>
|
||||||
<td>
|
<tbody class="divide-y divide-slate-100">
|
||||||
{% if inquiry.patient %}
|
{% for inquiry in inquiries %}
|
||||||
<strong>{{ inquiry.patient.get_full_name }}</strong><br>
|
<tr class="hover:bg-slate-50 transition">
|
||||||
<small class="text-muted">{% trans "MRN" %}: {{ inquiry.patient.mrn }}</small>
|
<td class="py-3 px-4 text-sm font-mono text-slate">{{ inquiry.id|slice:":8" }}</td>
|
||||||
{% else %}
|
<td class="py-3 px-4">
|
||||||
{{ inquiry.contact_name|default:"-" }}<br>
|
<a href="{% url 'complaints:inquiry_detail' inquiry.pk %}"
|
||||||
<small class="text-muted">{{ inquiry.contact_email|default:"-" }}</small>
|
class="text-navy font-semibold hover:text-blue transition">
|
||||||
{% endif %}
|
{{ inquiry.subject|truncatewords:8 }}
|
||||||
</td>
|
</a>
|
||||||
<td><span class="badge bg-secondary">{{ inquiry.get_category_display }}</span></td>
|
</td>
|
||||||
<td>
|
<td class="py-3 px-4">
|
||||||
{% if inquiry.status == 'open' %}
|
{% if inquiry.patient %}
|
||||||
<span class="badge bg-danger">{% trans "Open" %}</span>
|
<div class="font-medium text-slate">{{ inquiry.patient.get_full_name }}</div>
|
||||||
{% elif inquiry.status == 'in_progress' %}
|
<div class="text-xs text-slate/70">{% trans "MRN" %}: {{ inquiry.patient.mrn }}</div>
|
||||||
<span class="badge bg-warning text-dark">{% trans "In Progress" %}</span>
|
{% else %}
|
||||||
{% elif inquiry.status == 'resolved' %}
|
<div class="font-medium text-slate">{{ inquiry.contact_name|default:"-" }}</div>
|
||||||
<span class="badge bg-success">{% trans "Resolved" %}</span>
|
<div class="text-xs text-slate/70">{{ inquiry.contact_email|default:"-" }}</div>
|
||||||
{% else %}
|
{% endif %}
|
||||||
<span class="badge bg-secondary">{% trans "Closed" %}</span>
|
</td>
|
||||||
{% endif %}
|
<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">
|
||||||
<td>
|
{{ inquiry.get_category_display }}
|
||||||
{% if inquiry.assigned_to %}
|
</span>
|
||||||
{{ inquiry.assigned_to.get_full_name }}
|
</td>
|
||||||
{% else %}
|
<td class="py-3 px-4 text-center">
|
||||||
<span class="text-muted"><em>{% trans "Unassigned" %}</em></span>
|
{% if inquiry.status == 'open' %}
|
||||||
{% endif %}
|
<span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">{% trans "Open" %}</span>
|
||||||
</td>
|
{% elif inquiry.status == 'in_progress' %}
|
||||||
<td><small class="text-muted">{{ inquiry.created_at|date:"Y-m-d" }}</small></td>
|
<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>
|
||||||
<td>
|
{% elif inquiry.status == 'resolved' %}
|
||||||
<a href="{% url 'complaints:inquiry_detail' inquiry.pk %}"
|
<span class="inline-flex items-center px-2 py-1 bg-green-100 text-green-700 rounded text-xs font-medium">{% trans "Resolved" %}</span>
|
||||||
class="btn btn-sm btn-info"
|
{% else %}
|
||||||
title="{% trans 'View' %}">
|
<span class="inline-flex items-center px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs font-medium">{% trans "Closed" %}</span>
|
||||||
<i class="bi bi-eye"></i>
|
{% endif %}
|
||||||
</a>
|
</td>
|
||||||
</td>
|
<td class="py-3 px-4 text-sm text-slate">
|
||||||
</tr>
|
{% if inquiry.assigned_to %}
|
||||||
{% empty %}
|
{{ inquiry.assigned_to.get_full_name }}
|
||||||
<tr>
|
{% else %}
|
||||||
<td colspan="8" class="text-center py-5">
|
<span class="text-slate/60 italic">{% trans "Unassigned" %}</span>
|
||||||
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
|
{% endif %}
|
||||||
<p class="text-muted mt-3">
|
</td>
|
||||||
{% trans "No inquiries found for your source." %}
|
<td class="py-3 px-4 text-sm text-slate">{{ inquiry.created_at|date:"Y-m-d" }}</td>
|
||||||
</p>
|
<td class="py-3 px-4 text-center">
|
||||||
{% if source_user.can_create_inquiries %}
|
<a href="{% url 'complaints:inquiry_detail' inquiry.pk %}"
|
||||||
<a href="{% url 'complaints:inquiry_create' %}" class="btn btn-primary">
|
class="inline-flex items-center justify-center p-2 bg-blue text-white rounded-lg hover:bg-navy transition"
|
||||||
<i class="bi bi-plus-circle me-1"></i> {% trans "Create Inquiry" %}
|
title="{% trans 'View' %}">
|
||||||
</a>
|
<i data-lucide="eye" class="w-4 h-4"></i>
|
||||||
{% endif %}
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% empty %}
|
||||||
</tbody>
|
<tr>
|
||||||
</table>
|
<td colspan="8" class="text-center py-12">
|
||||||
</div>
|
<i data-lucide="inbox" class="w-16 h-16 mx-auto text-slate/30 mb-4"></i>
|
||||||
|
<p class="text-slate mb-4">{% trans "No inquiries found for your source." %}</p>
|
||||||
|
{% if source_user.can_create_inquiries %}
|
||||||
|
<a href="{% url 'complaints:inquiry_create' %}"
|
||||||
|
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>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</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>
|
||||||
{% endblock %}
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
lucide.createIcons();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@ -111,7 +111,7 @@
|
|||||||
{% if response.numeric_value %}
|
{% if response.numeric_value %}
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="text-3xl font-bold {% if response.numeric_value >= 4 %}text-green-600{% elif response.numeric_value >= 3 %}text-orange-500{% else %}text-red-500{% endif %}">
|
<div class="text-3xl font-bold {% if response.numeric_value >= 4 %}text-green-600{% elif response.numeric_value >= 3 %}text-orange-500{% else %}text-red-500{% endif %}">
|
||||||
{{ response.numeric_value }}
|
{{ response.numeric_value }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-slate text-sm">{% trans "out of" %} 5</div>
|
<div class="text-slate text-sm">{% trans "out of" %} 5</div>
|
||||||
</div>
|
</div>
|
||||||
@ -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' %}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user