update in forms
This commit is contained in:
parent
d5a2cbda21
commit
fe0f2c5cea
@ -9,7 +9,7 @@ from .models import Complaint, ComplaintAttachment, ComplaintUpdate, Inquiry
|
||||
class ComplaintAttachmentSerializer(serializers.ModelSerializer):
|
||||
"""Complaint attachment serializer"""
|
||||
uploaded_by_name = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
class Meta:
|
||||
model = ComplaintAttachment
|
||||
fields = [
|
||||
@ -18,7 +18,7 @@ class ComplaintAttachmentSerializer(serializers.ModelSerializer):
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['id', 'file_size', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_uploaded_by_name(self, obj):
|
||||
"""Get uploader name"""
|
||||
if obj.uploaded_by:
|
||||
@ -29,7 +29,7 @@ class ComplaintAttachmentSerializer(serializers.ModelSerializer):
|
||||
class ComplaintUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Complaint update serializer"""
|
||||
created_by_name = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
class Meta:
|
||||
model = ComplaintUpdate
|
||||
fields = [
|
||||
@ -39,7 +39,7 @@ class ComplaintUpdateSerializer(serializers.ModelSerializer):
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_created_by_name(self, obj):
|
||||
"""Get creator name"""
|
||||
if obj.created_by:
|
||||
@ -58,7 +58,7 @@ class ComplaintSerializer(serializers.ModelSerializer):
|
||||
attachments = ComplaintAttachmentSerializer(many=True, read_only=True)
|
||||
updates = ComplaintUpdateSerializer(many=True, read_only=True)
|
||||
sla_status = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Complaint
|
||||
fields = [
|
||||
@ -82,28 +82,85 @@ class ComplaintSerializer(serializers.ModelSerializer):
|
||||
'resolved_at', 'closed_at', 'resolution_survey_sent_at',
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
Create complaint with auto-setting of department from AI analysis.
|
||||
|
||||
If metadata contains AI analysis with department name,
|
||||
automatically set the department field by querying the database.
|
||||
"""
|
||||
from apps.organizations.models import Department
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Extract metadata if present
|
||||
metadata = validated_data.get('metadata', {})
|
||||
|
||||
# Check if department is not already set and AI analysis contains department info
|
||||
if not validated_data.get('department') and metadata.get('ai_analysis'):
|
||||
ai_analysis = metadata['ai_analysis']
|
||||
department_name = ai_analysis.get('department')
|
||||
|
||||
# If AI identified a department, try to match it in the database
|
||||
if department_name:
|
||||
hospital = validated_data.get('hospital')
|
||||
|
||||
if hospital:
|
||||
try:
|
||||
# Try exact match first
|
||||
department = Department.objects.filter(
|
||||
hospital=hospital,
|
||||
status='active',
|
||||
name__iexact=department_name
|
||||
).first()
|
||||
|
||||
# If no exact match, try partial match
|
||||
if not department:
|
||||
department = Department.objects.filter(
|
||||
hospital=hospital,
|
||||
status='active',
|
||||
name__icontains=department_name
|
||||
).first()
|
||||
|
||||
if department:
|
||||
validated_data['department'] = department
|
||||
logger.info(
|
||||
f"Auto-set department '{department.name}' from AI analysis "
|
||||
f"for complaint (matched from '{department_name}')"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"AI suggested department '{department_name}' but no match found "
|
||||
f"in hospital '{hospital.name}'"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error auto-setting department: {e}")
|
||||
|
||||
# Create the complaint
|
||||
return super().create(validated_data)
|
||||
|
||||
def get_physician_name(self, obj):
|
||||
"""Get physician name"""
|
||||
if obj.physician:
|
||||
return obj.physician.get_full_name()
|
||||
return None
|
||||
|
||||
|
||||
def get_assigned_to_name(self, obj):
|
||||
"""Get assigned user name"""
|
||||
if obj.assigned_to:
|
||||
return obj.assigned_to.get_full_name()
|
||||
return None
|
||||
|
||||
|
||||
def get_sla_status(self, obj):
|
||||
"""Get SLA status"""
|
||||
if obj.is_overdue:
|
||||
return 'overdue'
|
||||
|
||||
|
||||
from django.utils import timezone
|
||||
time_remaining = obj.due_at - timezone.now()
|
||||
hours_remaining = time_remaining.total_seconds() / 3600
|
||||
|
||||
|
||||
if hours_remaining < 4:
|
||||
return 'due_soon'
|
||||
return 'on_time'
|
||||
@ -114,7 +171,7 @@ class ComplaintListSerializer(serializers.ModelSerializer):
|
||||
patient_name = serializers.CharField(source='patient.get_full_name', read_only=True)
|
||||
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
|
||||
sla_status = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Complaint
|
||||
fields = [
|
||||
@ -122,16 +179,16 @@ class ComplaintListSerializer(serializers.ModelSerializer):
|
||||
'category', 'severity', 'status', 'sla_status',
|
||||
'assigned_to', 'created_at'
|
||||
]
|
||||
|
||||
|
||||
def get_sla_status(self, obj):
|
||||
"""Get SLA status"""
|
||||
if obj.is_overdue:
|
||||
return 'overdue'
|
||||
|
||||
|
||||
from django.utils import timezone
|
||||
time_remaining = obj.due_at - timezone.now()
|
||||
hours_remaining = time_remaining.total_seconds() / 3600
|
||||
|
||||
|
||||
if hours_remaining < 4:
|
||||
return 'due_soon'
|
||||
return 'on_time'
|
||||
@ -143,7 +200,7 @@ class InquirySerializer(serializers.ModelSerializer):
|
||||
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
|
||||
department_name = serializers.CharField(source='department.name', read_only=True)
|
||||
assigned_to_name = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Inquiry
|
||||
fields = [
|
||||
@ -156,7 +213,7 @@ class InquirySerializer(serializers.ModelSerializer):
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['id', 'responded_at', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_assigned_to_name(self, obj):
|
||||
"""Get assigned user name"""
|
||||
if obj.assigned_to:
|
||||
|
||||
@ -9,6 +9,7 @@ This module contains tasks for:
|
||||
- AI-powered complaint analysis
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from celery import shared_task
|
||||
from django.db import transaction
|
||||
@ -18,6 +19,119 @@ from django.utils import timezone
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Optional[str] = None) -> Tuple[Optional[str], float, str]:
|
||||
"""
|
||||
Match staff member from extracted name using multiple matching strategies.
|
||||
|
||||
Args:
|
||||
staff_name: Name extracted from complaint (without titles)
|
||||
hospital_id: Hospital ID to search within
|
||||
department_name: Optional department name to prioritize matching
|
||||
|
||||
Returns:
|
||||
Tuple of (staff_id, confidence_score, matching_method)
|
||||
- staff_id: UUID of matched staff or None
|
||||
- confidence_score: Float from 0.0 to 1.0
|
||||
- matching_method: Description of how staff was matched
|
||||
"""
|
||||
from apps.organizations.models import Staff, Department
|
||||
|
||||
if not staff_name or not staff_name.strip():
|
||||
return None, 0.0, "No staff name provided"
|
||||
|
||||
staff_name = staff_name.strip()
|
||||
|
||||
# Build base query - staff from this hospital, active status
|
||||
base_query = Staff.objects.filter(
|
||||
hospital_id=hospital_id,
|
||||
status='active'
|
||||
)
|
||||
|
||||
# If department is specified, prioritize staff in that department
|
||||
dept_id = None
|
||||
if department_name:
|
||||
department = Department.objects.filter(
|
||||
hospital_id=hospital_id,
|
||||
name__iexact=department_name,
|
||||
status='active'
|
||||
).first()
|
||||
if department:
|
||||
dept_id = department.id
|
||||
|
||||
# Layer 1: Exact English match (full name)
|
||||
# Try "first_name last_name" format
|
||||
words = staff_name.split()
|
||||
if len(words) >= 2:
|
||||
first_name = words[0]
|
||||
last_name = ' '.join(words[1:])
|
||||
|
||||
# Exact match in correct department
|
||||
exact_query = base_query.filter(
|
||||
Q(first_name__iexact=first_name) & Q(last_name__iexact=last_name)
|
||||
)
|
||||
if dept_id:
|
||||
exact_query = exact_query.filter(department_id=dept_id)
|
||||
|
||||
staff = exact_query.first()
|
||||
if staff:
|
||||
confidence = 0.95 if dept_id else 0.90
|
||||
method = f"Exact English match in {'correct' if dept_id else 'any'} department"
|
||||
logger.info(f"Matched staff using exact English match: {staff.first_name} {staff.last_name} (confidence: {confidence})")
|
||||
return str(staff.id), confidence, method
|
||||
|
||||
# Layer 2: Exact Arabic match
|
||||
arabic_query = base_query.filter(
|
||||
Q(first_name_ar__iexact=staff_name) | Q(last_name_ar__iexact=staff_name)
|
||||
)
|
||||
if dept_id:
|
||||
arabic_query = arabic_query.filter(department_id=dept_id)
|
||||
|
||||
# Try full Arabic name match
|
||||
for staff in arabic_query:
|
||||
full_arabic_name = f"{staff.first_name_ar} {staff.last_name_ar}".strip()
|
||||
if full_arabic_name == staff_name:
|
||||
confidence = 0.95 if dept_id else 0.90
|
||||
method = f"Exact Arabic match in {'correct' if dept_id else 'any'} department"
|
||||
logger.info(f"Matched staff using exact Arabic match: {staff.first_name_ar} {staff.last_name_ar} (confidence: {confidence})")
|
||||
return str(staff.id), confidence, method
|
||||
|
||||
# Layer 3: Partial match (first name or last name)
|
||||
partial_query = base_query.filter(
|
||||
Q(first_name__icontains=staff_name) |
|
||||
Q(last_name__icontains=staff_name) |
|
||||
Q(first_name_ar__icontains=staff_name) |
|
||||
Q(last_name_ar__icontains=staff_name)
|
||||
)
|
||||
if dept_id:
|
||||
partial_query = partial_query.filter(department_id=dept_id)
|
||||
|
||||
staff = partial_query.first()
|
||||
if staff:
|
||||
confidence = 0.70 if dept_id else 0.60
|
||||
method = f"Partial match in {'correct' if dept_id else 'any'} department"
|
||||
logger.info(f"Matched staff using partial match: {staff.first_name} {staff.last_name} (confidence: {confidence})")
|
||||
return str(staff.id), confidence, method
|
||||
|
||||
# Layer 4: Fuzzy match using individual words
|
||||
# Handle cases like "Dr. Ahmed" or "Nurse Sarah"
|
||||
word_query = base_query.filter(
|
||||
Q(first_name__in=words) | Q(first_name_ar__in=words)
|
||||
)
|
||||
if dept_id:
|
||||
word_query = word_query.filter(department_id=dept_id)
|
||||
|
||||
staff = word_query.first()
|
||||
if staff:
|
||||
confidence = 0.50 if dept_id else 0.45
|
||||
method = f"Word match in {'correct' if dept_id else 'any'} department"
|
||||
logger.info(f"Matched staff using word match: {staff.first_name} {staff.last_name} (confidence: {confidence})")
|
||||
return str(staff.id), confidence, method
|
||||
|
||||
# No match found
|
||||
logger.warning(f"No staff match found for name: {staff_name}")
|
||||
return None, 0.0, "No match found"
|
||||
|
||||
|
||||
@shared_task
|
||||
def check_overdue_complaints():
|
||||
"""
|
||||
@ -541,7 +655,8 @@ def analyze_complaint_with_ai(complaint_id):
|
||||
analysis = AIService.analyze_complaint(
|
||||
title=complaint.title,
|
||||
description=complaint.description,
|
||||
category=category_name
|
||||
category=category_name,
|
||||
hospital_id=complaint.hospital.id
|
||||
)
|
||||
|
||||
# Analyze emotion using AI service
|
||||
@ -566,11 +681,24 @@ def analyze_complaint_with_ai(complaint_id):
|
||||
department_name = analysis.get('department', '')
|
||||
if department_name:
|
||||
from apps.organizations.models import Department
|
||||
# Try exact match first (case-insensitive)
|
||||
if department := Department.objects.filter(
|
||||
hospital_id=complaint.hospital.id,
|
||||
name=department_name
|
||||
name__iexact=department_name,
|
||||
status='active'
|
||||
).first():
|
||||
complaint.department = department
|
||||
logger.info(f"Matched department exactly: {department.name}")
|
||||
# If no exact match, try partial match
|
||||
elif department := Department.objects.filter(
|
||||
hospital_id=complaint.hospital.id,
|
||||
name__icontains=department_name,
|
||||
status='active'
|
||||
).first():
|
||||
complaint.department = department
|
||||
logger.info(f"Matched department partially: {department.name} from '{department_name}'")
|
||||
else:
|
||||
logger.warning(f"AI suggested department '{department_name}' but no match found in hospital '{complaint.hospital.name}'")
|
||||
|
||||
# Update title from AI analysis (use English version)
|
||||
if analysis.get('title_en'):
|
||||
@ -578,12 +706,62 @@ def analyze_complaint_with_ai(complaint_id):
|
||||
elif analysis.get('title'):
|
||||
complaint.title = analysis['title']
|
||||
|
||||
# Get staff_name from analyze_complaint result (already extracted by AI)
|
||||
staff_name = analysis.get('staff_name', '').strip()
|
||||
|
||||
matched_staff_id = None
|
||||
staff_confidence = 0.0
|
||||
staff_matching_method = None
|
||||
|
||||
# Capture old staff before matching
|
||||
old_staff = complaint.staff
|
||||
|
||||
if staff_name:
|
||||
logger.info(f"AI extracted staff name: {staff_name}")
|
||||
|
||||
# Try matching WITH department filter first (higher confidence if match found)
|
||||
matched_staff_id, staff_confidence, staff_matching_method = match_staff_from_name(
|
||||
staff_name=staff_name,
|
||||
hospital_id=str(complaint.hospital.id),
|
||||
department_name=department_name
|
||||
)
|
||||
|
||||
# If no match found with department, try WITHOUT department filter
|
||||
if not matched_staff_id:
|
||||
logger.info(f"No match found with department filter '{department_name}', trying without department filter...")
|
||||
matched_staff_id, staff_confidence, staff_matching_method = match_staff_from_name(
|
||||
staff_name=staff_name,
|
||||
hospital_id=str(complaint.hospital.id),
|
||||
department_name=None # Search all departments
|
||||
)
|
||||
|
||||
# Only assign staff if confidence is above threshold (0.6)
|
||||
if matched_staff_id and staff_confidence >= 0.6:
|
||||
from apps.organizations.models import Staff
|
||||
try:
|
||||
staff = Staff.objects.get(id=matched_staff_id)
|
||||
complaint.staff = staff
|
||||
logger.info(
|
||||
f"Assigned staff {staff.first_name} {staff.last_name} "
|
||||
f"to complaint {complaint_id} "
|
||||
f"(confidence: {staff_confidence:.2f}, method: {staff_matching_method})"
|
||||
)
|
||||
except Staff.DoesNotExist:
|
||||
logger.warning(f"Staff {matched_staff_id} not found in database")
|
||||
else:
|
||||
logger.info(
|
||||
f"Staff match confidence {staff_confidence:.2f} below threshold 0.6, "
|
||||
f"or no match found. Not assigning staff."
|
||||
)
|
||||
|
||||
# Save reasoning in metadata
|
||||
# Use JSON-serializable values instead of model objects
|
||||
old_category_name = old_category.name_en if old_category else None
|
||||
old_category_id = str(old_category.id) if old_category else None
|
||||
old_department_name = old_department.name if old_department else None
|
||||
old_department_id = str(old_department.id) if old_department else None
|
||||
old_staff_name = f"{old_staff.first_name} {old_staff.last_name}" if old_staff else None
|
||||
old_staff_id = str(old_staff.id) if old_staff else None
|
||||
|
||||
# Initialize metadata if needed
|
||||
if not complaint.metadata:
|
||||
@ -608,10 +786,16 @@ def analyze_complaint_with_ai(complaint_id):
|
||||
'old_category': old_category_name,
|
||||
'old_category_id': old_category_id,
|
||||
'old_department': old_department_name,
|
||||
'old_department_id': old_department_id
|
||||
'old_department_id': old_department_id,
|
||||
'old_staff': old_staff_name,
|
||||
'old_staff_id': old_staff_id,
|
||||
'extracted_staff_name': staff_name,
|
||||
'matched_staff_id': matched_staff_id,
|
||||
'staff_confidence': staff_confidence,
|
||||
'staff_matching_method': staff_matching_method
|
||||
}
|
||||
|
||||
complaint.save(update_fields=['severity', 'priority', 'category', 'department', 'title', 'metadata'])
|
||||
complaint.save(update_fields=['severity', 'priority', 'category', 'department', 'staff', 'title', 'metadata'])
|
||||
|
||||
# Re-calculate SLA due date based on new severity
|
||||
complaint.due_at = complaint.calculate_sla_due_date()
|
||||
@ -624,8 +808,19 @@ def analyze_complaint_with_ai(complaint_id):
|
||||
emotion_display = emotion_analysis.get('emotion', 'neutral')
|
||||
emotion_intensity = emotion_analysis.get('intensity', 0.0)
|
||||
|
||||
message_en = f"AI analysis complete: Severity={analysis['severity']}, Priority={analysis['priority']}, Category={analysis.get('category', 'N/A')}, Department={department_name or 'N/A'}, Emotion={emotion_display} (Intensity: {emotion_intensity:.2f})"
|
||||
message_ar = f"اكتمل تحليل الذكاء الاصطناعي: الشدة={analysis['severity']}, الأولوية={analysis['priority']}, الفئة={analysis.get('category', 'N/A')}, القسم={department_name or 'N/A'}, العاطفة={emotion_display} (الشدة: {emotion_intensity:.2f})"
|
||||
# Build English message
|
||||
message_en = f"AI analysis complete: Severity={analysis['severity']}, Priority={analysis['priority']}, Category={analysis.get('category', 'N/A')}, Department={department_name or 'N/A'}"
|
||||
if matched_staff_id:
|
||||
message_en += f", Staff={f'{complaint.staff.first_name} {complaint.staff.last_name}' if complaint.staff else 'N/A'} (confidence: {staff_confidence:.2f})"
|
||||
message_en += f", Emotion={emotion_display} (Intensity: {emotion_intensity:.2f})"
|
||||
|
||||
# Build Arabic message
|
||||
message_ar = f"اكتمل تحليل الذكاء الاصطناعي: الشدة={analysis['severity']}, الأولوية={analysis['priority']}, الفئة={analysis.get('category', 'N/A')}, القسم={department_name or 'N/A'}"
|
||||
if matched_staff_id and complaint.staff:
|
||||
staff_name_ar = complaint.staff.first_name_ar if complaint.staff.first_name_ar else complaint.staff.first_name
|
||||
message_ar += f", الموظف={staff_name_ar} {complaint.staff.last_name_ar if complaint.staff.last_name_ar else complaint.staff.last_name} (الثقة: {staff_confidence:.2f})"
|
||||
message_ar += f", العاطفة={emotion_display} (الشدة: {emotion_intensity:.2f})"
|
||||
|
||||
message = f"{message_en}\n\n{message_ar}"
|
||||
|
||||
ComplaintUpdate.objects.create(
|
||||
|
||||
@ -19,6 +19,82 @@ from .serializers import (
|
||||
)
|
||||
|
||||
|
||||
def map_complaint_category_to_action_category(complaint_category_code):
|
||||
"""
|
||||
Map complaint category code to PX Action category.
|
||||
|
||||
Provides intelligent mapping from complaint categories to PX Action categories.
|
||||
Returns 'other' as fallback if no match found.
|
||||
"""
|
||||
if not complaint_category_code:
|
||||
return 'other'
|
||||
|
||||
mapping = {
|
||||
# Clinical issues
|
||||
'clinical': 'clinical_quality',
|
||||
'medical': 'clinical_quality',
|
||||
'diagnosis': 'clinical_quality',
|
||||
'treatment': 'clinical_quality',
|
||||
'medication': 'clinical_quality',
|
||||
'care': 'clinical_quality',
|
||||
|
||||
# Safety issues
|
||||
'safety': 'patient_safety',
|
||||
'risk': 'patient_safety',
|
||||
'incident': 'patient_safety',
|
||||
'infection': 'patient_safety',
|
||||
'harm': 'patient_safety',
|
||||
|
||||
# Service quality
|
||||
'service': 'service_quality',
|
||||
'communication': 'service_quality',
|
||||
'wait': 'service_quality',
|
||||
'response': 'service_quality',
|
||||
'customer_service': 'service_quality',
|
||||
'timeliness': 'service_quality',
|
||||
'waiting_time': 'service_quality',
|
||||
|
||||
# Staff behavior
|
||||
'staff': 'staff_behavior',
|
||||
'behavior': 'staff_behavior',
|
||||
'attitude': 'staff_behavior',
|
||||
'professionalism': 'staff_behavior',
|
||||
'rude': 'staff_behavior',
|
||||
'respect': 'staff_behavior',
|
||||
|
||||
# Facility
|
||||
'facility': 'facility',
|
||||
'environment': 'facility',
|
||||
'cleanliness': 'facility',
|
||||
'equipment': 'facility',
|
||||
'infrastructure': 'facility',
|
||||
'parking': 'facility',
|
||||
'accessibility': 'facility',
|
||||
|
||||
# Process
|
||||
'process': 'process_improvement',
|
||||
'administrative': 'process_improvement',
|
||||
'billing': 'process_improvement',
|
||||
'procedure': 'process_improvement',
|
||||
'workflow': 'process_improvement',
|
||||
'registration': 'process_improvement',
|
||||
'appointment': 'process_improvement',
|
||||
}
|
||||
|
||||
# Try exact match first
|
||||
category_lower = complaint_category_code.lower()
|
||||
if category_lower in mapping:
|
||||
return mapping[category_lower]
|
||||
|
||||
# Try partial match (contains the keyword)
|
||||
for keyword, action_category in mapping.items():
|
||||
if keyword in category_lower:
|
||||
return action_category
|
||||
|
||||
# Fallback to 'other'
|
||||
return 'other'
|
||||
|
||||
|
||||
class ComplaintViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for Complaints with workflow actions.
|
||||
@ -205,6 +281,122 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
||||
serializer = ComplaintUpdateSerializer(update)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def create_action_from_ai(self, request, pk=None):
|
||||
"""Create PX Action from AI-suggested action"""
|
||||
complaint = self.get_object()
|
||||
|
||||
# Check if complaint has suggested action
|
||||
suggested_action = request.data.get('suggested_action')
|
||||
if not suggested_action and complaint.metadata and 'ai_analysis' in complaint.metadata:
|
||||
suggested_action = complaint.metadata['ai_analysis'].get('suggested_action_en')
|
||||
|
||||
if not suggested_action:
|
||||
return Response(
|
||||
{'error': 'No suggested action available for this complaint'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Get category (optional - will be auto-mapped from complaint category if not provided)
|
||||
category = request.data.get('category')
|
||||
|
||||
# If category not provided, auto-map from complaint category
|
||||
if not category:
|
||||
if complaint.category:
|
||||
category = map_complaint_category_to_action_category(complaint.category.code)
|
||||
else:
|
||||
category = 'other'
|
||||
|
||||
# Validate category choice if manually provided
|
||||
valid_categories = [
|
||||
'clinical_quality', 'patient_safety', 'service_quality',
|
||||
'staff_behavior', 'facility', 'process_improvement', 'other'
|
||||
]
|
||||
if category not in valid_categories:
|
||||
return Response(
|
||||
{'error': f'Invalid category. Valid options: {", ".join(valid_categories)}'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Get optional assigned_to
|
||||
assigned_to_id = request.data.get('assigned_to')
|
||||
assigned_to = None
|
||||
if assigned_to_id:
|
||||
from apps.accounts.models import User
|
||||
try:
|
||||
assigned_to = User.objects.get(id=assigned_to_id)
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Assigned user not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Create PX Action
|
||||
from apps.px_action_center.models import PXAction, PXActionLog
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
complaint_content_type = ContentType.objects.get_for_model(Complaint)
|
||||
|
||||
action = PXAction.objects.create(
|
||||
source_type='complaint',
|
||||
content_type=complaint_content_type,
|
||||
object_id=complaint.id,
|
||||
title=f"Action from Complaint: {complaint.title}",
|
||||
description=suggested_action,
|
||||
hospital=complaint.hospital,
|
||||
department=complaint.department,
|
||||
category=category,
|
||||
priority=complaint.priority,
|
||||
severity=complaint.severity,
|
||||
assigned_to=assigned_to,
|
||||
status='open',
|
||||
metadata={
|
||||
'source_complaint_id': str(complaint.id),
|
||||
'source_complaint_title': complaint.title,
|
||||
'ai_generated': True,
|
||||
'created_from_ai_suggestion': True
|
||||
}
|
||||
)
|
||||
|
||||
# Create action log entry
|
||||
PXActionLog.objects.create(
|
||||
action=action,
|
||||
log_type='note',
|
||||
message=f"Action created from AI-suggested action for complaint: {complaint.title}",
|
||||
created_by=request.user,
|
||||
metadata={
|
||||
'complaint_id': str(complaint.id),
|
||||
'ai_generated': True
|
||||
}
|
||||
)
|
||||
|
||||
# Create complaint update
|
||||
ComplaintUpdate.objects.create(
|
||||
complaint=complaint,
|
||||
update_type='note',
|
||||
message=f"PX Action created from AI-suggested action (Action #{action.id})",
|
||||
created_by=request.user,
|
||||
metadata={'action_id': str(action.id)}
|
||||
)
|
||||
|
||||
# Log audit
|
||||
AuditService.log_from_request(
|
||||
event_type='action_created_from_ai',
|
||||
description=f"PX Action created from AI-suggested action for complaint: {complaint.title}",
|
||||
request=request,
|
||||
content_object=action,
|
||||
metadata={
|
||||
'complaint_id': str(complaint.id),
|
||||
'category': category,
|
||||
'ai_generated': True
|
||||
}
|
||||
)
|
||||
|
||||
return Response({
|
||||
'action_id': str(action.id),
|
||||
'message': 'Action created successfully from AI-suggested action'
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class ComplaintAttachmentViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for Complaint Attachments"""
|
||||
|
||||
@ -280,7 +280,11 @@ class AIService:
|
||||
5. If a category has no subcategories, leave the subcategory field empty
|
||||
6. Select the most appropriate department from the hospital's departments (if available)
|
||||
7. If no departments are available or department is unclear, leave the department field empty
|
||||
8. Generate a suggested_action (2-3 sentences) with specific, actionable steps to address this complaint in BOTH English and Arabic
|
||||
8. Extract any staff members mentioned in the complaint (physicians, nurses, etc.)
|
||||
9. Return the staff name WITHOUT titles (Dr., Nurse, دكتور, ممرض, etc.)
|
||||
10. If multiple staff are mentioned, return the PRIMARY one
|
||||
11. If no staff is mentioned, leave the staff_name field empty
|
||||
12. Generate a suggested_action (2-3 sentences) with specific, actionable steps to address this complaint in BOTH English and Arabic
|
||||
|
||||
IMPORTANT: ALL TEXT FIELDS MUST BE PROVIDED IN BOTH ENGLISH AND ARABIC
|
||||
- title: Provide in both English and Arabic
|
||||
@ -299,6 +303,7 @@ class AIService:
|
||||
"category": "exact category name from the list above",
|
||||
"subcategory": "exact subcategory name from the chosen category, or empty string if not applicable",
|
||||
"department": "exact department name from the hospital's departments, or empty string if not applicable",
|
||||
"staff_name": "name of staff member mentioned (without titles like Dr., Nurse, etc.), or empty string if no staff mentioned",
|
||||
"suggested_action_en": "2-3 specific, actionable steps in English to address this complaint",
|
||||
"suggested_action_ar": "خطوات محددة وعمليه بالعربية",
|
||||
"reasoning_en": "Brief explanation in English of your classification (2-3 sentences)",
|
||||
@ -361,6 +366,7 @@ class AIService:
|
||||
'category': 'other',
|
||||
'subcategory': '',
|
||||
'department': '',
|
||||
'staff_name': '',
|
||||
'reasoning': 'AI analysis failed, using default values'
|
||||
}
|
||||
except AIServiceError as e:
|
||||
@ -372,6 +378,7 @@ class AIService:
|
||||
'category': 'other',
|
||||
'subcategory': '',
|
||||
'department': '',
|
||||
'staff_name': '',
|
||||
'reasoning': f'AI service unavailable: {str(e)}'
|
||||
}
|
||||
|
||||
|
||||
@ -3,15 +3,34 @@ Core app URLs
|
||||
"""
|
||||
from django.urls import path
|
||||
|
||||
from .views import health_check, select_hospital, no_hospital_assigned
|
||||
from .views import (
|
||||
health_check,
|
||||
select_hospital,
|
||||
no_hospital_assigned,
|
||||
public_submit_landing,
|
||||
public_inquiry_submit,
|
||||
public_observation_submit,
|
||||
api_hospitals,
|
||||
api_observation_categories
|
||||
)
|
||||
from . import config_views
|
||||
|
||||
app_name = 'core'
|
||||
|
||||
urlpatterns = [
|
||||
# Health check
|
||||
path('', health_check, name='health'),
|
||||
|
||||
# Hospital selection
|
||||
path('select-hospital/', select_hospital, name='select_hospital'),
|
||||
path('no-hospital/', no_hospital_assigned, name='no_hospital_assigned'),
|
||||
|
||||
# Public submission pages
|
||||
path('public/submit/', public_submit_landing, name='public_submit_landing'),
|
||||
path('public/inquiry/submit/', public_inquiry_submit, name='public_inquiry_submit'),
|
||||
path('public/observation/submit/', public_observation_submit, name='public_observation_submit'),
|
||||
path('api/hospitals/', api_hospitals, name='api_hospitals'),
|
||||
path('api/observation-categories/', api_observation_categories, name='api_observation_categories'),
|
||||
]
|
||||
|
||||
# Configuration URLs (separate app_name)
|
||||
|
||||
@ -90,3 +90,239 @@ def no_hospital_assigned(request):
|
||||
Users without a hospital assignment cannot access the system.
|
||||
"""
|
||||
return render(request, 'core/no_hospital_assigned.html', status=403)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PUBLIC SUBMISSION VIEWS
|
||||
# ============================================================================
|
||||
|
||||
@require_GET
|
||||
def public_submit_landing(request):
|
||||
"""
|
||||
Landing page for public submissions.
|
||||
|
||||
Allows users to choose between Complaint, Observation, or Inquiry.
|
||||
No authentication required.
|
||||
"""
|
||||
from apps.organizations.models import Hospital
|
||||
|
||||
hospitals = Hospital.objects.all().order_by('name')
|
||||
|
||||
context = {
|
||||
'hospitals': hospitals,
|
||||
}
|
||||
|
||||
return render(request, 'core/public_submit.html', context)
|
||||
|
||||
|
||||
@require_POST
|
||||
def public_inquiry_submit(request):
|
||||
"""
|
||||
Handle public inquiry submissions.
|
||||
|
||||
Creates an inquiry from public submission.
|
||||
Returns JSON response with reference number.
|
||||
"""
|
||||
from apps.complaints.models import Inquiry
|
||||
from apps.organizations.models import Hospital
|
||||
import uuid
|
||||
|
||||
# Get form data
|
||||
name = request.POST.get('name', '').strip()
|
||||
email = request.POST.get('email', '').strip()
|
||||
phone = request.POST.get('phone', '').strip()
|
||||
hospital_id = request.POST.get('hospital')
|
||||
category = request.POST.get('category', '').strip()
|
||||
subject = request.POST.get('subject', '').strip()
|
||||
message = request.POST.get('message', '').strip()
|
||||
|
||||
# Validation
|
||||
errors = []
|
||||
|
||||
if not name:
|
||||
errors.append('Name is required')
|
||||
if not email:
|
||||
errors.append('Email is required')
|
||||
if not phone:
|
||||
errors.append('Phone number is required')
|
||||
if not hospital_id:
|
||||
errors.append('Hospital selection is required')
|
||||
if not subject:
|
||||
errors.append('Subject is required')
|
||||
if not message:
|
||||
errors.append('Message is required')
|
||||
|
||||
if errors:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'errors': errors
|
||||
}, status=400)
|
||||
|
||||
try:
|
||||
# Validate hospital
|
||||
hospital = Hospital.objects.get(id=hospital_id)
|
||||
|
||||
# Create inquiry (using correct field names from model)
|
||||
inquiry = Inquiry.objects.create(
|
||||
hospital=hospital,
|
||||
contact_name=name,
|
||||
contact_email=email,
|
||||
contact_phone=phone,
|
||||
subject=subject,
|
||||
message=message,
|
||||
category=category,
|
||||
status='open'
|
||||
)
|
||||
|
||||
# Generate a simple reference for display
|
||||
reference_number = f"INQ-{str(inquiry.id)[:8].upper()}"
|
||||
|
||||
# Send notification email (optional)
|
||||
try:
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
send_mail(
|
||||
f'New Public Inquiry - {reference_number}',
|
||||
f'Inquiry from {name}\n\nSubject: {subject}\n\nMessage:\n{message}',
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[settings.DEFAULT_FROM_EMAIL], # Send to admin
|
||||
fail_silently=True,
|
||||
)
|
||||
except Exception:
|
||||
pass # Don't fail if email doesn't send
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'reference_number': reference_number,
|
||||
'inquiry_id': str(inquiry.id)
|
||||
})
|
||||
|
||||
except Hospital.DoesNotExist:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'errors': ['Invalid hospital selected']
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'errors': [str(e)]
|
||||
}, status=500)
|
||||
|
||||
|
||||
@require_GET
|
||||
def api_hospitals(request):
|
||||
"""
|
||||
API endpoint to get hospitals list.
|
||||
|
||||
Used by public submission forms to populate hospital dropdown.
|
||||
"""
|
||||
from apps.organizations.models import Hospital
|
||||
|
||||
hospitals = Hospital.objects.all().order_by('name').values('id', 'name')
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'hospitals': list(hospitals)
|
||||
})
|
||||
|
||||
|
||||
@require_GET
|
||||
def api_observation_categories(request):
|
||||
"""
|
||||
API endpoint to get observation categories list.
|
||||
|
||||
Used by public observation form to populate category dropdown.
|
||||
"""
|
||||
from apps.observations.models import ObservationCategory
|
||||
|
||||
categories = ObservationCategory.objects.filter(is_active=True).order_by('sort_order', 'name_en').values('id', 'name_en', 'name_ar')
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'categories': list(categories)
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
def public_observation_submit(request):
|
||||
"""
|
||||
Handle public observation submissions.
|
||||
|
||||
Creates an observation from public submission.
|
||||
Returns JSON response with tracking code.
|
||||
"""
|
||||
from apps.observations.models import Observation, ObservationAttachment
|
||||
from apps.observations.services import ObservationService
|
||||
import mimetypes
|
||||
|
||||
# Get form data
|
||||
category_id = request.POST.get('category')
|
||||
severity = request.POST.get('severity', 'medium')
|
||||
title = request.POST.get('title', '').strip()
|
||||
description = request.POST.get('description', '').strip()
|
||||
location_text = request.POST.get('location_text', '').strip()
|
||||
incident_datetime = request.POST.get('incident_datetime', '')
|
||||
reporter_staff_id = request.POST.get('reporter_staff_id', '').strip()
|
||||
reporter_name = request.POST.get('reporter_name', '').strip()
|
||||
reporter_phone = request.POST.get('reporter_phone', '').strip()
|
||||
reporter_email = request.POST.get('reporter_email', '').strip()
|
||||
|
||||
# Validation
|
||||
errors = []
|
||||
|
||||
if not description:
|
||||
errors.append('Description is required')
|
||||
|
||||
if severity not in ['low', 'medium', 'high', 'critical']:
|
||||
errors.append('Invalid severity selected')
|
||||
|
||||
if errors:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'errors': errors
|
||||
}, status=400)
|
||||
|
||||
try:
|
||||
# Get client info
|
||||
def get_client_ip(req):
|
||||
x_forwarded_for = req.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0].strip()
|
||||
else:
|
||||
ip = req.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
user_agent = request.META.get('HTTP_USER_AGENT', '')
|
||||
|
||||
# Handle file uploads
|
||||
attachments = request.FILES.getlist('attachments')
|
||||
|
||||
# Create observation using service
|
||||
observation = ObservationService.create_observation(
|
||||
description=description,
|
||||
severity=severity,
|
||||
category_id=category_id if category_id else None,
|
||||
title=title,
|
||||
location_text=location_text,
|
||||
incident_datetime=incident_datetime if incident_datetime else None,
|
||||
reporter_staff_id=reporter_staff_id,
|
||||
reporter_name=reporter_name,
|
||||
reporter_phone=reporter_phone,
|
||||
reporter_email=reporter_email,
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'tracking_code': observation.tracking_code,
|
||||
'observation_id': str(observation.id)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'errors': [str(e)]
|
||||
}, status=500)
|
||||
|
||||
19
apps/organizations/migrations/0003_patient_department.py
Normal file
19
apps/organizations/migrations/0003_patient_department.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.0.14 on 2026-01-06 11:55
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('organizations', '0002_hospital_metadata'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='patient',
|
||||
name='department',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='organizations.department'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.0.14 on 2026-01-06 11:56
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('organizations', '0003_patient_department'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='patient',
|
||||
name='department',
|
||||
),
|
||||
]
|
||||
@ -11,16 +11,19 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, Spec
|
||||
urlpatterns = [
|
||||
# Language switching
|
||||
path('i18n/', include('django.conf.urls.i18n')),
|
||||
|
||||
|
||||
# Admin
|
||||
path('admin/', admin.site.urls),
|
||||
|
||||
|
||||
# Dashboard
|
||||
path('', include('apps.dashboard.urls')),
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
path('health/', include('apps.core.urls')),
|
||||
|
||||
|
||||
# Core pages (public submissions, hospital selection)
|
||||
path('core/', include('apps.core.urls')),
|
||||
|
||||
# UI Pages
|
||||
path('complaints/', include('apps.complaints.urls')),
|
||||
path('feedback/', include('apps.feedback.urls')),
|
||||
@ -37,14 +40,14 @@ urlpatterns = [
|
||||
path('ai-engine/', include('apps.ai_engine.urls')),
|
||||
path('appreciation/', include('apps.appreciation.urls', namespace='appreciation')),
|
||||
path('observations/', include('apps.observations.urls', namespace='observations')),
|
||||
|
||||
|
||||
# API endpoints
|
||||
path('api/auth/', include('apps.accounts.urls')),
|
||||
path('api/physicians/', include('apps.physicians.urls')),
|
||||
path('api/integrations/', include('apps.integrations.urls')),
|
||||
path('api/notifications/', include('apps.notifications.urls')),
|
||||
path('api/v1/appreciation/', include('apps.appreciation.urls', namespace='api_appreciation')),
|
||||
|
||||
|
||||
# OpenAPI/Swagger documentation
|
||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||
|
||||
@ -1588,34 +1588,36 @@ def main():
|
||||
print("="*60 + "\n")
|
||||
|
||||
# Clear existing data first
|
||||
clear_existing_data()
|
||||
# clear_existing_data()
|
||||
|
||||
# Create base data
|
||||
hospitals = create_hospitals()
|
||||
# hospitals = create_hospitals()
|
||||
hospitals = Hospital.objects.all()
|
||||
|
||||
departments = create_departments(hospitals)
|
||||
staff = create_staff(hospitals, departments)
|
||||
patients = create_patients(hospitals)
|
||||
create_users(hospitals)
|
||||
# create_users(hospitals)
|
||||
|
||||
# Get all users for assignments
|
||||
users = list(User.objects.all())
|
||||
# users = list(User.objects.all())
|
||||
|
||||
# Create complaint categories first
|
||||
categories = create_complaint_categories(hospitals)
|
||||
# categories = create_complaint_categories(hospitals)
|
||||
|
||||
# Create operational data
|
||||
complaints = create_complaints(patients, hospitals, physicians, users)
|
||||
inquiries = create_inquiries(patients, hospitals, users)
|
||||
feedbacks = create_feedback(patients, hospitals, physicians, users)
|
||||
create_survey_templates(hospitals)
|
||||
create_journey_templates(hospitals)
|
||||
projects = create_qi_projects(hospitals)
|
||||
actions = create_px_actions(complaints, hospitals, users)
|
||||
journey_instances = create_journey_instances(None, patients)
|
||||
survey_instances = create_survey_instances(None, patients, physicians)
|
||||
call_interactions = create_call_center_interactions(patients, hospitals, users)
|
||||
social_mentions = create_social_mentions(hospitals)
|
||||
physician_ratings = create_physician_monthly_ratings(physicians)
|
||||
# complaints = create_complaints(patients, hospitals, physicians, users)
|
||||
# inquiries = create_inquiries(patients, hospitals, users)
|
||||
# feedbacks = create_feedback(patients, hospitals, physicians, users)
|
||||
# create_survey_templates(hospitals)
|
||||
# create_journey_templates(hospitals)
|
||||
# projects = create_qi_projects(hospitals)
|
||||
# actions = create_px_actions(complaints, hospitals, users)
|
||||
# journey_instances = create_journey_instances(None, patients)
|
||||
# survey_instances = create_survey_instances(None, patients, physicians)
|
||||
# call_interactions = create_call_center_interactions(patients, hospitals, users)
|
||||
# social_mentions = create_social_mentions(hospitals)
|
||||
# physician_ratings = create_physician_monthly_ratings(physicians)
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("Data Generation Complete!")
|
||||
@ -1625,21 +1627,21 @@ def main():
|
||||
print(f" - {len(departments)} Departments")
|
||||
print(f" - {len(staff)} Staff")
|
||||
print(f" - {len(patients)} Patients")
|
||||
print(f" - {len(users)} Users")
|
||||
print(f" - {len(complaints)} Complaints (2 years)")
|
||||
print(f" - {len(inquiries)} Inquiries (2 years)")
|
||||
print(f" - {len(feedbacks)} Feedback Items")
|
||||
print(f" - {len(actions)} PX Actions")
|
||||
print(f" - {len(journey_instances)} Journey Instances")
|
||||
print(f" - {len(survey_instances)} Survey Instances")
|
||||
print(f" - {len(call_interactions)} Call Center Interactions")
|
||||
print(f" - {len(social_mentions)} Social Media Mentions")
|
||||
print(f" - {len(projects)} QI Projects")
|
||||
print(f" - {len(staff_ratings)} Staff Monthly Ratings")
|
||||
print(f" - {len(appreciations)} Appreciations (2 years)")
|
||||
print(f" - {len(user_badges)} Badges Awarded")
|
||||
print(f" - {len(appreciation_stats)} Appreciation Statistics")
|
||||
print(f" - {len(observations)} Observations (2 years)")
|
||||
# print(f" - {len(users)} Users")
|
||||
# print(f" - {len(complaints)} Complaints (2 years)")
|
||||
# print(f" - {len(inquiries)} Inquiries (2 years)")
|
||||
# print(f" - {len(feedbacks)} Feedback Items")
|
||||
# print(f" - {len(actions)} PX Actions")
|
||||
# print(f" - {len(journey_instances)} Journey Instances")
|
||||
# print(f" - {len(survey_instances)} Survey Instances")
|
||||
# print(f" - {len(call_interactions)} Call Center Interactions")
|
||||
# print(f" - {len(social_mentions)} Social Media Mentions")
|
||||
# print(f" - {len(projects)} QI Projects")
|
||||
# print(f" - {len(staff_ratings)} Staff Monthly Ratings")
|
||||
# print(f" - {len(appreciations)} Appreciations (2 years)")
|
||||
# print(f" - {len(user_badges)} Badges Awarded")
|
||||
# print(f" - {len(appreciation_stats)} Appreciation Statistics")
|
||||
# print(f" - {len(observations)} Observations (2 years)")
|
||||
print(f"\nYou can now login with:")
|
||||
print(f" Username: px_admin")
|
||||
print(f" Password: admin123")
|
||||
|
||||
@ -27,6 +27,7 @@ dependencies = [
|
||||
"reportlab>=4.4.7",
|
||||
"openpyxl>=3.1.5",
|
||||
"litellm>=1.0.0",
|
||||
"watchdog>=6.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@ -257,6 +257,64 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if complaint.department %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
<div class="info-label">{{ _("Department") }}</div>
|
||||
<div class="info-value">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-building me-2"></i>
|
||||
<div>
|
||||
<strong>{{ complaint.department.name_en }}</strong>
|
||||
{% if complaint.department.name_ar %}
|
||||
<span class="text-muted ms-2">({{ complaint.department.name_ar }})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if complaint.metadata.ai_analysis.old_department %}
|
||||
<span class="badge bg-info ms-auto">
|
||||
<i class="bi bi-robot me-1"></i>AI Mapped
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if complaint.staff %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
<div class="info-label">{{ _("Staff Member") }}</div>
|
||||
<div class="info-value">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-person-badge me-2"></i>
|
||||
<div>
|
||||
<strong>{{ complaint.staff.first_name }} {{ complaint.staff.last_name }}</strong>
|
||||
{% if complaint.staff.first_name_ar or complaint.staff.last_name_ar %}
|
||||
<span class="text-muted ms-2">({{ complaint.staff.first_name_ar }} {{ complaint.staff.last_name_ar }})</span>
|
||||
{% endif %}
|
||||
{% if complaint.staff.job_title %}
|
||||
<div class="text-muted">{{ complaint.staff.job_title }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="badge bg-success ms-auto">
|
||||
<i class="bi bi-robot me-1"></i>AI Matched
|
||||
</span>
|
||||
</div>
|
||||
{% if complaint.metadata.ai_analysis.extracted_staff_name %}
|
||||
<small class="text-muted mt-1 d-block">
|
||||
<i class="bi bi-lightning me-1"></i>
|
||||
Extracted from complaint: "{{ complaint.metadata.ai_analysis.extracted_staff_name }}"
|
||||
{% if complaint.metadata.ai_analysis.staff_confidence %}
|
||||
(confidence: {{ complaint.metadata.ai_analysis.staff_confidence|floatformat:0 }}%)
|
||||
{% endif %}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="mb-3">
|
||||
@ -334,6 +392,9 @@
|
||||
<i class="bi bi-lightning me-1 text-success"></i>
|
||||
<small>{{ complaint.suggested_action }}</small>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary mt-2" data-bs-toggle="modal" data-bs-target="#createActionModal">
|
||||
<i class="bi bi-plus-circle me-1"></i> Create PX Action
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -669,7 +730,7 @@
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
{{ _("This will escalate the complaint to higher management")}}.
|
||||
{{ _("This will escalate")}} complaint to higher management}}.
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Reason for Escalation" %}</label>
|
||||
@ -687,4 +748,117 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create PX Action Modal -->
|
||||
<div class="modal fade" id="createActionModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-lightning-fill me-2"></i>Create PX Action from AI Suggestion
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
This will create a PX Action based on the AI-suggested action for this complaint.
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Auto-mapped Category</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">
|
||||
<i class="bi bi-tag"></i>
|
||||
</span>
|
||||
<input type="text" id="autoMappedCategory" class="form-control" readonly
|
||||
value="{% if complaint.category %}{{ complaint.category.name_en }}{% else %}Other{% endif %}">
|
||||
</div>
|
||||
<small class="text-muted">Category automatically mapped from complaint category</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Assign To (Optional)</label>
|
||||
<select id="actionAssignTo" class="form-select">
|
||||
<option value="">Leave unassigned</option>
|
||||
{% for user_obj in assignable_users %}
|
||||
<option value="{{ user_obj.id }}">{{ user_obj.get_full_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="text-muted">If left unassigned, you can assign the action later.</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-0">
|
||||
<label class="form-label">Action Description</label>
|
||||
<div class="alert alert-success mb-0">
|
||||
<small>{{ complaint.suggested_action }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" id="createActionBtn" class="btn btn-primary" onclick="createAction()">
|
||||
<i class="bi bi-plus-circle me-1"></i>Create Action
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function createAction() {
|
||||
const assignTo = document.getElementById('actionAssignTo').value;
|
||||
const btn = document.getElementById('createActionBtn');
|
||||
|
||||
const data = {};
|
||||
|
||||
if (assignTo) {
|
||||
data.assigned_to = assignTo;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
|
||||
|
||||
fetch(`/api/complaints/{{ complaint.id }}/create_action_from_ai/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.action_id) {
|
||||
// Redirect to action detail page
|
||||
window.location.href = `/actions/${data.action_id}/`;
|
||||
} else {
|
||||
alert('Error: ' + (data.error || 'Unknown error'));
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Create Action';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Failed to create action. Please try again.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Create Action';
|
||||
});
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -304,45 +304,44 @@
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Get CSRF token
|
||||
function getCSRFToken() {
|
||||
// Try to get from cookie first
|
||||
const cookieValue = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrftoken='))
|
||||
?.split('=')[1];
|
||||
// Store all categories data globally for easy access
|
||||
let allCategories = [];
|
||||
let currentLanguage = 'en';
|
||||
|
||||
if (cookieValue) {
|
||||
return cookieValue;
|
||||
}
|
||||
// Get CSRF token
|
||||
function getCSRFToken() {
|
||||
// Try to get from cookie first
|
||||
const cookieValue = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrftoken='))
|
||||
?.split('=')[1];
|
||||
|
||||
// Fallback to the hidden input
|
||||
return $('[name="csrfmiddlewaretoken"]').val();
|
||||
if (cookieValue) {
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
// Store all categories data globally for easy access
|
||||
let allCategories = [];
|
||||
let currentLanguage = 'en';
|
||||
// Fallback to hidden input
|
||||
return $('[name="csrfmiddlewaretoken"]').val();
|
||||
}
|
||||
|
||||
// Get description based on current language
|
||||
function getDescription(category) {
|
||||
if (currentLanguage === 'ar' && category.description_ar) {
|
||||
return category.description_ar;
|
||||
}
|
||||
return category.description_en || '';
|
||||
// Get description based on current language
|
||||
function getDescription(category) {
|
||||
if (currentLanguage === 'ar' && category.description_ar) {
|
||||
return category.description_ar;
|
||||
}
|
||||
return category.description_en || '';
|
||||
}
|
||||
|
||||
// Get name based on current language
|
||||
function getName(category) {
|
||||
if (currentLanguage === 'ar' && category.name_ar) {
|
||||
return category.name_ar;
|
||||
}
|
||||
return category.name_en;
|
||||
// Get name based on current language
|
||||
function getName(category) {
|
||||
if (currentLanguage === 'ar' && category.name_ar) {
|
||||
return category.name_ar;
|
||||
}
|
||||
return category.name_en;
|
||||
}
|
||||
|
||||
// Load categories
|
||||
function loadCategories() {
|
||||
// Load categories
|
||||
function loadCategories() {
|
||||
$.ajax({
|
||||
url: '{% url "complaints:api_load_categories" %}',
|
||||
type: 'GET',
|
||||
@ -369,8 +368,8 @@ $(document).ready(function() {
|
||||
});
|
||||
}
|
||||
|
||||
// Show category description
|
||||
function showCategoryDescription(categoryId) {
|
||||
// Show category description
|
||||
function showCategoryDescription(categoryId) {
|
||||
const category = allCategories.find(c => c.id === categoryId);
|
||||
const descriptionDiv = $('#category_description');
|
||||
const descriptionText = $('#category_description_text');
|
||||
@ -383,8 +382,8 @@ $(document).ready(function() {
|
||||
}
|
||||
}
|
||||
|
||||
// Show subcategory description
|
||||
function showSubcategoryDescription(subcategoryId) {
|
||||
// Show subcategory description
|
||||
function showSubcategoryDescription(subcategoryId) {
|
||||
const subcategory = allCategories.find(c => c.id === subcategoryId);
|
||||
const descriptionDiv = $('#subcategory_description');
|
||||
const descriptionText = $('#subcategory_description_text');
|
||||
@ -397,8 +396,8 @@ $(document).ready(function() {
|
||||
}
|
||||
}
|
||||
|
||||
// Load subcategories based on selected category
|
||||
function loadSubcategories(categoryId) {
|
||||
// Load subcategories based on selected category
|
||||
function loadSubcategories(categoryId) {
|
||||
if (!categoryId) {
|
||||
$('#subcategory_container').hide();
|
||||
$('#subcategory_description').hide();
|
||||
@ -430,31 +429,34 @@ $(document).ready(function() {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize complaint form - called when form is loaded
|
||||
function initializeComplaintForm() {
|
||||
// Detect current language from HTML
|
||||
const htmlLang = document.documentElement.lang;
|
||||
if (htmlLang === 'ar') {
|
||||
currentLanguage = 'ar';
|
||||
}
|
||||
|
||||
// Initialize - load categories on page load
|
||||
// Load categories immediately
|
||||
loadCategories();
|
||||
}
|
||||
|
||||
// Handle category change
|
||||
$('#id_category').on('change', function() {
|
||||
const categoryId = $(this).val();
|
||||
loadSubcategories(categoryId);
|
||||
showCategoryDescription(categoryId);
|
||||
$('#subcategory_description').hide(); // Hide subcategory description when category changes
|
||||
});
|
||||
// Handle category change
|
||||
$('#id_category').on('change', function() {
|
||||
const categoryId = $(this).val();
|
||||
loadSubcategories(categoryId);
|
||||
showCategoryDescription(categoryId);
|
||||
$('#subcategory_description').hide(); // Hide subcategory description when category changes
|
||||
});
|
||||
|
||||
// Handle subcategory change
|
||||
$('#id_subcategory').on('change', function() {
|
||||
const subcategoryId = $(this).val();
|
||||
showSubcategoryDescription(subcategoryId);
|
||||
});
|
||||
// Handle subcategory change
|
||||
$('#id_subcategory').on('change', function() {
|
||||
const subcategoryId = $(this).val();
|
||||
showSubcategoryDescription(subcategoryId);
|
||||
});
|
||||
|
||||
// Form submission
|
||||
$('#public_complaint_form').on('submit', function(e) {
|
||||
// Form submission
|
||||
$('#public_complaint_form').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = $('#submit_btn');
|
||||
@ -508,6 +510,9 @@ $(document).ready(function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize form on page load
|
||||
initializeComplaintForm();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
198
templates/complaints/public_inquiry_form.html
Normal file
198
templates/complaints/public_inquiry_form.html
Normal file
@ -0,0 +1,198 @@
|
||||
{% load i18n %}
|
||||
|
||||
<!-- Inquiry Form -->
|
||||
<div class="inquiry-form" id="inquiryFormContainer">
|
||||
<div class="form-section">
|
||||
<h3><i class="fas fa-question-circle"></i> {% trans "Inquiry Details" %}</h3>
|
||||
<p class="text-muted">{% trans "Ask a question or request information. We'll get back to you within 24-48 hours." %}</p>
|
||||
|
||||
<!-- Contact Information -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="inquiry_name">
|
||||
{% trans "Name" %} <span class="required-mark">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="inquiry_name"
|
||||
name="name"
|
||||
maxlength="200"
|
||||
placeholder="{% trans 'Your full name' %}"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="inquiry_email">
|
||||
{% trans "Email Address" %} <span class="required-mark">*</span>
|
||||
</label>
|
||||
<input type="email"
|
||||
class="form-control"
|
||||
id="inquiry_email"
|
||||
name="email"
|
||||
placeholder="your@email.com"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="inquiry_phone">
|
||||
{% trans "Phone Number" %} <span class="required-mark">*</span>
|
||||
</label>
|
||||
<input type="tel"
|
||||
class="form-control"
|
||||
id="inquiry_phone"
|
||||
name="phone"
|
||||
maxlength="20"
|
||||
placeholder="{% trans 'Your phone number' %}"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="inquiry_hospital">
|
||||
{% trans "Hospital" %} <span class="required-mark">*</span>
|
||||
</label>
|
||||
<select class="form-control"
|
||||
id="inquiry_hospital"
|
||||
name="hospital"
|
||||
required>
|
||||
<option value="">{% trans "Select Hospital" %}</option>
|
||||
{% for hospital in hospitals %}
|
||||
<option value="{{ hospital.id }}">{{ hospital.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inquiry Details -->
|
||||
<div class="form-group mt-4">
|
||||
<label for="inquiry_category">
|
||||
{% trans "Category" %} <span class="required-mark">*</span>
|
||||
</label>
|
||||
<select class="form-control"
|
||||
id="inquiry_category"
|
||||
name="category"
|
||||
required>
|
||||
<option value="">{% trans "Select Category" %}</option>
|
||||
<option value="general">{% trans "General Inquiry" %}</option>
|
||||
<option value="services">{% trans "Services Information" %}</option>
|
||||
<option value="appointments">{% trans "Appointments" %}</option>
|
||||
<option value="billing">{% trans "Billing & Insurance" %}</option>
|
||||
<option value="medical">{% trans "Medical Records" %}</option>
|
||||
<option value="other">{% trans "Other" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-3">
|
||||
<label for="inquiry_subject">
|
||||
{% trans "Subject" %} <span class="required-mark">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="inquiry_subject"
|
||||
name="subject"
|
||||
maxlength="200"
|
||||
placeholder="{% trans 'Brief summary of your inquiry' %}"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-3">
|
||||
<label for="inquiry_message">
|
||||
{% trans "Message" %} <span class="required-mark">*</span>
|
||||
</label>
|
||||
<textarea class="form-control"
|
||||
id="inquiry_message"
|
||||
name="message"
|
||||
rows="6"
|
||||
placeholder="{% trans 'Please provide details about your inquiry' %}"
|
||||
required></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="text-center mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg" id="inquirySubmitBtn">
|
||||
<i class="fas fa-paper-plane"></i> {% trans "Submit Inquiry" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Get CSRF token
|
||||
function getCSRFToken() {
|
||||
const cookieValue = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrftoken='))
|
||||
?.split('=')[1];
|
||||
return cookieValue || $('[name="csrfmiddlewaretoken"]').val();
|
||||
}
|
||||
|
||||
// Inquiry form submission
|
||||
$('#publicFormContainer').on('submit', '#inquiryFormContainer form', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = $('#inquirySubmitBtn');
|
||||
const originalText = submitBtn.html();
|
||||
|
||||
submitBtn.prop('disabled', true).html(
|
||||
'<span class="spinner"></span> {% trans "Submitting..." %}'
|
||||
);
|
||||
|
||||
const formData = new FormData(this);
|
||||
|
||||
$.ajax({
|
||||
url: '{% url "core:public_inquiry_submit" %}',
|
||||
type: 'POST',
|
||||
data: formData,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
headers: {
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
$('#referenceNumber').text(response.reference_number);
|
||||
$('#successModal').modal('show');
|
||||
|
||||
// Reset form
|
||||
this.reset();
|
||||
|
||||
// Go back to landing page
|
||||
$('#successModal').on('hidden.bs.modal', function() {
|
||||
resetToSelection();
|
||||
});
|
||||
} else {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '{% trans "Error" %}',
|
||||
text: response.message || '{% trans "Failed to submit inquiry. Please try again." %}'
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
let errorMessage = '{% trans "Failed to submit inquiry. Please try again." %}';
|
||||
|
||||
if (xhr.responseJSON && xhr.responseJSON.errors) {
|
||||
errorMessage = xhr.responseJSON.errors.join('\n');
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '{% trans "Error" %}',
|
||||
text: errorMessage
|
||||
});
|
||||
},
|
||||
complete: function() {
|
||||
submitBtn.prop('disabled', false).html(originalText);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
948
templates/core/public_submit.html
Normal file
948
templates/core/public_submit.html
Normal file
@ -0,0 +1,948 @@
|
||||
{% extends "layouts/public_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Submit Feedback" %}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.public-submit-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.header-section h1 {
|
||||
color: #2c3e50;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.header-section p {
|
||||
color: #6c757d;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.selection-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.selection-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.selection-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15);
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card-complaint .card-icon { color: #e74c3c; }
|
||||
.card-observation .card-icon { color: #9b59b6; }
|
||||
.card-inquiry .card-icon { color: #3498db; }
|
||||
|
||||
.card-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
color: #6c757d;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
display: none;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-container.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.back-button {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #3498db;
|
||||
}
|
||||
|
||||
.track-links {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.track-links a {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
margin: 0 1rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.track-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
[dir="rtl"] .spinner {
|
||||
margin-right: 0;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.loading-spinner i {
|
||||
font-size: 3rem;
|
||||
color: #3498db;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="public-submit-container">
|
||||
<!-- Header -->
|
||||
<div class="header-section">
|
||||
<h1><i class="fas fa-comments"></i> {% trans "We Value Your Feedback" %}</h1>
|
||||
<p>{% trans "Choose how you'd like to reach us and we'll respond promptly" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Selection Cards -->
|
||||
<div class="selection-cards" id="selectionCards">
|
||||
<!-- Complaint Card -->
|
||||
<div class="selection-card card-complaint" data-type="complaint">
|
||||
<div class="card-icon">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
</div>
|
||||
<h3 class="card-title">{% trans "Complaint" %}</h3>
|
||||
<p class="card-description">
|
||||
{% trans "Report issues with services, staff, or facilities. We take your concerns seriously and will investigate thoroughly." %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Observation Card -->
|
||||
<div class="selection-card card-observation" data-type="observation">
|
||||
<div class="card-icon">
|
||||
<i class="fas fa-eye"></i>
|
||||
</div>
|
||||
<h3 class="card-title">{% trans "Observation" %}</h3>
|
||||
<p class="card-description">
|
||||
{% trans "Report incidents or issues anonymously. Help us improve safety and quality by sharing what you've noticed." %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Inquiry Card -->
|
||||
<div class="selection-card card-inquiry" data-type="inquiry">
|
||||
<div class="card-icon">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
</div>
|
||||
<h3 class="card-title">{% trans "Inquiry" %}</h3>
|
||||
<p class="card-description">
|
||||
{% trans "Ask questions or request information. Need help understanding services, appointments, or policies?" %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Container -->
|
||||
<div class="form-container" id="publicFormContainer">
|
||||
<!-- Back Button -->
|
||||
<div class="back-button">
|
||||
<button class="btn btn-outline-secondary" onclick="resetToSelection()">
|
||||
<i class="fas fa-arrow-left"></i> {% trans "Back to Selection" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading Spinner -->
|
||||
<div class="loading-spinner" id="loadingSpinner" style="display: none;">
|
||||
<i class="fas fa-circle-notch"></i>
|
||||
<p class="mt-3">{% trans "Loading form..." %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Form Content (loaded via AJAX) -->
|
||||
<div id="formContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Track Links -->
|
||||
<div class="track-links">
|
||||
<p class="text-muted">{% trans "Already submitted something?" %}</p>
|
||||
<a href="{% url 'complaints:public_complaint_success' 'lookup' %}">
|
||||
<i class="fas fa-search me-1"></i>{% trans "Track Complaint" %}
|
||||
</a>
|
||||
<a href="{% url 'observations:observation_track' %}">
|
||||
<i class="fas fa-search me-1"></i>{% trans "Track Observation" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Modal -->
|
||||
<div class="modal fade" id="successModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-body text-center" style="padding: 3rem;">
|
||||
<i class="fas fa-check-circle" style="font-size: 5rem; color: #28a745; margin-bottom: 1.5rem;"></i>
|
||||
<h3 style="margin-bottom: 1rem;">{% trans "Submitted Successfully!" %}</h3>
|
||||
<p class="lead" style="margin-bottom: 1rem;">
|
||||
{% trans "Your submission has been received and is being processed." %}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{% trans "Reference Number:" %}</strong>
|
||||
<span id="referenceNumber" style="font-size: 1.5rem; color: #3498db;"></span>
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
{% trans "Please save this reference number for your records." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
let currentFormType = null;
|
||||
|
||||
// Handle card selection
|
||||
$('.selection-card').on('click', function() {
|
||||
const formType = $(this).data('type');
|
||||
currentFormType = formType;
|
||||
|
||||
// Hide selection cards
|
||||
$('#selectionCards').slideUp(300);
|
||||
|
||||
// Show form container
|
||||
$('#publicFormContainer').fadeIn(300);
|
||||
|
||||
// Load the appropriate form
|
||||
loadForm(formType);
|
||||
});
|
||||
|
||||
// Load form via AJAX
|
||||
function loadForm(formType) {
|
||||
$('#loadingSpinner').show();
|
||||
$('#formContent').hide();
|
||||
|
||||
let templateUrl = '';
|
||||
let hospitals = [];
|
||||
|
||||
// Get hospitals for the form
|
||||
$.ajax({
|
||||
url: '/core/api/hospitals/',
|
||||
type: 'GET',
|
||||
async: false,
|
||||
success: function(response) {
|
||||
hospitals = response.hospitals || [];
|
||||
}
|
||||
});
|
||||
|
||||
if (formType === 'complaint') {
|
||||
// Load complaint form via AJAX
|
||||
$.ajax({
|
||||
url: '{% url "complaints:public_complaint_submit" %}',
|
||||
type: 'GET',
|
||||
success: function(response) {
|
||||
// Extract form from response
|
||||
const formHtml = $('<div>').html(response).find('.complaint-form').html();
|
||||
$('#formContent').html(formHtml);
|
||||
|
||||
// Initialize form functionality
|
||||
if (typeof initializeComplaintForm === 'function') {
|
||||
initializeComplaintForm();
|
||||
}
|
||||
|
||||
showForm();
|
||||
},
|
||||
error: function() {
|
||||
$('#loadingSpinner').hide();
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '{% trans "Error" %}',
|
||||
text: '{% trans "Failed to load form. Please try again." %}'
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (formType === 'observation') {
|
||||
// Render observation form inline
|
||||
fetchCategoriesAndRenderObservationForm();
|
||||
} else if (formType === 'inquiry') {
|
||||
// Render inquiry form template with context
|
||||
renderInquiryForm(hospitals);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchCategoriesAndRenderObservationForm() {
|
||||
// Fetch observation categories
|
||||
$.ajax({
|
||||
url: '/core/api/observation-categories/',
|
||||
type: 'GET',
|
||||
success: function(response) {
|
||||
renderObservationForm(response.categories || []);
|
||||
},
|
||||
error: function() {
|
||||
$('#loadingSpinner').hide();
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '{% trans "Error" %}',
|
||||
text: '{% trans "Failed to load observation form. Please try again." %}'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderObservationForm(categories) {
|
||||
// Build observation categories options
|
||||
let categoriesOptions = '<option value="">{% trans "Select Category" %} ({% trans "optional" %})</option>';
|
||||
categories.forEach(function(category) {
|
||||
const lang = '{{ LANGUAGE_CODE }}';
|
||||
const name = lang === 'ar' ? category.name_ar : category.name_en;
|
||||
categoriesOptions += `<option value="${category.id}">${name}</option>`;
|
||||
});
|
||||
|
||||
const formHtml = `
|
||||
<form id="observationForm" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="form-section">
|
||||
<h3><i class="fas fa-eye"></i> {% trans "Report an Observation" %}</h3>
|
||||
<p class="text-muted">{% trans "Help us improve by reporting issues you notice. You can submit this anonymously." %}</p>
|
||||
|
||||
<!-- Category -->
|
||||
<div class="form-group">
|
||||
<label>{% trans "Category" %} <span class="text-muted">({% trans "optional" %})</span></label>
|
||||
<select class="form-control" name="category">
|
||||
${categoriesOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Severity -->
|
||||
<div class="form-group mt-4">
|
||||
<label>{% trans "Severity" %} <span class="required-mark">*</span></label>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="severity-option severity-low p-3 text-center border rounded cursor-pointer" data-value="low">
|
||||
<i class="fas fa-info-circle text-success"></i>
|
||||
<div class="small mt-1">{% trans "Low" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="severity-option severity-medium p-3 text-center border rounded cursor-pointer selected" data-value="medium">
|
||||
<i class="fas fa-exclamation-circle text-warning"></i>
|
||||
<div class="small mt-1">{% trans "Medium" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="severity-option severity-high p-3 text-center border rounded cursor-pointer" data-value="high">
|
||||
<i class="fas fa-exclamation-triangle text-danger"></i>
|
||||
<div class="small mt-1">{% trans "High" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="severity-option severity-critical p-3 text-center border rounded cursor-pointer" data-value="critical">
|
||||
<i class="fas fa-exclamation-circle text-dark"></i>
|
||||
<div class="small mt-1">{% trans "Critical" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="severity" id="severityInput" value="medium">
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="form-group mt-4">
|
||||
<label>{% trans "Title" %} <span class="text-muted">({% trans "optional" %})</span></label>
|
||||
<input type="text" class="form-control" name="title" maxlength="200">
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-group mt-4">
|
||||
<label>{% trans "Description" %} <span class="required-mark">*</span></label>
|
||||
<textarea class="form-control" name="description" rows="5" required></textarea>
|
||||
<small class="text-muted">{% trans "Please describe what you observed in detail." %}</small>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="form-group mt-4">
|
||||
<label>{% trans "Location" %} <span class="text-muted">({% trans "optional" %})</span></label>
|
||||
<input type="text" class="form-control" name="location_text" maxlength="200">
|
||||
</div>
|
||||
|
||||
<!-- Incident Date/Time -->
|
||||
<div class="form-group mt-4">
|
||||
<label>{% trans "When did this occur?" %}</label>
|
||||
<input type="datetime-local" class="form-control" name="incident_datetime">
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div class="form-group mt-4">
|
||||
<label>{% trans "Attachments" %} <span class="text-muted">({% trans "optional" %})</span></label>
|
||||
<div class="file-upload-area border-2 border-dashed rounded p-4 text-center cursor-pointer" onclick="document.getElementById('observationAttachments').click()">
|
||||
<i class="fas fa-cloud-upload-alt fa-2x text-primary"></i>
|
||||
<p class="mb-0 mt-2">{% trans "Click to upload files" %}</p>
|
||||
<small class="text-muted">{% trans "Images, PDF, Word, Excel (max 10MB each)" %}</small>
|
||||
</div>
|
||||
<input type="file" id="observationAttachments" name="attachments" multiple
|
||||
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx"
|
||||
style="display: none;" onchange="updateObservationFileList()">
|
||||
<div id="observationFileList" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Reporter Information (Optional) -->
|
||||
<div class="form-group mt-4">
|
||||
<label>{% trans "Your Information" %} <span class="text-muted">({% trans "optional" %})</span></label>
|
||||
<p class="text-muted small">{% trans "Providing your information helps us follow up if needed. Leave blank to submit anonymously." %}</p>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label>{% trans "Staff ID" %}</label>
|
||||
<input type="text" class="form-control" name="reporter_staff_id">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label>{% trans "Name" %}</label>
|
||||
<input type="text" class="form-control" name="reporter_name">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label>{% trans "Phone" %}</label>
|
||||
<input type="tel" class="form-control" name="reporter_phone">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label>{% trans "Email" %}</label>
|
||||
<input type="email" class="form-control" name="reporter_email">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="text-center mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg" id="observationSubmitBtn">
|
||||
<i class="fas fa-paper-plane"></i> {% trans "Submit Observation" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
$('#formContent').html(formHtml);
|
||||
|
||||
// Initialize severity selection
|
||||
$('#formContent').on('click', '.severity-option', function() {
|
||||
$('#formContent').find('.severity-option').removeClass('selected border-primary bg-light');
|
||||
$(this).addClass('selected border-primary bg-light');
|
||||
$('#severityInput').val($(this).data('value'));
|
||||
});
|
||||
|
||||
// Attach form submission handler
|
||||
$('#observationForm').on('submit', handleObservationSubmit);
|
||||
|
||||
showForm();
|
||||
}
|
||||
|
||||
function updateObservationFileList() {
|
||||
const input = document.getElementById('observationAttachments');
|
||||
const fileList = document.getElementById('observationFileList');
|
||||
fileList.innerHTML = '';
|
||||
|
||||
if (input.files.length > 0) {
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'list-unstyled mb-0';
|
||||
|
||||
for (let i = 0; i < input.files.length; i++) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'small text-muted';
|
||||
li.innerHTML = '<i class="fas fa-file me-1"></i>' + input.files[i].name;
|
||||
list.appendChild(li);
|
||||
}
|
||||
|
||||
fileList.appendChild(list);
|
||||
}
|
||||
}
|
||||
|
||||
function handleObservationSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = $('#observationSubmitBtn');
|
||||
const originalText = submitBtn.html();
|
||||
|
||||
submitBtn.prop('disabled', true).html(
|
||||
'<span class="spinner"></span> {% trans "Submitting..." %}'
|
||||
);
|
||||
|
||||
const formData = new FormData(this);
|
||||
|
||||
$.ajax({
|
||||
url: '/core/public/observation/submit/',
|
||||
type: 'POST',
|
||||
data: formData,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
headers: {
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
$('#referenceNumber').text(response.tracking_code);
|
||||
$('#successModal').modal('show');
|
||||
|
||||
// Reset and go back
|
||||
document.getElementById('observationForm').reset();
|
||||
$('#successModal').on('hidden.bs.modal', function() {
|
||||
resetToSelection();
|
||||
});
|
||||
} else {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '{% trans "Error" %}',
|
||||
text: response.errors ? response.errors.join('\\n') : '{% trans "Failed to submit. Please try again." %}'
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
let errorMessage = '{% trans "Failed to submit. Please try again." %}';
|
||||
if (xhr.responseJSON && xhr.responseJSON.errors) {
|
||||
errorMessage = xhr.responseJSON.errors.join('\\n');
|
||||
}
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '{% trans "Error" %}',
|
||||
text: errorMessage
|
||||
});
|
||||
},
|
||||
complete: function() {
|
||||
submitBtn.prop('disabled', false).html(originalText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderInquiryForm(hospitals) {
|
||||
// Build the inquiry form HTML
|
||||
let hospitalsOptions = '<option value="">{% trans "Select Hospital" %}</option>';
|
||||
hospitals.forEach(function(hospital) {
|
||||
hospitalsOptions += `<option value="${hospital.id}">${hospital.name}</option>`;
|
||||
});
|
||||
|
||||
const formHtml = `
|
||||
<form id="inquiryForm">
|
||||
{% csrf_token %}
|
||||
<div class="form-section">
|
||||
<h3><i class="fas fa-question-circle"></i> {% trans "Inquiry Details" %}</h3>
|
||||
<p class="text-muted">{% trans "Ask a question or request information. We'll get back to you within 24-48 hours." %}</p>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label>{% trans "Name" %} <span class="required-mark">*</span></label>
|
||||
<input type="text" class="form-control" name="name" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label>{% trans "Email Address" %} <span class="required-mark">*</span></label>
|
||||
<input type="email" class="form-control" name="email" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label>{% trans "Phone Number" %} <span class="required-mark">*</span></label>
|
||||
<input type="tel" class="form-control" name="phone" maxlength="20" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label>{% trans "Hospital" %} <span class="required-mark">*</span></label>
|
||||
<select class="form-control" name="hospital" required>
|
||||
${hospitalsOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-4">
|
||||
<label>{% trans "Category" %} <span class="required-mark">*</span></label>
|
||||
<select class="form-control" name="category" required>
|
||||
<option value="">{% trans "Select Category" %}</option>
|
||||
<option value="general">{% trans "General Information" %}</option>
|
||||
<option value="appointment">{% trans "Appointment" %}</option>
|
||||
<option value="billing">{% trans "Billing" %}</option>
|
||||
<option value="medical_records">{% trans "Medical Records" %}</option>
|
||||
<option value="other">{% trans "Other" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-3">
|
||||
<label>{% trans "Subject" %} <span class="required-mark">*</span></label>
|
||||
<input type="text" class="form-control" name="subject" maxlength="200" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-3">
|
||||
<label>{% trans "Message" %} <span class="required-mark">*</span></label>
|
||||
<textarea class="form-control" name="message" rows="6" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg" id="inquirySubmitBtn">
|
||||
<i class="fas fa-paper-plane"></i> {% trans "Submit Inquiry" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
$('#formContent').html(formHtml);
|
||||
|
||||
// Attach form submission handler
|
||||
$('#inquiryForm').on('submit', handleInquirySubmit);
|
||||
|
||||
showForm();
|
||||
}
|
||||
|
||||
function showForm() {
|
||||
$('#loadingSpinner').hide();
|
||||
$('#formContent').fadeIn(300);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMPLAINT FORM FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
let complaintAllCategories = [];
|
||||
let complaintCurrentLanguage = 'en';
|
||||
|
||||
// Get complaint description based on current language
|
||||
function getComplaintDescription(category) {
|
||||
if (complaintCurrentLanguage === 'ar' && category.description_ar) {
|
||||
return category.description_ar;
|
||||
}
|
||||
return category.description_en || '';
|
||||
}
|
||||
|
||||
// Get complaint category name based on current language
|
||||
function getComplaintName(category) {
|
||||
if (complaintCurrentLanguage === 'ar' && category.name_ar) {
|
||||
return category.name_ar;
|
||||
}
|
||||
return category.name_en;
|
||||
}
|
||||
|
||||
// Load complaint categories
|
||||
function loadComplaintCategories() {
|
||||
$.ajax({
|
||||
url: '{% url "complaints:api_load_categories" %}',
|
||||
type: 'GET',
|
||||
success: function(response) {
|
||||
// Store all categories
|
||||
complaintAllCategories = response.categories;
|
||||
|
||||
const categorySelect = $('#id_category');
|
||||
categorySelect.find('option:not(:first)').remove();
|
||||
|
||||
// Only show parent categories (no parent_id)
|
||||
complaintAllCategories.forEach(function(category) {
|
||||
if (!category.parent_id) {
|
||||
categorySelect.append($('<option>', {
|
||||
value: category.id,
|
||||
text: getComplaintName(category)
|
||||
}));
|
||||
}
|
||||
});
|
||||
},
|
||||
error: function() {
|
||||
console.error('Failed to load complaint categories');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show complaint category description
|
||||
function showComplaintCategoryDescription(categoryId) {
|
||||
const category = complaintAllCategories.find(c => c.id === categoryId);
|
||||
const descriptionDiv = $('#category_description');
|
||||
const descriptionText = $('#category_description_text');
|
||||
|
||||
if (category && getComplaintDescription(category)) {
|
||||
descriptionText.text(getComplaintDescription(category));
|
||||
descriptionDiv.show();
|
||||
} else {
|
||||
descriptionDiv.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Show complaint subcategory description
|
||||
function showComplaintSubcategoryDescription(subcategoryId) {
|
||||
const subcategory = complaintAllCategories.find(c => c.id === subcategoryId);
|
||||
const descriptionDiv = $('#subcategory_description');
|
||||
const descriptionText = $('#subcategory_description_text');
|
||||
|
||||
if (subcategory && getComplaintDescription(subcategory)) {
|
||||
descriptionText.text(getComplaintDescription(subcategory));
|
||||
descriptionDiv.show();
|
||||
} else {
|
||||
descriptionDiv.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Load complaint subcategories based on selected category
|
||||
function loadComplaintSubcategories(categoryId) {
|
||||
if (!categoryId) {
|
||||
$('#subcategory_container').hide();
|
||||
$('#subcategory_description').hide();
|
||||
$('#id_subcategory').find('option:not(:first)').remove();
|
||||
$('#id_subcategory').prop('required', false);
|
||||
return;
|
||||
}
|
||||
|
||||
const subcategorySelect = $('#id_subcategory');
|
||||
subcategorySelect.find('option:not(:first)').remove();
|
||||
|
||||
// Filter subcategories for this parent category (match parent_id)
|
||||
complaintAllCategories.forEach(function(category) {
|
||||
if (category.parent_id == categoryId) {
|
||||
subcategorySelect.append($('<option>', {
|
||||
value: category.id,
|
||||
text: getComplaintName(category)
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
if (subcategorySelect.find('option').length > 1) {
|
||||
$('#subcategory_container').show();
|
||||
$('#id_subcategory').prop('required', true);
|
||||
} else {
|
||||
$('#subcategory_container').hide();
|
||||
$('#subcategory_description').hide();
|
||||
$('#id_subcategory').prop('required', false);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize complaint form - called when form is loaded via AJAX
|
||||
function initializeComplaintForm() {
|
||||
// Detect current language from HTML
|
||||
const htmlLang = document.documentElement.lang;
|
||||
if (htmlLang === 'ar') {
|
||||
complaintCurrentLanguage = 'ar';
|
||||
}
|
||||
|
||||
// Load categories immediately
|
||||
loadComplaintCategories();
|
||||
|
||||
// Attach event handlers for complaint form
|
||||
$('#formContent').on('change', '#id_category', function() {
|
||||
const categoryId = $(this).val();
|
||||
loadComplaintSubcategories(categoryId);
|
||||
showComplaintCategoryDescription(categoryId);
|
||||
$('#subcategory_description').hide();
|
||||
});
|
||||
|
||||
$('#formContent').on('change', '#id_subcategory', function() {
|
||||
const subcategoryId = $(this).val();
|
||||
showComplaintSubcategoryDescription(subcategoryId);
|
||||
});
|
||||
|
||||
$('#formContent').on('submit', '#public_complaint_form', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = $('#submit_btn');
|
||||
const originalText = submitBtn.html();
|
||||
|
||||
submitBtn.prop('disabled', true).html(
|
||||
'<span class="spinner"></span> {% trans "Submitting..." %}'
|
||||
);
|
||||
|
||||
const formData = new FormData(this);
|
||||
|
||||
$.ajax({
|
||||
url: '{% url "complaints:public_complaint_submit" %}',
|
||||
type: 'POST',
|
||||
data: formData,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
headers: {
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
$('#referenceNumber').text(response.reference_number);
|
||||
$('#successModal').modal('show');
|
||||
|
||||
// Reset form
|
||||
document.getElementById('public_complaint_form').reset();
|
||||
$('#successModal').on('hidden.bs.modal', function() {
|
||||
resetToSelection();
|
||||
});
|
||||
} else {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '{% trans "Error" %}',
|
||||
text: response.message || '{% trans "Failed to submit complaint. Please try again." %}'
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
let errorMessage = '{% trans "Failed to submit complaint. Please try again." %}';
|
||||
|
||||
if (xhr.responseJSON && xhr.responseJSON.errors) {
|
||||
errorMessage = xhr.responseJSON.errors.join('\n');
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '{% trans "Error" %}',
|
||||
text: errorMessage
|
||||
});
|
||||
},
|
||||
complete: function() {
|
||||
submitBtn.prop('disabled', false).html(originalText);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleInquirySubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = $('#inquirySubmitBtn');
|
||||
const originalText = submitBtn.html();
|
||||
|
||||
submitBtn.prop('disabled', true).html(
|
||||
'<span class="spinner"></span> {% trans "Submitting..." %}'
|
||||
);
|
||||
|
||||
const formData = new FormData(this);
|
||||
|
||||
$.ajax({
|
||||
url: '{% url "core:public_inquiry_submit" %}',
|
||||
type: 'POST',
|
||||
data: formData,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
headers: {
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
$('#referenceNumber').text(response.reference_number);
|
||||
$('#successModal').modal('show');
|
||||
|
||||
// Reset and go back
|
||||
document.getElementById('inquiryForm').reset();
|
||||
$('#successModal').on('hidden.bs.modal', function() {
|
||||
resetToSelection();
|
||||
});
|
||||
} else {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '{% trans "Error" %}',
|
||||
text: response.message || '{% trans "Failed to submit. Please try again." %}'
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
let errorMessage = '{% trans "Failed to submit. Please try again." %}';
|
||||
if (xhr.responseJSON && xhr.responseJSON.errors) {
|
||||
errorMessage = xhr.responseJSON.errors.join('\\n');
|
||||
}
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '{% trans "Error" %}',
|
||||
text: errorMessage
|
||||
});
|
||||
},
|
||||
complete: function() {
|
||||
submitBtn.prop('disabled', false).html(originalText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getCSRFToken() {
|
||||
const cookieValue = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrftoken='))
|
||||
?.split('=')[1];
|
||||
return cookieValue || $('[name="csrfmiddlewaretoken"]').val();
|
||||
}
|
||||
|
||||
// Global function to reset to selection
|
||||
window.resetToSelection = function() {
|
||||
$('#publicFormContainer').fadeOut(300);
|
||||
setTimeout(function() {
|
||||
$('#formContent').empty();
|
||||
$('#selectionCards').slideDown(300);
|
||||
}, 300);
|
||||
currentFormType = null;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
26
uv.lock
generated
26
uv.lock
generated
@ -1675,6 +1675,7 @@ dependencies = [
|
||||
{ name = "redis" },
|
||||
{ name = "reportlab" },
|
||||
{ name = "rich" },
|
||||
{ name = "watchdog" },
|
||||
{ name = "whitenoise" },
|
||||
]
|
||||
|
||||
@ -1712,6 +1713,7 @@ requires-dist = [
|
||||
{ name = "reportlab", specifier = ">=4.4.7" },
|
||||
{ name = "rich", specifier = ">=14.2.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
||||
{ name = "watchdog", specifier = ">=6.0.0" },
|
||||
{ name = "whitenoise", specifier = ">=6.6.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
@ -2434,6 +2436,30 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchdog"
|
||||
version = "6.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.14"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user