Merge remote-tracking branch 'origin/main'

# Conflicts:
#	data.json
This commit is contained in:
Marwan Alwali 2026-03-16 02:12:48 +03:00
commit 3c2593de78
183 changed files with 1706650 additions and 19652 deletions

View File

@ -68,9 +68,14 @@ SMS_API_RETRY_DELAY=2
# Admin URL (change in production)
ADMIN_URL=admin/
# Integration APIs (Stubs - Replace with actual credentials)
HIS_API_URL=
# Integration APIs
# HIS API - Hospital Information System for fetching patient discharge data
HIS_API_URL=https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps
HIS_API_USERNAME=your_his_username
HIS_API_PASSWORD=your_his_password
HIS_API_KEY=
# Other Integration APIs (Stubs - Replace with actual credentials)
MOH_API_URL=
MOH_API_KEY=
CHI_API_URL=

View File

@ -0,0 +1 @@
,ismail,ismail-Latitude-5500,11.03.2026 02:32,/home/ismail/.local/share/onlyoffice;

View File

@ -0,0 +1,220 @@
# Admin Complaint Notification Email Fix
## Problem
When a new complaint was created, the admin notification email was being sent as plain text without using the branded HTML email template system.
## Root Cause
The `notify_admins_new_complaint()` function in `apps/complaints/tasks.py` was generating plain text emails with bilingual content (English/Arabic) but not rendering any HTML template.
**Location:** `apps/complaints/tasks.py` lines 2408-2500
## Solution
### 1. Created New Email Template
**File:** `templates/emails/new_complaint_admin_notification.html`
**Features:**
- ✅ Extends `emails/base_email_template.html` for consistent branding
- ✅ Hospital logo header with gradient background
- ✅ Priority/severity badges with color coding:
- 🔴 Critical
- 🟠 High
- 🟡 Medium
- 🟢 Low
- ✅ Complaint details grid (reference, title, priority, severity, status)
- ✅ Patient information section (name, MRN, phone, email)
- ✅ Hospital/department section
- ✅ Description preview
- ✅ Action required notice
- ✅ View complaint CTA button
- ✅ Professional footer with hospital branding
### 2. Updated Task Function
**File:** `apps/complaints/tasks.py`
**Changes:**
1. Added import: `from django.template.loader import render_to_string`
2. Updated `notify_admins_new_complaint()` function (line 2436)
3. Now renders HTML template with complaint context
4. Sends both HTML and plain text versions
**Code Changes:**
```python
# Before: Plain text only
message_en = f"""Dear {admin.get_full_name() or 'Admin'},
A new complaint has been submitted..."""
NotificationService.send_email(
email=admin.email,
subject=subject_en,
message=message_en, # Plain text only
...
)
# After: HTML + Plain text
context = {
'admin_name': admin.get_full_name() or 'Admin',
'priority_badge': priority_badge,
'is_high_priority': is_high_priority,
'reference_number': complaint.reference_number,
'complaint_title': complaint.title,
'priority': complaint.priority,
'severity': complaint.severity,
# ... more context variables
}
html_message = render_to_string(
'emails/new_complaint_admin_notification.html',
context
)
NotificationService.send_email(
email=admin.email,
subject=subject_en,
message=message_text, # Plain text fallback
html_message=html_message, # HTML template ⭐ NEW
...
)
```
## Email Template Structure
```
┌─────────────────────────────────────────────┐
│ Al Hammadi Hospital Logo (Gradient Header) │
├─────────────────────────────────────────────┤
│ 📋 New Complaint Notification │
│ A new complaint has been submitted... │
├─────────────────────────────────────────────┤
│ [🚨 URGENT: High Priority Complaint] │
│ (Shown only for high priority) │
├─────────────────────────────────────────────┤
│ Complaint Details │
│ ┌──────────────┬──────────────┐ │
│ │ Reference │ Title │ │
│ │ Priority │ Severity │ │
│ │ Status │ Submitted │ │
│ └──────────────┴──────────────┘ │
├─────────────────────────────────────────────┤
│ 👤 Patient Information │
│ Name | MRN | Phone | Email │
├─────────────────────────────────────────────┤
│ 🏥 Hospital & Department │
│ Hospital Name | Department Name │
├─────────────────────────────────────────────┤
│ 📝 Description │
│ (Complaint description preview) │
├─────────────────────────────────────────────┤
│ ✓ Action Required │
│ Please review and activate... │
├─────────────────────────────────────────────┤
│ [View Complaint Button] │
├─────────────────────────────────────────────┤
Notification Information │
│ Type: Working Hours / After Hours │
│ Time: 2026-03-12 10:30:00 │
├─────────────────────────────────────────────┤
│ PX360 Complaint Management System │
│ Al Hammadi Hospital │
└─────────────────────────────────────────────┘
```
## Testing
### To Test:
1. Create a new complaint via the complaint form
2. Check admin email inbox
3. Verify email displays:
- ✅ Hospital branding (logo, colors)
- ✅ Priority/severity badges
- ✅ Complaint details grid
- ✅ Patient information
- ✅ Hospital/department info
- ✅ Description preview
- ✅ Clickable "View Complaint" button
- ✅ Professional footer
### Email Clients to Test:
- Gmail (Web, iOS, Android)
- Outlook (Desktop, Web)
- Apple Mail
- Yahoo Mail
## Files Modified
| File | Changes |
|------|---------|
| `templates/emails/new_complaint_admin_notification.html` | ⭐ NEW - Created |
| `apps/complaints/tasks.py` | ✏️ Updated - Added HTML rendering |
| `templates/emails/README_EMAIL_TEMPLATES.md` | 📝 Updated - Added documentation |
| `EMAIL_TEMPLATE_SYSTEM_SUMMARY.md` | 📝 Updated - Added template info |
## Context Variables
The template receives the following context:
```python
{
'admin_name': str, # Admin's full name
'priority_badge': str, # e.g., '🚨 URGENT' or '📋 New'
'is_high_priority': bool, # True for high/critical priority
'reference_number': str, # Complaint reference
'complaint_title': str, # Complaint title
'priority': str, # low/medium/high/critical
'severity': str, # low/medium/high/critical
'status': str, # e.g., 'New'
'patient_name': str, # Patient name or 'N/A'
'mrn': str, # Medical record number or 'N/A'
'contact_phone': str, # Phone or 'N/A'
'contact_email': str, # Email or 'N/A'
'hospital_name': str, # Hospital name or 'N/A'
'department_name': str, # Department or 'N/A'
'description': str, # Complaint description
'complaint_url': str, # Full URL to complaint
'notification_type': str, # 'Working Hours' or 'After Hours'
'current_time': str, # Formatted timestamp
}
```
## Benefits
### Before:
- ❌ Plain text email
- ❌ No visual hierarchy
- ❌ No branding
- ❌ Hard to scan quickly
- ❌ No color-coded priority
### After:
- ✅ Professional HTML email with hospital branding
- ✅ Clear visual hierarchy
- ✅ Al Hammadi Hospital colors and logo
- ✅ Easy to scan with sections and badges
- ✅ Color-coded priority/severity indicators
- ✅ Prominent CTA button
- ✅ Mobile-responsive design
- ✅ Consistent with other email templates
## Integration Points
This template integrates with:
1. **Complaint Creation Signal** - Triggered when new complaint is created
2. **On-Call Admin System** - Respects working hours and on-call schedules
3. **Notification Service** - Uses `NotificationService.send_email()`
4. **Audit Logging** - Email sends are logged in database
## Future Enhancements
Potential improvements:
1. Add QR code for quick complaint access
2. Include complaint attachment previews
3. Add "Quick Actions" buttons (Assign, Escalate, Acknowledge)
4. Bilingual support (English/Arabic toggle)
5. Add complaint timeline preview
6. Include assigned staff member info
---
**Fixed:** March 12, 2026
**Version:** 1.0
**Status:** ✅ Complete

View File

@ -0,0 +1,144 @@
# Email Template Simplification - Summary
## Changes Made
### 1. Simplified Complaint Notification Template
**File:** `templates/emails/new_complaint_admin_notification.html`
**Removed:**
- ❌ Excessive icons (👤, , 📝, ✓, etc.)
- ❌ Multiple colored sections
- ❌ Complex grid layouts
- ❌ Priority alert boxes
**Kept:**
- ✅ Clean blue header with hospital branding
- ✅ Simple complaint details box with left border accent
- ✅ Clean typography and spacing
- ✅ Professional CTA button
- ✅ Responsive design
### 2. Fixed Responsive Layout
**File:** `templates/emails/base_email_template.html`
**Changes:**
- Added proper centering styles
- Fixed width constraints (600px max)
- Added mobile-responsive padding
- Ensured proper display on all screen sizes
**CSS Improvements:**
```css
/* Body centering */
body, table, td { margin: 0 auto; }
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
width: 100% !important;
max-width: 100% !important;
}
.content-padding {
padding-left: 20px !important;
padding-right: 20px !important;
}
}
```
## Email Structure
```
┌─────────────────────────────────────────┐
│ Blue Gradient Header (Hospital Logo) │
├─────────────────────────────────────────┤
│ New Complaint Notification │
│ A new complaint requires attention │
├─────────────────────────────────────────┤
│ Dear Admin, │
│ A new complaint has been submitted... │
├─────────────────────────────────────────┤
│ ┌───────────────────────────────────┐ │
│ │ Reference: CMP-2026-001 │ │
│ │ Title: Long wait time │ │
│ │ Priority: High │ │
│ │ Severity: High │ │
│ │ Patient: Mohammed Ali │ │
│ │ Hospital: Al Hammadi │ │
│ │ Department: OPD │ │
│ └───────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ ┌───────────────────────────────────┐ │
│ │ Description │ │
│ │ Patient waited for 3 hours... │ │
│ └───────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ ┌───────────────────────────────────┐ │
│ │ Please review and activate... │ │
│ └───────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ [View Complaint Button] │
├─────────────────────────────────────────┤
Notification Details │
│ Type: Working Hours │
│ Time: 2026-03-12 10:30:00 │
├─────────────────────────────────────────┤
│ PX360 Complaint Management System │
│ Al Hammadi Hospital │
└─────────────────────────────────────────┘
```
## Responsive Behavior
### Desktop (>600px)
- Email width: 600px (centered)
- Padding: 40px left/right
- Full layout visible
### Mobile (<600px)
- Email width: 100% (full screen)
- Padding: 20px left/right
- Content stacks vertically
- Button becomes full-width
## Testing Checklist
### Screen Sizes
- [ ] Desktop (1920px)
- [ ] Laptop (1366px)
- [ ] Tablet (768px)
- [ ] Mobile (375px)
- [ ] Small Mobile (320px)
### Email Clients
- [ ] Gmail (Web)
- [ ] Gmail (iOS App)
- [ ] Gmail (Android App)
- [ ] Outlook (Desktop)
- [ ] Outlook (Web)
- [ ] Apple Mail (iOS)
- [ ] Apple Mail (macOS)
### Dark Mode
- [ ] iOS Dark Mode
- [ ] Android Dark Mode
- [ ] Outlook Dark Mode
## Files Modified
| File | Status | Changes |
|------|--------|---------|
| `templates/emails/new_complaint_admin_notification.html` | ✏️ Updated | Simplified layout, removed icons |
| `templates/emails/base_email_template.html` | ✏️ Updated | Fixed responsive centering |
## Next Steps
1. **Test the email** by creating a new complaint
2. **Check on multiple devices** (desktop, mobile, tablet)
3. **Verify in different email clients** (Gmail, Outlook, Apple Mail)
4. **Test dark mode** rendering
5. **Confirm links work** properly
---
**Updated:** March 12, 2026
**Status:** ✅ Complete

View File

@ -0,0 +1,452 @@
# Email Template System - Implementation Summary
## Overview
Implemented a unified email template system for Al Hammadi Hospital using the hospital's official brand colors and design language across all application emails.
## 🎨 Brand Identity
### Color Palette
| Color | Hex Code | Usage |
|-------|----------|-------|
| **Primary Navy** | `#005696` | Main brand color, headers, primary buttons |
| **Accent Blue** | `#007bbd` | Gradients, secondary elements, links |
| **Light Background** | `#eef6fb` | Info boxes, highlights, backgrounds |
| **Slate Gray** | `#64748b` | Secondary text |
| **Success Green** | `#10b981` | Positive indicators, success metrics |
| **Warning Yellow** | `#f59e0b` | Alerts, warnings, important notices |
### Design Features
- **Gradient Header**: `linear-gradient(135deg, #005696 0%, #007bbd 100%)`
- **Responsive Layout**: Mobile-optimized (320px - 1920px)
- **Email Client Compatibility**: Gmail, Outlook, Apple Mail, Yahoo Mail
- **Dark Mode Support**: Automatic adaptation
- **RTL Support**: Arabic language ready
## 📧 Templates Updated
### 1. Base Template
**File:** `templates/emails/base_email_template.html`
**Features:**
- Responsive email wrapper
- Hospital logo header with gradient background
- Multiple content blocks (hero, content, CTA, info boxes)
- Professional footer with contact information
- Extensible block structure
**Blocks Available:**
- `title` - Email title
- `preheader` - Preview text
- `hero_title` - Main heading
- `hero_subtitle` - Subheading
- `content` - Main content area
- `cta_section` - Call-to-action button
- `info_box` - Information/warning box
- `footer_address` - Footer contact info
- `extra_styles` - Custom CSS
---
### 2. Patient-Facing Templates
#### Survey Invitation
**File:** `templates/emails/survey_invitation.html`
**Used By:** Survey distribution system
**Context Variables:**
- `patient_name`
- `visit_date`
- `survey_duration`
- `survey_link`
- `deadline`
**Features:**
- Personalized greeting
- Benefits highlights with icons
- Survey information box
- Clear call-to-action
---
#### Appointment Confirmation
**File:** `templates/emails/appointment_confirmation.html`
**Used By:** Appointment booking system
**Context Variables:**
- `patient_name`
- `appointment_id`
- `appointment_date`
- `appointment_time`
- `department`
- `doctor_name`
- `location`
- `reschedule_link`
**Features:**
- Appointment details card
- Important reminders section
- Reschedule/cancel CTA
- Contact information
---
#### Survey Results Notification
**File:** `templates/emails/survey_results_notification.html`
**Used By:** Analytics reporting system
**Context Variables:**
- `recipient_name`
- `department_name`
- `overall_score`
- `total_responses`
- `response_rate`
- `survey_period`
- `results_link`
- `deadline`
**Features:**
- Statistics dashboard (3 metrics)
- Key highlights section
- Action items alert
- Full report access
---
### 3. Staff/Admin Templates
#### Explanation Request
**File:** `templates/emails/explanation_request.html`
**Used By:** `apps/complaints/tasks.py::send_explanation_request_email()`
**Context Variables:**
- `staff_name`
- `complaint_id`
- `complaint_title`
- `patient_name`
- `hospital_name`
- `department_name`
- `category`
- `status`
- `created_date`
- `description`
- `custom_message` (optional)
- `explanation_url`
**Features:**
- Complaint details card (8 fields)
- Custom message from PX team
- Important information box
- Submit explanation CTA
**Integration:**
```python
# Updated in apps/complaints/tasks.py:1584
html_message = render_to_string(
'emails/explanation_request.html',
context
)
send_mail(
subject=subject,
message=message_text,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[explanation.staff.email],
html_message=html_message,
fail_silently=False
)
```
---
#### New Complaint Admin Notification ⭐ NEW
**File:** `templates/emails/new_complaint_admin_notification.html`
**Used By:** `apps/complaints/tasks.py::notify_admins_new_complaint()`
**Context Variables:**
- `admin_name`
- `priority_badge`
- `is_high_priority`
- `reference_number`
- `complaint_title`
- `priority` (low, medium, high, critical)
- `severity` (low, medium, high, critical)
- `status`
- `patient_name`
- `mrn`
- `contact_phone`
- `contact_email`
- `hospital_name`
- `department_name`
- `description`
- `complaint_url`
- `notification_type`
- `current_time`
**Features:**
- Priority/severity badges with color coding
- Complaint details grid
- Patient information section
- Hospital/department information
- Description preview
- Action required notice
- View complaint CTA
**Integration:**
```python
# Updated in apps/complaints/tasks.py:2436
html_message = render_to_string(
'emails/new_complaint_admin_notification.html',
context
)
NotificationService.send_email(
email=admin.email,
subject=subject,
message=message_text,
html_message=html_message,
...
)
```
---
#### User Invitation
**File:** `templates/accounts/onboarding/invitation_email.html`
**Used By:** `apps/accounts/services.py:EmailService.send_invitation_email()`
**Context Variables:**
- `user` (user instance)
- `activation_url`
- `expires_at`
**Features:**
- Welcome message
- Onboarding process overview (4 items)
- Account setup CTA
- Expiry notice
**Integration:**
```python
# apps/accounts/services.py:409
message_html = render_to_string(
'accounts/onboarding/invitation_email.html',
context
)
send_mail(
subject=subject,
message=message_text,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
html_message=message_html,
fail_silently=False
)
```
---
#### Invitation Reminder
**File:** `templates/accounts/onboarding/reminder_email.html`
**Used By:** `apps/accounts/services.py:EmailService.send_reminder_email()`
**Context Variables:**
- `user` (user instance)
- `activation_url`
- `expires_at`
**Features:**
- Friendly reminder message
- Benefits highlights (3 items)
- Setup CTA
- Urgency notice
---
#### Onboarding Completion
**File:** `templates/accounts/onboarding/completion_email.html`
**Used By:** `apps/accounts/services.py:EmailService.send_completion_notification()`
**Context Variables:**
- `user` (user instance)
- `user_detail_url`
**Features:**
- Success notification
- User information card (7 fields)
- View details CTA
- Completion timestamp
---
#### Password Reset
**File:** `templates/accounts/email/password_reset_email.html`
**Used By:** Django authentication system
**Context Variables:**
- `user`
- `protocol`
- `domain`
- `uid` (uidb64)
- `token`
**Features:**
- Secure reset link
- Expiry warning (24 hours)
- Support contact info
- Security notice
---
## 🔧 Integration Points
### 1. Complaints System
**File:** `apps/complaints/tasks.py`
**Updated Function:** `send_explanation_request_email()`
- Line 1584-1617
- Now renders branded HTML template
- Includes plain text fallback
- Sends via Django's `send_mail()`
---
### 2. Accounts Service
**File:** `apps/accounts/services.py`
**Updated Functions:**
- `EmailService.send_invitation_email()` - Line 407
- `EmailService.send_reminder_email()` - Line 459
- `EmailService.send_completion_notification()` - Line 511
All functions now use the branded templates with consistent styling.
---
### 3. Notifications Service
**File:** `apps/notifications/services.py`
**Function:** `NotificationService.send_email()`
- Line 167-191
- Supports HTML emails via `html_message` parameter
- Logs all email sends to database
- API integration ready
---
## 📊 Usage Statistics
### Email Types in Production
1. **Transaction Emails** (High Volume)
- Appointment confirmations
- Survey invitations
- Password resets
2. **Notification Emails** (Medium Volume)
- Survey results
- Onboarding notifications
- Explanation requests
3. **Administrative Emails** (Low Volume)
- User invitations
- Completion notifications
- System alerts
---
## ✅ Testing Checklist
### Email Client Testing
- [ ] Gmail (Web, iOS, Android)
- [ ] Outlook (2016+, Office 365)
- [ ] Apple Mail (macOS, iOS)
- [ ] Yahoo Mail
- [ ] AOL Mail
### Feature Testing
- [ ] Responsive layout (mobile, tablet, desktop)
- [ ] Dark mode compatibility
- [ ] RTL support (Arabic)
- [ ] Image rendering
- [ ] Link tracking
- [ ] Unsubscribe functionality
### Integration Testing
- [ ] Complaint explanation requests
- [ ] User onboarding emails
- [ ] Password reset emails
- [ ] Survey invitations
- [ ] Appointment confirmations
---
## 🚀 Deployment
### 1. Template Files
All templates are in the Django templates directory and will be automatically available.
### 2. Logo Configuration
Update the logo URL in your settings or context:
```python
# settings.py
EMAIL_LOGO_URL = 'https://your-cdn.com/static/images/HH_P_H_Logo(hospital)_.png'
# Or in views
context = {
'logo_url': request.build_absolute_uri(
static('images/HH_P_H_Logo(hospital)_.png')
)
}
```
### 3. Email Configuration
Ensure Django email settings are configured:
```python
# settings.py
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'smtp.office365.com'
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = 'noreply@alhammadihospital.com'
EMAIL_HOST_PASSWORD = 'your-password'
DEFAULT_FROM_EMAIL = 'Al Hammadi Hospital <noreply@alhammadihospital.com>'
```
---
## 📈 Future Enhancements
### Planned Features
1. **Email Templates for:**
- Bulk survey invitations
- Appointment reminders (24h before)
- Complaint status updates
- Appreciation notifications
- Project notifications
2. **Advanced Features:**
- Email analytics tracking
- A/B testing support
- Dynamic content blocks
- Multi-language support (EN/AR)
- Email preference center
3. **Integration Improvements:**
- Celery tasks for bulk sending
- Email queue management
- Bounce handling
- Delivery tracking
---
## 📞 Support
For questions or issues:
- **Email:** px-team@alhammadihospital.com
- **Department:** Patient Experience Management
- **Documentation:** `templates/emails/README_EMAIL_TEMPLATES.md`
---
**Version:** 1.0
**Created:** March 12, 2026
**Last Updated:** March 12, 2026
**Maintained by:** PX360 Development Team

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
"""
Accounts views and viewsets
"""
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
@ -55,19 +56,19 @@ class CustomTokenObtainPairView(TokenObtainPairView):
# Log successful login and add redirect info
if response.status_code == 200:
username = request.data.get('username')
username = request.data.get("username")
try:
user = User.objects.get(username=username)
AuditService.log_from_request(
event_type='user_login',
event_type="user_login",
description=f"User {user.email} logged in",
request=request,
content_object=user
content_object=user,
)
# Add redirect URL to response data
response_data = response.data
response_data['redirect_url'] = self.get_redirect_url(user)
response_data["redirect_url"] = self.get_redirect_url(user)
response.data = response_data
except User.DoesNotExist:
@ -81,23 +82,25 @@ class CustomTokenObtainPairView(TokenObtainPairView):
"""
# Check if user is a Source User first
from apps.px_sources.models import SourceUser
if SourceUser.objects.filter(user=user).exists():
return '/px_sources/dashboard/'
return "/px_sources/dashboard/"
# PX Admins need to select a hospital first
if user.is_px_admin():
from apps.organizations.models import Hospital
# Check if there's already a hospital in session
# Since we don't have access to request here, frontend should handle this
# Return to hospital selector URL
return '/health/select-hospital/'
return "/health/select-hospital/"
# Users without hospital assignment get error page
if not user.hospital:
return '/health/no-hospital/'
return "/health/no-hospital/"
# All other users go to dashboard
return '/'
return "/"
class UserViewSet(viewsets.ModelViewSet):
@ -109,26 +112,27 @@ class UserViewSet(viewsets.ModelViewSet):
- Create/Update/Delete: PX Admins only
- Users can update their own profile
"""
queryset = User.objects.all()
permission_classes = [IsAuthenticated]
filterset_fields = ['is_active', 'hospital', 'department', 'groups']
search_fields = ['username', 'email', 'first_name', 'last_name', 'employee_id']
ordering_fields = ['date_joined', 'email', 'last_name']
ordering = ['-date_joined']
filterset_fields = ["is_active", "hospital", "department", "groups"]
search_fields = ["username", "email", "first_name", "last_name", "employee_id"]
ordering_fields = ["date_joined", "email", "last_name"]
ordering = ["-date_joined"]
def get_serializer_class(self):
"""Return appropriate serializer based on action"""
if self.action == 'create':
if self.action == "create":
return UserCreateSerializer
elif self.action in ['update', 'partial_update']:
elif self.action in ["update", "partial_update"]:
return UserUpdateSerializer
return UserSerializer
def get_permissions(self):
"""Set permissions based on action"""
if self.action in ['create', 'destroy']:
if self.action in ["create", "destroy"]:
return [IsPXAdmin()]
elif self.action in ['update', 'partial_update']:
elif self.action in ["update", "partial_update"]:
return [IsOwnerOrPXAdmin()]
return [IsAuthenticated()]
@ -139,15 +143,15 @@ class UserViewSet(viewsets.ModelViewSet):
# PX Admins see all users
if user.is_px_admin():
return queryset.select_related('hospital', 'department')
return queryset.select_related("hospital", "department")
# Hospital Admins see users in their hospital
if user.is_hospital_admin() and user.hospital:
return queryset.filter(hospital=user.hospital).select_related('hospital', 'department')
return queryset.filter(hospital=user.hospital).select_related("hospital", "department")
# Department Managers see users in their department
if user.is_department_manager() and user.department:
return queryset.filter(department=user.department).select_related('hospital', 'department')
return queryset.filter(department=user.department).select_related("hospital", "department")
# Source Users see only themselves
if user.is_source_user():
@ -160,29 +164,23 @@ class UserViewSet(viewsets.ModelViewSet):
"""Log user creation"""
user = serializer.save()
AuditService.log_from_request(
event_type='other',
description=f"User {user.email} created",
request=self.request,
content_object=user
event_type="other", description=f"User {user.email} created", request=self.request, content_object=user
)
def perform_update(self, serializer):
"""Log user update"""
user = serializer.save()
AuditService.log_from_request(
event_type='other',
description=f"User {user.email} updated",
request=self.request,
content_object=user
event_type="other", description=f"User {user.email} updated", request=self.request, content_object=user
)
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
@action(detail=False, methods=["get"], permission_classes=[IsAuthenticated])
def me(self, request):
"""Get current user profile"""
serializer = self.get_serializer(request.user)
return Response(serializer.data)
@action(detail=False, methods=['put'], permission_classes=[IsAuthenticated])
@action(detail=False, methods=["put"], permission_classes=[IsAuthenticated])
def update_profile(self, request):
"""Update current user profile"""
serializer = UserUpdateSerializer(request.user, data=request.data, partial=True)
@ -190,76 +188,76 @@ class UserViewSet(viewsets.ModelViewSet):
serializer.save()
AuditService.log_from_request(
event_type='other',
event_type="other",
description=f"User {request.user.email} updated their profile",
request=request,
content_object=request.user
content_object=request.user,
)
return Response(UserSerializer(request.user).data)
@action(detail=False, methods=['post'], permission_classes=[IsAuthenticated])
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
def change_password(self, request):
"""Change user password"""
serializer = ChangePasswordSerializer(data=request.data, context={'request': request})
serializer = ChangePasswordSerializer(data=request.data, context={"request": request})
serializer.is_valid(raise_exception=True)
# Change password
request.user.set_password(serializer.validated_data['new_password'])
request.user.set_password(serializer.validated_data["new_password"])
request.user.save()
AuditService.log_from_request(
event_type='other',
event_type="other",
description=f"User {request.user.email} changed their password",
request=request,
content_object=request.user
content_object=request.user,
)
return Response({'message': 'Password changed successfully'}, status=status.HTTP_200_OK)
return Response({"message": "Password changed successfully"}, status=status.HTTP_200_OK)
@action(detail=True, methods=['post'], permission_classes=[IsPXAdmin])
@action(detail=True, methods=["post"], permission_classes=[IsPXAdmin])
def assign_role(self, request, pk=None):
"""Assign role to user (PX Admin only)"""
user = self.get_object()
role_id = request.data.get('role_id')
role_id = request.data.get("role_id")
try:
role = Role.objects.get(id=role_id)
user.groups.add(role.group)
AuditService.log_from_request(
event_type='role_change',
event_type="role_change",
description=f"Role {role.display_name} assigned to user {user.email}",
request=request,
content_object=user,
metadata={'role': role.name}
metadata={"role": role.name},
)
return Response({'message': f'Role {role.display_name} assigned successfully'})
return Response({"message": f"Role {role.display_name} assigned successfully"})
except Role.DoesNotExist:
return Response({'error': 'Role not found'}, status=status.HTTP_404_NOT_FOUND)
return Response({"error": "Role not found"}, status=status.HTTP_404_NOT_FOUND)
@action(detail=True, methods=['post'], permission_classes=[IsPXAdmin])
@action(detail=True, methods=["post"], permission_classes=[IsPXAdmin])
def remove_role(self, request, pk=None):
"""Remove role from user (PX Admin only)"""
user = self.get_object()
role_id = request.data.get('role_id')
role_id = request.data.get("role_id")
try:
role = Role.objects.get(id=role_id)
user.groups.remove(role.group)
AuditService.log_from_request(
event_type='role_change',
event_type="role_change",
description=f"Role {role.display_name} removed from user {user.email}",
request=request,
content_object=user,
metadata={'role': role.name}
metadata={"role": role.name},
)
return Response({'message': f'Role {role.display_name} removed successfully'})
return Response({"message": f"Role {role.display_name} removed successfully"})
except Role.DoesNotExist:
return Response({'error': 'Role not found'}, status=status.HTTP_404_NOT_FOUND)
return Response({"error": "Role not found"}, status=status.HTTP_404_NOT_FOUND)
class RoleViewSet(viewsets.ModelViewSet):
@ -270,282 +268,269 @@ class RoleViewSet(viewsets.ModelViewSet):
- List/Retrieve: Authenticated users
- Create/Update/Delete: PX Admins only
"""
queryset = Role.objects.all()
serializer_class = RoleSerializer
permission_classes = [IsPXAdminOrReadOnly]
filterset_fields = ['name', 'level']
search_fields = ['name', 'display_name', 'description']
ordering_fields = ['level', 'name']
ordering = ['-level', 'name']
filterset_fields = ["name", "level"]
search_fields = ["name", "display_name", "description"]
ordering_fields = ["level", "name"]
ordering = ["-level", "name"]
def get_queryset(self):
return super().get_queryset().select_related('group')
return super().get_queryset().select_related("group")
# ==================== Settings Views ====================
@login_required
def user_settings(request):
"""
User settings page for managing notification preferences, profile, and security.
"""
user = request.user
if request.method == 'POST':
if request.method == "POST":
# Get form type
form_type = request.POST.get('form_type', 'preferences')
if form_type == 'preferences':
form_type = request.POST.get("form_type", "preferences")
if form_type == "preferences":
# Update notification preferences
user.notification_email_enabled = request.POST.get('notification_email_enabled', 'off') == 'on'
user.notification_sms_enabled = request.POST.get('notification_sms_enabled', 'off') == 'on'
user.preferred_notification_channel = request.POST.get('preferred_notification_channel', 'email')
user.explanation_notification_channel = request.POST.get('explanation_notification_channel', 'email')
user.phone = request.POST.get('phone', '')
user.language = request.POST.get('language', 'en')
messages.success(request, _('Notification preferences updated successfully.'))
elif form_type == 'profile':
user.notification_email_enabled = request.POST.get("notification_email_enabled", "off") == "on"
user.notification_sms_enabled = request.POST.get("notification_sms_enabled", "off") == "on"
user.preferred_notification_channel = request.POST.get("preferred_notification_channel", "email")
user.explanation_notification_channel = request.POST.get("explanation_notification_channel", "email")
user.phone = request.POST.get("phone", "")
user.language = request.POST.get("language", "en")
messages.success(request, _("Notification preferences updated successfully."))
elif form_type == "profile":
# Update profile information
user.first_name = request.POST.get('first_name', '')
user.last_name = request.POST.get('last_name', '')
user.phone = request.POST.get('phone', '')
user.bio = request.POST.get('bio', '')
user.first_name = request.POST.get("first_name", "")
user.last_name = request.POST.get("last_name", "")
user.phone = request.POST.get("phone", "")
user.bio = request.POST.get("bio", "")
# Handle avatar upload
if request.FILES.get('avatar'):
user.avatar = request.FILES.get('avatar')
messages.success(request, _('Profile updated successfully.'))
elif form_type == 'password':
if request.FILES.get("avatar"):
user.avatar = request.FILES.get("avatar")
messages.success(request, _("Profile updated successfully."))
elif form_type == "password":
# Change password
current_password = request.POST.get('current_password')
new_password = request.POST.get('new_password')
confirm_password = request.POST.get('confirm_password')
current_password = request.POST.get("current_password")
new_password = request.POST.get("new_password")
confirm_password = request.POST.get("confirm_password")
if not user.check_password(current_password):
messages.error(request, _('Current password is incorrect.'))
messages.error(request, _("Current password is incorrect."))
elif new_password != confirm_password:
messages.error(request, _('New passwords do not match.'))
messages.error(request, _("New passwords do not match."))
elif len(new_password) < 8:
messages.error(request, _('Password must be at least 8 characters long.'))
messages.error(request, _("Password must be at least 8 characters long."))
else:
user.set_password(new_password)
messages.success(request, _('Password changed successfully. Please login again.'))
messages.success(request, _("Password changed successfully. Please login again."))
# Re-authenticate user with new password
from django.contrib.auth import update_session_auth_hash
update_session_auth_hash(request, user)
user.save()
# Log the update
AuditService.log_from_request(
event_type='other',
description=f"User {user.email} updated settings",
request=request,
content_object=user
event_type="other", description=f"User {user.email} updated settings", request=request, content_object=user
)
return redirect('accounts:settings')
return redirect("accounts:settings")
context = {
'user': user,
'notification_channels': [
('email', _('Email')),
('sms', _('SMS')),
('both', _('Both'))
],
'languages': [
('en', _('English')),
('ar', _('Arabic'))
]
"user": user,
"notification_channels": [("email", _("Email")), ("sms", _("SMS")), ("both", _("Both"))],
"languages": [("en", _("English")), ("ar", _("Arabic"))],
}
return render(request, 'accounts/settings.html', context)
# Add user statistics for PX Admin
if user.is_px_admin():
User = get_user_model()
context["total_users_count"] = User.objects.count()
context["active_users_count"] = User.objects.filter(is_active=True, is_provisional=False).count()
context["provisional_users_count"] = User.objects.filter(is_provisional=True).count()
context["inactive_users_count"] = User.objects.filter(is_active=False).count()
return render(request, "accounts/settings.html", context)
# ==================== Onboarding ViewSets ====================
class AcknowledgementContentViewSet(viewsets.ModelViewSet):
"""
ViewSet for AcknowledgementContent model.
Permissions:
- List/Retrieve: Authenticated users
- Create/Update/Delete: PX Admins only
"""
queryset = AcknowledgementContent.objects.all()
serializer_class = AcknowledgementContentSerializer
permission_classes = [CanManageAcknowledgementContent]
filterset_fields = ['role', 'is_active']
search_fields = ['code', 'title_en', 'title_ar', 'description_en', 'description_ar']
ordering_fields = ['role', 'order']
ordering = ['role', 'order']
filterset_fields = ["role", "is_active"]
search_fields = ["code", "title_en", "title_ar", "description_en", "description_ar"]
ordering_fields = ["role", "order"]
ordering = ["role", "order"]
class AcknowledgementChecklistItemViewSet(viewsets.ModelViewSet):
"""
ViewSet for AcknowledgementChecklistItem model.
Permissions:
- List/Retrieve: Authenticated users
- Create/Update/Delete: PX Admins only
"""
queryset = AcknowledgementChecklistItem.objects.all()
serializer_class = AcknowledgementChecklistItemSerializer
permission_classes = [CanManageAcknowledgementContent]
filterset_fields = ['role', 'content', 'is_required', 'is_active']
search_fields = ['code', 'text_en', 'text_ar', 'description_en', 'description_ar']
ordering_fields = ['role', 'order']
ordering = ['role', 'order']
filterset_fields = ["role", "content", "is_required", "is_active"]
search_fields = ["code", "text_en", "text_ar", "description_en", "description_ar"]
ordering_fields = ["role", "order"]
ordering = ["role", "order"]
def get_queryset(self):
return super().get_queryset().select_related('content')
return super().get_queryset().select_related("content")
class UserAcknowledgementViewSet(viewsets.ReadOnlyModelViewSet):
"""
ViewSet for UserAcknowledgement model.
Permissions:
- Users can view their own acknowledgements
- PX Admins can view all
"""
queryset = UserAcknowledgement.objects.all()
serializer_class = UserAcknowledgementSerializer
permission_classes = [IsOnboardingOwnerOrAdmin]
filterset_fields = ['user', 'checklist_item', 'is_acknowledged']
ordering_fields = ['-acknowledged_at']
ordering = ['-acknowledged_at']
filterset_fields = ["user", "checklist_item", "is_acknowledged"]
ordering_fields = ["-acknowledged_at"]
ordering = ["-acknowledged_at"]
def get_queryset(self):
queryset = super().get_queryset()
user = self.request.user
# PX Admins see all
if user.is_px_admin():
return queryset.select_related('user', 'checklist_item')
return queryset.select_related("user", "checklist_item")
# Others see only their own
return queryset.filter(user=user).select_related('user', 'checklist_item')
@action(detail=True, methods=['get'], permission_classes=[IsAuthenticated])
return queryset.filter(user=user).select_related("user", "checklist_item")
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
def download_pdf(self, request, pk=None):
"""
Download PDF for a specific acknowledgement
"""
from django.http import FileResponse, Http404
import os
acknowledgement = self.get_object()
# Check if PDF exists
if not acknowledgement.pdf_file:
return Response(
{'error': 'PDF not available for this acknowledgement'},
status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "PDF not available for this acknowledgement"}, status=status.HTTP_404_NOT_FOUND)
# Check file exists
if not os.path.exists(acknowledgement.pdf_file.path):
return Response(
{'error': 'PDF file not found'},
status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "PDF file not found"}, status=status.HTTP_404_NOT_FOUND)
# Return file
try:
response = FileResponse(
open(acknowledgement.pdf_file.path, 'rb'),
content_type='application/pdf'
)
response = FileResponse(open(acknowledgement.pdf_file.path, "rb"), content_type="application/pdf")
# Generate filename
filename = f"acknowledgement_{acknowledgement.id}_{acknowledgement.user.employee_id or acknowledgement.user.username}.pdf"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
except Exception as e:
return Response(
{'error': f'Error downloading PDF: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({"error": f"Error downloading PDF: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# ==================== Onboarding Actions for UserViewSet ====================
def onboarding_create_provisional(self, request):
"""Create provisional user"""
from .services import OnboardingService, EmailService
serializer = ProvisionalUserSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Create provisional user
user_data = serializer.validated_data.copy()
roles = user_data.pop('roles', [])
roles = user_data.pop("roles", [])
user = OnboardingService.create_provisional_user(user_data)
# Assign roles
for role_name in roles:
from .models import Role as RoleModel
try:
role = RoleModel.objects.get(name=role_name)
user.groups.add(role.group)
except RoleModel.DoesNotExist:
pass
# Send invitation email
EmailService.send_invitation_email(user, request)
return Response(
UserSerializer(user).data,
status=status.HTTP_201_CREATED
)
return Response(UserSerializer(user).data, status=status.HTTP_201_CREATED)
def onboarding_resend_invitation(self, request, pk=None):
"""Resend invitation email"""
from .services import EmailService
user = self.get_object()
if not user.is_provisional:
return Response(
{'error': 'User is not a provisional user'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "User is not a provisional user"}, status=status.HTTP_400_BAD_REQUEST)
EmailService.send_reminder_email(user, request)
return Response({'message': 'Invitation email resent successfully'})
return Response({"message": "Invitation email resent successfully"})
def onboarding_progress(self, request):
"""Get current user's onboarding progress"""
from .services import OnboardingService
user = request.user
# Get checklist items
required_items = OnboardingService.get_checklist_items(user).filter(is_required=True)
acknowledged_items = UserAcknowledgement.objects.filter(
user=user,
checklist_item__in=required_items,
is_acknowledged=True
user=user, checklist_item__in=required_items, is_acknowledged=True
)
progress = {
'current_step': user.current_wizard_step,
'completed_steps': user.wizard_completed_steps,
'progress_percentage': OnboardingService.get_user_progress_percentage(user),
'total_required_items': required_items.count(),
'acknowledged_items': acknowledged_items.count()
"current_step": user.current_wizard_step,
"completed_steps": user.wizard_completed_steps,
"progress_percentage": OnboardingService.get_user_progress_percentage(user),
"total_required_items": required_items.count(),
"acknowledged_items": acknowledged_items.count(),
}
serializer = WizardProgressSerializer(progress)
return Response(serializer.data)
@ -553,7 +538,7 @@ def onboarding_progress(self, request):
def onboarding_content(self, request):
"""Get wizard content for current user"""
from .services import OnboardingService
content = OnboardingService.get_wizard_content(request.user)
serializer = AcknowledgementContentSerializer(content, many=True)
return Response(serializer.data)
@ -562,156 +547,125 @@ def onboarding_content(self, request):
def onboarding_checklist(self, request):
"""Get checklist items for current user"""
from .services import OnboardingService
items = OnboardingService.get_checklist_items(request.user)
# Include acknowledgement status
from django.db import models
acknowledged_ids = UserAcknowledgement.objects.filter(
user=request.user,
is_acknowledged=True
).values_list('checklist_item_id', flat=True)
acknowledged_ids = UserAcknowledgement.objects.filter(user=request.user, is_acknowledged=True).values_list(
"checklist_item_id", flat=True
)
data = []
for item in items:
item_data = AcknowledgementChecklistItemSerializer(item).data
item_data['is_acknowledged'] = item.id in acknowledged_ids
item_data["is_acknowledged"] = item.id in acknowledged_ids
data.append(item_data)
return Response(data)
def onboarding_acknowledge(self, request):
"""Acknowledge a checklist item"""
from .services import OnboardingService
serializer = AcknowledgeItemSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
item = AcknowledgementChecklistItem.objects.get(
id=serializer.validated_data['checklist_item_id']
)
item = AcknowledgementChecklistItem.objects.get(id=serializer.validated_data["checklist_item_id"])
except AcknowledgementChecklistItem.DoesNotExist:
return Response(
{'error': 'Checklist item not found'},
status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Checklist item not found"}, status=status.HTTP_404_NOT_FOUND)
# Acknowledge item
OnboardingService.acknowledge_item(
request.user,
item,
signature=serializer.validated_data.get('signature', ''),
request=request
request.user, item, signature=serializer.validated_data.get("signature", ""), request=request
)
return Response({'message': 'Item acknowledged successfully'})
return Response({"message": "Item acknowledged successfully"})
def onboarding_complete(self, request):
"""Complete wizard and activate account"""
from .services import OnboardingService, EmailService
serializer = AccountActivationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Complete wizard
success = OnboardingService.complete_wizard(
request.user,
serializer.validated_data['username'],
serializer.validated_data['password'],
serializer.validated_data['signature'],
request=request
serializer.validated_data["username"],
serializer.validated_data["password"],
serializer.validated_data["signature"],
request=request,
)
if not success:
return Response(
{'error': 'Failed to complete wizard. Please ensure all required items are acknowledged.'},
status=status.HTTP_400_BAD_REQUEST
{"error": "Failed to complete wizard. Please ensure all required items are acknowledged."},
status=status.HTTP_400_BAD_REQUEST,
)
# Notify admins
from django.contrib.auth import get_user_model
User = get_user_model()
admin_users = User.objects.filter(groups__name='PX Admin')
admin_users = User.objects.filter(groups__name="PX Admin")
EmailService.send_completion_notification(request.user, admin_users, request)
return Response({'message': 'Account activated successfully'})
return Response({"message": "Account activated successfully"})
def onboarding_status(self, request, pk=None):
"""Get onboarding status for a specific user"""
user = self.get_object()
status_data = {
'user': UserSerializer(user).data,
'is_provisional': user.is_provisional,
'acknowledgement_completed': user.acknowledgement_completed,
'acknowledgement_completed_at': user.acknowledgement_completed_at,
'current_wizard_step': user.current_wizard_step,
'invitation_expires_at': user.invitation_expires_at,
'progress_percentage': user.get_onboarding_progress_percentage()
"user": UserSerializer(user).data,
"is_provisional": user.is_provisional,
"acknowledgement_completed": user.acknowledgement_completed,
"acknowledgement_completed_at": user.acknowledgement_completed_at,
"current_wizard_step": user.current_wizard_step,
"invitation_expires_at": user.invitation_expires_at,
"progress_percentage": user.get_onboarding_progress_percentage(),
}
return Response(status_data)
# Add onboarding actions to UserViewSet with proper function names
UserViewSet.onboarding_create_provisional = action(
detail=False,
methods=['post'],
permission_classes=[CanManageOnboarding],
url_path='onboarding/create-provisional'
detail=False, methods=["post"], permission_classes=[CanManageOnboarding], url_path="onboarding/create-provisional"
)(onboarding_create_provisional)
UserViewSet.onboarding_resend_invitation = action(
detail=True,
methods=['post'],
permission_classes=[CanManageOnboarding],
url_path='onboarding/resend-invitation'
detail=True, methods=["post"], permission_classes=[CanManageOnboarding], url_path="onboarding/resend-invitation"
)(onboarding_resend_invitation)
UserViewSet.onboarding_progress = action(
detail=False,
methods=['get'],
permission_classes=[IsProvisionalUser],
url_path='onboarding/progress'
detail=False, methods=["get"], permission_classes=[IsProvisionalUser], url_path="onboarding/progress"
)(onboarding_progress)
UserViewSet.onboarding_content = action(
detail=False,
methods=['get'],
permission_classes=[IsProvisionalUser],
url_path='onboarding/content'
detail=False, methods=["get"], permission_classes=[IsProvisionalUser], url_path="onboarding/content"
)(onboarding_content)
UserViewSet.onboarding_checklist = action(
detail=False,
methods=['get'],
permission_classes=[IsProvisionalUser],
url_path='onboarding/checklist'
detail=False, methods=["get"], permission_classes=[IsProvisionalUser], url_path="onboarding/checklist"
)(onboarding_checklist)
UserViewSet.onboarding_acknowledge = action(
detail=False,
methods=['post'],
permission_classes=[IsProvisionalUser],
url_path='onboarding/acknowledge'
detail=False, methods=["post"], permission_classes=[IsProvisionalUser], url_path="onboarding/acknowledge"
)(onboarding_acknowledge)
UserViewSet.onboarding_complete = action(
detail=False,
methods=['post'],
permission_classes=[IsProvisionalUser],
url_path='onboarding/complete'
detail=False, methods=["post"], permission_classes=[IsProvisionalUser], url_path="onboarding/complete"
)(onboarding_complete)
UserViewSet.onboarding_status = action(
detail=True,
methods=['get'],
permission_classes=[CanViewOnboarding],
url_path='onboarding/status'
detail=True, methods=["get"], permission_classes=[CanViewOnboarding], url_path="onboarding/status"
)(onboarding_status)

View File

@ -1,6 +1,7 @@
"""
Analytics Console UI views
"""
from datetime import datetime
from django.contrib.auth.decorators import login_required
@ -25,14 +26,14 @@ import json
def serialize_queryset_values(queryset):
"""Properly serialize QuerySet values to JSON string."""
if queryset is None:
return '[]'
return "[]"
data = list(queryset)
result = []
for item in data:
row = {}
for key, value in item.items():
# Convert UUID to string
if hasattr(value, 'hex'): # UUID object
if hasattr(value, "hex"): # UUID object
row[key] = str(value)
# Convert Python None to JavaScript null
elif value is None:
@ -60,11 +61,11 @@ def analytics_dashboard(request):
from django.utils import timezone
from datetime import timedelta
from django.db.models.functions import TruncDate, TruncMonth
user = request.user
# Get hospital filter
hospital_filter = request.GET.get('hospital')
hospital_filter = request.GET.get("hospital")
if hospital_filter:
hospital = Hospital.objects.filter(id=hospital_filter).first()
elif user.hospital:
@ -75,7 +76,7 @@ def analytics_dashboard(request):
# Base querysets
complaints_queryset = Complaint.objects.all()
actions_queryset = PXAction.objects.all()
surveys_queryset = SurveyInstance.objects.filter(status='completed')
surveys_queryset = SurveyInstance.objects.filter(status="completed")
feedback_queryset = Feedback.objects.all()
if hospital:
@ -86,117 +87,128 @@ def analytics_dashboard(request):
# ============ COMPLAINTS KPIs ============
total_complaints = complaints_queryset.count()
open_complaints = complaints_queryset.filter(status='open').count()
in_progress_complaints = complaints_queryset.filter(status='in_progress').count()
resolved_complaints = complaints_queryset.filter(status='resolved').count()
closed_complaints = complaints_queryset.filter(status='closed').count()
open_complaints = complaints_queryset.filter(status="open").count()
in_progress_complaints = complaints_queryset.filter(status="in_progress").count()
resolved_complaints = complaints_queryset.filter(status="resolved").count()
closed_complaints = complaints_queryset.filter(status="closed").count()
overdue_complaints = complaints_queryset.filter(is_overdue=True).count()
# Complaint sources
complaint_sources = complaints_queryset.values('source').annotate(count=Count('id')).order_by('-count')[:6]
complaint_sources = complaints_queryset.values("source").annotate(count=Count("id")).order_by("-count")[:6]
# Complaint domains (Level 1)
top_domains = complaints_queryset.filter(domain__isnull=False).values('domain__name_en').annotate(count=Count('id')).order_by('-count')[:5]
top_domains = (
complaints_queryset.filter(domain__isnull=False)
.values("domain__name_en")
.annotate(count=Count("id"))
.order_by("-count")[:5]
)
# Complaint categories (Level 2)
top_categories = complaints_queryset.filter(category__isnull=False).values('category__name_en').annotate(count=Count('id')).order_by('-count')[:5]
top_categories = (
complaints_queryset.filter(category__isnull=False)
.values("category__name_en")
.annotate(count=Count("id"))
.order_by("-count")[:5]
)
# Complaint severity
severity_breakdown = complaints_queryset.values('severity').annotate(count=Count('id')).order_by('-count')
severity_breakdown = complaints_queryset.values("severity").annotate(count=Count("id")).order_by("-count")
# Status breakdown
status_breakdown = complaints_queryset.values('status').annotate(count=Count('id')).order_by('-count')
status_breakdown = complaints_queryset.values("status").annotate(count=Count("id")).order_by("-count")
# ============ ACTIONS KPIs ============
total_actions = actions_queryset.count()
open_actions = actions_queryset.filter(status='open').count()
in_progress_actions = actions_queryset.filter(status='in_progress').count()
approved_actions = actions_queryset.filter(status='approved').count()
closed_actions = actions_queryset.filter(status='closed').count()
open_actions = actions_queryset.filter(status="open").count()
in_progress_actions = actions_queryset.filter(status="in_progress").count()
approved_actions = actions_queryset.filter(status="approved").count()
closed_actions = actions_queryset.filter(status="closed").count()
overdue_actions = actions_queryset.filter(is_overdue=True).count()
# Action sources
action_sources = actions_queryset.values('source_type').annotate(count=Count('id')).order_by('-count')[:6]
action_sources = actions_queryset.values("source_type").annotate(count=Count("id")).order_by("-count")[:6]
# Action categories
action_categories = actions_queryset.exclude(category='').values('category').annotate(count=Count('id')).order_by('-count')[:5]
action_categories = (
actions_queryset.exclude(category="").values("category").annotate(count=Count("id")).order_by("-count")[:5]
)
# ============ SURVEYS KPIs ============
total_surveys = surveys_queryset.count()
avg_survey_score = surveys_queryset.aggregate(avg=Avg('total_score'))['avg'] or 0
avg_survey_score = surveys_queryset.aggregate(avg=Avg("total_score"))["avg"] or 0
negative_surveys = surveys_queryset.filter(is_negative=True).count()
# Survey completion rate
all_surveys = SurveyInstance.objects.all()
if hospital:
all_surveys = all_surveys.filter(survey_template__hospital=hospital)
total_sent = all_surveys.count()
completed_surveys = all_surveys.filter(status='completed').count()
completed_surveys = all_surveys.filter(status="completed").count()
completion_rate = (completed_surveys / total_sent * 100) if total_sent > 0 else 0
# Survey types
survey_types = all_surveys.values('survey_template__survey_type').annotate(count=Count('id')).order_by('-count')[:5]
survey_types = all_surveys.values("survey_template__survey_type").annotate(count=Count("id")).order_by("-count")[:5]
# ============ FEEDBACK KPIs ============
total_feedback = feedback_queryset.count()
compliments = feedback_queryset.filter(feedback_type='compliment').count()
suggestions = feedback_queryset.filter(feedback_type='suggestion').count()
compliments = feedback_queryset.filter(feedback_type="compliment").count()
suggestions = feedback_queryset.filter(feedback_type="suggestion").count()
# Sentiment analysis
sentiment_breakdown = feedback_queryset.values('sentiment').annotate(count=Count('id')).order_by('-count')
sentiment_breakdown = feedback_queryset.values("sentiment").annotate(count=Count("id")).order_by("-count")
# Feedback categories
feedback_categories = feedback_queryset.values('category').annotate(count=Count('id')).order_by('-count')[:5]
feedback_categories = feedback_queryset.values("category").annotate(count=Count("id")).order_by("-count")[:5]
# Average rating
avg_rating = feedback_queryset.filter(rating__isnull=False).aggregate(avg=Avg('rating'))['avg'] or 0
avg_rating = feedback_queryset.filter(rating__isnull=False).aggregate(avg=Avg("rating"))["avg"] or 0
# ============ TRENDS (Last 30 days) ============
thirty_days_ago = timezone.now() - timedelta(days=30)
# Complaint trends
complaint_trend = complaints_queryset.filter(
created_at__gte=thirty_days_ago
).annotate(
day=TruncDate('created_at')
).values('day').annotate(count=Count('id')).order_by('day')
complaint_trend = (
complaints_queryset.filter(created_at__gte=thirty_days_ago)
.annotate(day=TruncDate("created_at"))
.values("day")
.annotate(count=Count("id"))
.order_by("day")
)
# Survey score trend
survey_score_trend = surveys_queryset.filter(
completed_at__gte=thirty_days_ago
).annotate(
day=TruncDate('completed_at')
).values('day').annotate(avg_score=Avg('total_score')).order_by('day')
survey_score_trend = (
surveys_queryset.filter(completed_at__gte=thirty_days_ago)
.annotate(day=TruncDate("completed_at"))
.values("day")
.annotate(avg_score=Avg("total_score"))
.order_by("day")
)
# ============ DEPARTMENT RANKINGS ============
department_rankings = Department.objects.filter(
status='active'
).annotate(
avg_score=Avg(
'journey_instances__surveys__total_score',
filter=Q(journey_instances__surveys__status='completed')
),
survey_count=Count(
'journey_instances__surveys',
filter=Q(journey_instances__surveys__status='completed')
),
complaint_count=Count('complaints'),
action_count=Count('px_actions')
).filter(
survey_count__gt=0
).order_by('-avg_score')[:7]
department_rankings = (
Department.objects.filter(status="active")
.annotate(
avg_score=Avg(
"journey_instances__surveys__total_score", filter=Q(journey_instances__surveys__status="completed")
),
survey_count=Count("journey_instances__surveys", filter=Q(journey_instances__surveys__status="completed")),
complaint_count=Count("complaints"),
action_count=Count("px_actions"),
)
.filter(survey_count__gt=0)
.order_by("-avg_score")[:7]
)
# ============ TIME-BASED CALCULATIONS ============
# Average resolution time (complaints)
resolved_with_time = complaints_queryset.filter(
status__in=['resolved', 'closed'],
resolved_at__isnull=False,
created_at__isnull=False
status__in=["resolved", "closed"], resolved_at__isnull=False, created_at__isnull=False
)
if resolved_with_time.exists():
avg_resolution_hours = resolved_with_time.annotate(
resolution_time=F('resolved_at') - F('created_at')
).aggregate(avg=Avg('resolution_time'))['avg']
resolution_time=F("resolved_at") - F("created_at")
).aggregate(avg=Avg("resolution_time"))["avg"]
if avg_resolution_hours:
avg_resolution_hours = avg_resolution_hours.total_seconds() / 3600
else:
@ -206,14 +218,12 @@ def analytics_dashboard(request):
# Average action completion time
closed_actions_with_time = actions_queryset.filter(
status='closed',
closed_at__isnull=False,
created_at__isnull=False
status="closed", closed_at__isnull=False, created_at__isnull=False
)
if closed_actions_with_time.exists():
avg_action_days = closed_actions_with_time.annotate(
completion_time=F('closed_at') - F('created_at')
).aggregate(avg=Avg('completion_time'))['avg']
avg_action_days = closed_actions_with_time.annotate(completion_time=F("closed_at") - F("created_at")).aggregate(
avg=Avg("completion_time")
)["avg"]
if avg_action_days:
avg_action_days = avg_action_days.days
else:
@ -224,17 +234,13 @@ def analytics_dashboard(request):
# ============ SLA COMPLIANCE ============
total_with_sla = complaints_queryset.filter(due_at__isnull=False).count()
resolved_within_sla = complaints_queryset.filter(
status__in=['resolved', 'closed'],
resolved_at__lte=F('due_at')
status__in=["resolved", "closed"], resolved_at__lte=F("due_at")
).count()
sla_compliance = (resolved_within_sla / total_with_sla * 100) if total_with_sla > 0 else 0
# ============ NPS CALCULATION ============
# NPS = % Promoters (9-10) - % Detractors (0-6)
nps_surveys = surveys_queryset.filter(
survey_template__survey_type='nps',
total_score__isnull=False
)
nps_surveys = surveys_queryset.filter(survey_template__survey_type="nps", total_score__isnull=False)
if nps_surveys.exists():
promoters = nps_surveys.filter(total_score__gte=9).count()
detractors = nps_surveys.filter(total_score__lte=6).count()
@ -243,76 +249,52 @@ def analytics_dashboard(request):
else:
nps_score = 0
# Get hospitals for filter
hospitals = Hospital.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
# Build comprehensive KPI data
kpis = {
# Complaints
'total_complaints': total_complaints,
'open_complaints': open_complaints,
'in_progress_complaints': in_progress_complaints,
'resolved_complaints': resolved_complaints,
'closed_complaints': closed_complaints,
'overdue_complaints': overdue_complaints,
'avg_resolution_hours': round(avg_resolution_hours, 1),
'sla_compliance': round(sla_compliance, 1),
# Actions
'total_actions': total_actions,
'open_actions': open_actions,
'in_progress_actions': in_progress_actions,
'approved_actions': approved_actions,
'closed_actions': closed_actions,
'overdue_actions': overdue_actions,
'avg_action_days': round(avg_action_days, 1),
# Surveys
'total_surveys': total_surveys,
'avg_survey_score': round(avg_survey_score, 2),
'nps_score': round(nps_score, 1),
'negative_surveys': negative_surveys,
'completion_rate': round(completion_rate, 1),
# Feedback
'total_feedback': total_feedback,
'compliments': compliments,
'suggestions': suggestions,
'avg_rating': round(avg_rating, 2),
"total_complaints": total_complaints,
"open_complaints": open_complaints,
"in_progress_complaints": in_progress_complaints,
"resolved_complaints": resolved_complaints,
"closed_complaints": closed_complaints,
"overdue_complaints": overdue_complaints,
"avg_resolution_hours": round(avg_resolution_hours, 1),
"sla_compliance": round(sla_compliance, 1),
"total_actions": total_actions,
"open_actions": open_actions,
"in_progress_actions": in_progress_actions,
"approved_actions": approved_actions,
"closed_actions": closed_actions,
"overdue_actions": overdue_actions,
"avg_action_days": round(avg_action_days, 1),
"total_surveys": total_surveys,
"avg_survey_score": round(avg_survey_score, 2),
"nps_score": round(nps_score, 1),
"negative_surveys": negative_surveys,
"completion_rate": round(completion_rate, 1),
"total_feedback": total_feedback,
"compliments": compliments,
"suggestions": suggestions,
"avg_rating": round(avg_rating, 2),
}
context = {
'kpis': kpis,
'hospitals': hospitals,
'selected_hospital': hospital,
# Complaint analytics - serialize properly for JSON
'complaint_sources': serialize_queryset_values(complaint_sources),
'top_domains': serialize_queryset_values(top_domains),
'top_categories': serialize_queryset_values(top_categories),
'severity_breakdown': serialize_queryset_values(severity_breakdown),
'status_breakdown': serialize_queryset_values(status_breakdown),
'complaint_trend': serialize_queryset_values(complaint_trend),
# Action analytics
'action_sources': serialize_queryset_values(action_sources),
'action_categories': serialize_queryset_values(action_categories),
# Survey analytics
'survey_types': serialize_queryset_values(survey_types),
'survey_score_trend': serialize_queryset_values(survey_score_trend),
# Feedback analytics
'sentiment_breakdown': serialize_queryset_values(sentiment_breakdown),
'feedback_categories': serialize_queryset_values(feedback_categories),
# Department rankings
'department_rankings': department_rankings,
"kpis": kpis,
"selected_hospital": hospital,
"complaint_sources": serialize_queryset_values(complaint_sources),
"top_domains": serialize_queryset_values(top_domains),
"top_categories": serialize_queryset_values(top_categories),
"severity_breakdown": serialize_queryset_values(severity_breakdown),
"status_breakdown": serialize_queryset_values(status_breakdown),
"complaint_trend": serialize_queryset_values(complaint_trend),
"action_sources": serialize_queryset_values(action_sources),
"action_categories": serialize_queryset_values(action_categories),
"survey_types": serialize_queryset_values(survey_types),
"survey_score_trend": serialize_queryset_values(survey_score_trend),
"sentiment_breakdown": serialize_queryset_values(sentiment_breakdown),
"feedback_categories": serialize_queryset_values(feedback_categories),
"department_rankings": department_rankings,
}
return render(request, 'analytics/dashboard.html', context)
return render(request, "analytics/dashboard.html", context)
@block_source_user
@ -320,34 +302,34 @@ def analytics_dashboard(request):
def kpi_list(request):
"""KPI definitions list view"""
queryset = KPI.objects.all()
# Apply filters
category_filter = request.GET.get('category')
category_filter = request.GET.get("category")
if category_filter:
queryset = queryset.filter(category=category_filter)
is_active = request.GET.get('is_active')
if is_active == 'true':
is_active = request.GET.get("is_active")
if is_active == "true":
queryset = queryset.filter(is_active=True)
elif is_active == 'false':
elif is_active == "false":
queryset = queryset.filter(is_active=False)
# Ordering
queryset = queryset.order_by('category', 'name')
queryset = queryset.order_by("category", "name")
# Pagination
page_size = int(request.GET.get('page_size', 25))
page_size = int(request.GET.get("page_size", 25))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get('page', 1)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
context = {
'page_obj': page_obj,
'kpis': page_obj.object_list,
'filters': request.GET,
"page_obj": page_obj,
"kpis": page_obj.object_list,
"filters": request.GET,
}
return render(request, 'analytics/kpi_list.html', context)
return render(request, "analytics/kpi_list.html", context)
@block_source_user
@ -355,7 +337,7 @@ def kpi_list(request):
def command_center(request):
"""
PX Command Center - Unified Dashboard
Comprehensive dashboard showing all PX360 metrics:
- Complaints, Surveys, Actions KPIs
- Interactive charts with ApexCharts
@ -363,53 +345,52 @@ def command_center(request):
- Export to Excel/PDF
"""
user = request.user
# Get filter parameters
filters = {
'date_range': request.GET.get('date_range', '30d'),
'hospital': request.GET.get('hospital', ''),
'department': request.GET.get('department', ''),
'kpi_category': request.GET.get('kpi_category', ''),
'custom_start': request.GET.get('custom_start', ''),
'custom_end': request.GET.get('custom_end', ''),
"date_range": request.GET.get("date_range", "30d"),
"hospital": request.GET.get("hospital", ""),
"department": request.GET.get("department", ""),
"kpi_category": request.GET.get("kpi_category", ""),
"custom_start": request.GET.get("custom_start", ""),
"custom_end": request.GET.get("custom_end", ""),
}
# Get hospitals for filter
hospitals = Hospital.objects.filter(status='active')
hospitals = Hospital.objects.filter(status="active")
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
# Get departments for filter
departments = Department.objects.filter(status='active')
if filters.get('hospital'):
departments = departments.filter(hospital_id=filters['hospital'])
departments = Department.objects.filter(status="active")
if filters.get("hospital"):
departments = departments.filter(hospital_id=filters["hospital"])
elif not user.is_px_admin() and user.hospital:
departments = departments.filter(hospital=user.hospital)
# Get initial KPIs
custom_start = None
custom_end = None
if filters['custom_start'] and filters['custom_end']:
custom_start = datetime.strptime(filters['custom_start'], '%Y-%m-%d')
custom_end = datetime.strptime(filters['custom_end'], '%Y-%m-%d')
if filters["custom_start"] and filters["custom_end"]:
custom_start = datetime.strptime(filters["custom_start"], "%Y-%m-%d")
custom_end = datetime.strptime(filters["custom_end"], "%Y-%m-%d")
kpis = UnifiedAnalyticsService.get_all_kpis(
user=user,
date_range=filters['date_range'],
hospital_id=filters['hospital'] if filters['hospital'] else None,
department_id=filters['department'] if filters['department'] else None,
date_range=filters["date_range"],
hospital_id=filters["hospital"] if filters["hospital"] else None,
department_id=filters["department"] if filters["department"] else None,
custom_start=custom_start,
custom_end=custom_end
custom_end=custom_end,
)
context = {
'filters': filters,
'hospitals': hospitals,
'departments': departments,
'kpis': kpis,
"filters": filters,
"departments": departments,
"kpis": kpis,
}
return render(request, 'analytics/command_center.html', context)
return render(request, "analytics/command_center.html", context)
@block_source_user
@ -417,39 +398,39 @@ def command_center(request):
def command_center_api(request):
"""
API endpoint for Command Center data
Returns JSON data for KPIs, charts, and tables based on filters.
Used by JavaScript to dynamically update dashboard.
"""
if request.method != 'GET':
return JsonResponse({'error': 'Only GET requests allowed'}, status=405)
if request.method != "GET":
return JsonResponse({"error": "Only GET requests allowed"}, status=405)
user = request.user
# Get filter parameters
date_range = request.GET.get('date_range', '30d')
hospital_id = request.GET.get('hospital')
department_id = request.GET.get('department')
kpi_category = request.GET.get('kpi_category')
custom_start_str = request.GET.get('custom_start')
custom_end_str = request.GET.get('custom_end')
date_range = request.GET.get("date_range", "30d")
hospital_id = request.GET.get("hospital")
department_id = request.GET.get("department")
kpi_category = request.GET.get("kpi_category")
custom_start_str = request.GET.get("custom_start")
custom_end_str = request.GET.get("custom_end")
# Parse custom dates
custom_start = None
custom_end = None
if custom_start_str and custom_end_str:
try:
custom_start = datetime.strptime(custom_start_str, '%Y-%m-%d')
custom_end = datetime.strptime(custom_end_str, '%Y-%m-%d')
custom_start = datetime.strptime(custom_start_str, "%Y-%m-%d")
custom_end = datetime.strptime(custom_end_str, "%Y-%m-%d")
except ValueError:
pass
# Handle hospital_id (can be integer or UUID string)
hospital_id = hospital_id if hospital_id else None
# Handle department_id (UUID string)
department_id = department_id if department_id else None
# Get KPIs
kpis = UnifiedAnalyticsService.get_all_kpis(
user=user,
@ -458,51 +439,62 @@ def command_center_api(request):
department_id=department_id,
kpi_category=kpi_category,
custom_start=custom_start,
custom_end=custom_end
custom_end=custom_end,
)
# Ensure numeric KPIs are proper Python types for JSON serialization
numeric_kpis = [
'total_complaints', 'open_complaints', 'overdue_complaints',
'high_severity_complaints', 'resolved_complaints',
'total_actions', 'open_actions', 'overdue_actions', 'escalated_actions', 'resolved_actions',
'total_surveys', 'negative_surveys', 'avg_survey_score',
'negative_social_mentions', 'low_call_ratings', 'total_sentiment_analyses'
"total_complaints",
"open_complaints",
"overdue_complaints",
"high_severity_complaints",
"resolved_complaints",
"total_actions",
"open_actions",
"overdue_actions",
"escalated_actions",
"resolved_actions",
"total_surveys",
"negative_surveys",
"avg_survey_score",
"negative_social_mentions",
"low_call_ratings",
"total_sentiment_analyses",
]
for key in numeric_kpis:
if key in kpis:
value = kpis[key]
if value is None:
kpis[key] = 0.0 if key == 'avg_survey_score' else 0
kpis[key] = 0.0 if key == "avg_survey_score" else 0
elif isinstance(value, (int, float)):
# Already a number - ensure floats for specific fields
if key == 'avg_survey_score':
if key == "avg_survey_score":
kpis[key] = float(value)
else:
# Try to convert to number
try:
kpis[key] = float(value)
except (ValueError, TypeError):
kpis[key] = 0.0 if key == 'avg_survey_score' else 0
kpis[key] = 0.0 if key == "avg_survey_score" else 0
# Handle nested trend data
if 'complaints_trend' in kpis and isinstance(kpis['complaints_trend'], dict):
trend = kpis['complaints_trend']
trend['current'] = int(trend.get('current', 0))
trend['previous'] = int(trend.get('previous', 0))
trend['percentage_change'] = float(trend.get('percentage_change', 0))
if "complaints_trend" in kpis and isinstance(kpis["complaints_trend"], dict):
trend = kpis["complaints_trend"]
trend["current"] = int(trend.get("current", 0))
trend["previous"] = int(trend.get("previous", 0))
trend["percentage_change"] = float(trend.get("percentage_change", 0))
# Get chart data
chart_types = [
'complaints_trend',
'complaints_by_category',
'survey_satisfaction_trend',
'survey_distribution',
'department_performance',
'physician_leaderboard'
"complaints_trend",
"complaints_by_category",
"survey_satisfaction_trend",
"survey_distribution",
"department_performance",
"physician_leaderboard",
]
charts = {}
for chart_type in chart_types:
charts[chart_type] = UnifiedAnalyticsService.get_chart_data(
@ -512,64 +504,60 @@ def command_center_api(request):
hospital_id=hospital_id,
department_id=department_id,
custom_start=custom_start,
custom_end=custom_end
custom_end=custom_end,
)
# Get table data
tables = {}
# Overdue complaints table
complaints_qs = Complaint.objects.filter(is_overdue=True)
if hospital_id:
complaints_qs = complaints_qs.filter(hospital_id=hospital_id)
if department_id:
complaints_qs = complaints_qs.filter(department_id=department_id)
# Apply role-based filtering
if not user.is_px_admin() and user.hospital:
complaints_qs = complaints_qs.filter(hospital=user.hospital)
if user.is_department_manager() and user.department:
complaints_qs = complaints_qs.filter(department=user.department)
tables['overdue_complaints'] = list(
complaints_qs.select_related('hospital', 'department', 'patient', 'source')
.order_by('due_at')[:20]
tables["overdue_complaints"] = list(
complaints_qs.select_related("hospital", "department", "patient", "source")
.order_by("due_at")[:20]
.values(
'id',
'title',
'severity',
'due_at',
'complaint_source_type',
hospital_name=F('hospital__name'),
department_name=F('department__name'),
patient_full_name=Concat('patient__first_name', Value(' '), 'patient__last_name'),
source_name=F('source__name_en'),
assigned_to_full_name=Concat('assigned_to__first_name', Value(' '), 'assigned_to__last_name')
"id",
"title",
"severity",
"due_at",
"complaint_source_type",
hospital_name=F("hospital__name"),
department_name=F("department__name"),
patient_full_name=Concat("patient__first_name", Value(" "), "patient__last_name"),
source_name=F("source__name_en"),
assigned_to_full_name=Concat("assigned_to__first_name", Value(" "), "assigned_to__last_name"),
)
)
# Physician leaderboard table
physician_data = charts.get('physician_leaderboard', {}).get('metadata', [])
tables['physician_leaderboard'] = [
physician_data = charts.get("physician_leaderboard", {}).get("metadata", [])
tables["physician_leaderboard"] = [
{
'physician_id': p['physician_id'],
'name': p['name'],
'specialization': p['specialization'],
'department': p['department'],
'rating': float(p['rating']) if p['rating'] is not None else 0.0,
'surveys': int(p['surveys']) if p['surveys'] is not None else 0,
'positive': int(p['positive']) if p['positive'] is not None else 0,
'neutral': int(p['neutral']) if p['neutral'] is not None else 0,
'negative': int(p['negative']) if p['negative'] is not None else 0
"physician_id": p["physician_id"],
"name": p["name"],
"specialization": p["specialization"],
"department": p["department"],
"rating": float(p["rating"]) if p["rating"] is not None else 0.0,
"surveys": int(p["surveys"]) if p["surveys"] is not None else 0,
"positive": int(p["positive"]) if p["positive"] is not None else 0,
"neutral": int(p["neutral"]) if p["neutral"] is not None else 0,
"negative": int(p["negative"]) if p["negative"] is not None else 0,
}
for p in physician_data
]
return JsonResponse({
'kpis': kpis,
'charts': charts,
'tables': tables
})
return JsonResponse({"kpis": kpis, "charts": charts, "tables": tables})
@block_source_user
@ -577,40 +565,40 @@ def command_center_api(request):
def export_command_center(request, export_format):
"""
Export Command Center data to Excel or PDF
Args:
export_format: 'excel' or 'pdf'
Returns:
HttpResponse with file download
"""
if export_format not in ['excel', 'pdf']:
return JsonResponse({'error': 'Invalid export format'}, status=400)
if export_format not in ["excel", "pdf"]:
return JsonResponse({"error": "Invalid export format"}, status=400)
user = request.user
# Get filter parameters
date_range = request.GET.get('date_range', '30d')
hospital_id = request.GET.get('hospital')
department_id = request.GET.get('department')
kpi_category = request.GET.get('kpi_category')
custom_start_str = request.GET.get('custom_start')
custom_end_str = request.GET.get('custom_end')
date_range = request.GET.get("date_range", "30d")
hospital_id = request.GET.get("hospital")
department_id = request.GET.get("department")
kpi_category = request.GET.get("kpi_category")
custom_start_str = request.GET.get("custom_start")
custom_end_str = request.GET.get("custom_end")
# Parse custom dates
custom_start = None
custom_end = None
if custom_start_str and custom_end_str:
try:
custom_start = datetime.strptime(custom_start_str, '%Y-%m-%d')
custom_end = datetime.strptime(custom_end_str, '%Y-%m-%d')
custom_start = datetime.strptime(custom_start_str, "%Y-%m-%d")
custom_end = datetime.strptime(custom_end_str, "%Y-%m-%d")
except ValueError:
pass
# Handle hospital_id and department_id (can be integer or UUID string)
hospital_id = hospital_id if hospital_id else None
department_id = department_id if department_id else None
# Get all data
kpis = UnifiedAnalyticsService.get_all_kpis(
user=user,
@ -619,18 +607,18 @@ def export_command_center(request, export_format):
department_id=department_id,
kpi_category=kpi_category,
custom_start=custom_start,
custom_end=custom_end
custom_end=custom_end,
)
chart_types = [
'complaints_trend',
'complaints_by_category',
'survey_satisfaction_trend',
'survey_distribution',
'department_performance',
'physician_leaderboard'
"complaints_trend",
"complaints_by_category",
"survey_satisfaction_trend",
"survey_distribution",
"department_performance",
"physician_leaderboard",
]
charts = {}
for chart_type in chart_types:
charts[chart_type] = UnifiedAnalyticsService.get_chart_data(
@ -640,77 +628,64 @@ def export_command_center(request, export_format):
hospital_id=hospital_id,
department_id=department_id,
custom_start=custom_start,
custom_end=custom_end
custom_end=custom_end,
)
# Get table data
tables = {}
# Overdue complaints
complaints_qs = Complaint.objects.filter(is_overdue=True)
if hospital_id:
complaints_qs = complaints_qs.filter(hospital_id=hospital_id)
if department_id:
complaints_qs = complaints_qs.filter(department_id=department_id)
if not user.is_px_admin() and user.hospital:
complaints_qs = complaints_qs.filter(hospital=user.hospital)
if user.is_department_manager() and user.department:
complaints_qs = complaints_qs.filter(department=user.department)
tables['overdue_complaints'] = {
'headers': ['ID', 'Title', 'Patient', 'Severity', 'Hospital', 'Department', 'Due Date'],
'rows': list(
complaints_qs.select_related('hospital', 'department', 'patient')
.order_by('due_at')[:100]
tables["overdue_complaints"] = {
"headers": ["ID", "Title", "Patient", "Severity", "Hospital", "Department", "Due Date"],
"rows": list(
complaints_qs.select_related("hospital", "department", "patient")
.order_by("due_at")[:100]
.annotate(
patient_full_name=Concat('patient__first_name', Value(' '), 'patient__last_name'),
hospital_name=F('hospital__name'),
department_name=F('department__name')
patient_full_name=Concat("patient__first_name", Value(" "), "patient__last_name"),
hospital_name=F("hospital__name"),
department_name=F("department__name"),
)
.values_list(
'id',
'title',
'patient_full_name',
'severity',
'hospital_name',
'department_name',
'due_at'
)
)
.values_list("id", "title", "patient_full_name", "severity", "hospital_name", "department_name", "due_at")
),
}
# Physician leaderboard
physician_data = charts.get('physician_leaderboard', {}).get('metadata', [])
tables['physician_leaderboard'] = {
'headers': ['Name', 'Specialization', 'Department', 'Rating', 'Surveys', 'Positive', 'Neutral', 'Negative'],
'rows': [
physician_data = charts.get("physician_leaderboard", {}).get("metadata", [])
tables["physician_leaderboard"] = {
"headers": ["Name", "Specialization", "Department", "Rating", "Surveys", "Positive", "Neutral", "Negative"],
"rows": [
[
p['name'],
p['specialization'],
p['department'],
str(p['rating']),
str(p['surveys']),
str(p['positive']),
str(p['neutral']),
str(p['negative'])
p["name"],
p["specialization"],
p["department"],
str(p["rating"]),
str(p["surveys"]),
str(p["positive"]),
str(p["neutral"]),
str(p["negative"]),
]
for p in physician_data
]
],
}
# Prepare export data
export_data = ExportService.prepare_dashboard_data(
user=user,
kpis=kpis,
charts=charts,
tables=tables
)
export_data = ExportService.prepare_dashboard_data(user=user, kpis=kpis, charts=charts, tables=tables)
# Export based on format
if export_format == 'excel':
if export_format == "excel":
return ExportService.export_to_excel(export_data)
elif export_format == 'pdf':
elif export_format == "pdf":
return ExportService.export_to_pdf(export_data)
return JsonResponse({'error': 'Export failed'}, status=500)
return JsonResponse({"error": "Export failed"}, status=500)

View File

@ -140,10 +140,6 @@ def appreciation_list(request):
page_number = request.GET.get('page', 1)
page_obj = paginator.get_page(page_number)
# Get filter options
hospitals = Hospital.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
departments = Department.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
@ -172,7 +168,6 @@ def appreciation_list(request):
'page_obj': page_obj,
'appreciations': page_obj.object_list,
'stats': stats,
'hospitals': hospitals,
'departments': departments,
'categories': categories,
'status_choices': AppreciationStatus.choices,
@ -334,7 +329,6 @@ def appreciation_send(request):
categories = categories.filter(Q(hospital_id=request.user.hospital.id) | Q(hospital__isnull=True))
context = {
'hospitals': hospitals,
'categories': categories,
'visibility_choices': AppreciationVisibility.choices,
}
@ -410,10 +404,6 @@ def leaderboard_view(request):
page_number = request.GET.get('page', 1)
page_obj = paginator.get_page(page_number)
# Get filter options
hospitals = Hospital.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
departments = Department.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
@ -426,7 +416,6 @@ def leaderboard_view(request):
context = {
'page_obj': page_obj,
'leaderboard': page_obj.object_list,
'hospitals': hospitals,
'departments': departments,
'months': months,
'years': years,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,397 @@
"""
Management command to test SLA reminder functionality.
Creates test complaints with various scenarios and optionally runs the SLA reminder task
to verify correct recipient selection and timing calculations.
Usage:
python manage.py test_sla_reminders --dry-run
python manage.py test_sla_reminders --run-task
python manage.py test_sla_reminders --scenario assigned --complaint-count 3
"""
import random
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from django.conf import settings
from apps.accounts.models import User
from apps.complaints.models import Complaint, ComplaintCategory
from apps.organizations.models import Hospital, Department, Staff
from apps.px_sources.models import PXSource
class Command(BaseCommand):
help = "Test SLA reminder functionality by creating test complaints and optionally running the reminder task"
def add_arguments(self, parser):
parser.add_argument("--hospital-code", type=str, help="Target hospital code (default: first active hospital)")
parser.add_argument(
"--complaint-count",
type=int,
default=5,
help="Number of test complaints to create per scenario (default: 5)",
)
parser.add_argument(
"--dry-run", action="store_true", help="Preview without creating complaints or sending emails"
)
parser.add_argument(
"--run-task", action="store_true", help="Execute the SLA reminder task after creating complaints"
)
parser.add_argument(
"--scenario",
type=str,
default="all",
choices=["assigned", "unassigned", "department-manager", "all"],
help="Test specific scenario (default: all)",
)
parser.add_argument("--cleanup", action="store_true", help="Delete test complaints after testing")
parser.add_argument(
"--hours-before-reminder",
type=int,
default=1,
help="Hours before first reminder threshold to create complaints (default: 1)",
)
def handle(self, *args, **options):
hospital_code = options["hospital_code"]
complaint_count = options["complaint_count"]
dry_run = options["dry_run"]
run_task = options["run_task"]
scenario = options["scenario"]
cleanup = options["cleanup"]
hours_before_reminder = options["hours_before_reminder"]
self.stdout.write(f"\n{'=' * 80}")
self.stdout.write("🔔 SLA REMINDER TEST COMMAND")
self.stdout.write(f"{'=' * 80}\n")
# Get hospital
if hospital_code:
hospital = Hospital.objects.filter(code=hospital_code).first()
if not hospital:
self.stdout.write(self.style.ERROR(f"Hospital with code '{hospital_code}' not found"))
return
else:
hospital = Hospital.objects.filter(status="active").first()
if not hospital:
self.stdout.write(self.style.ERROR("No active hospitals found"))
return
self.stdout.write(f"🏥 Hospital: {hospital.name} (Code: {hospital.code})")
# Get or create test data
test_data = self.setup_test_data(hospital, dry_run)
if not test_data:
return
admin_user, px_coordinator, department_with_manager, department_without_manager, source = test_data
# Define scenarios
scenarios = {
"assigned": {
"name": "Assigned Complaint",
"description": "Complaint assigned to a user - email goes to assigned user",
"assigned_to": admin_user,
"department": department_with_manager,
},
"unassigned": {
"name": "Unassigned Complaint (No Manager)",
"description": "Unassigned complaint with no department manager - email goes to hospital admins",
"assigned_to": None,
"department": department_without_manager,
},
"department-manager": {
"name": "Unassigned Complaint (With Manager)",
"description": "Unassigned complaint with department manager - email goes to department manager",
"assigned_to": None,
"department": department_with_manager,
},
}
# Filter scenarios
if scenario != "all":
scenarios = {scenario: scenarios[scenario]}
# Track created complaints
created_complaints = []
for scenario_key, scenario_config in scenarios.items():
self.stdout.write(f"\n{'=' * 80}")
self.stdout.write(f"📋 SCENARIO: {scenario_config['name']}")
self.stdout.write(f"{'=' * 80}")
self.stdout.write(f" {scenario_config['description']}")
self.stdout.write(f"\n Creating {complaint_count} test complaint(s)...\n")
for i in range(complaint_count):
# Calculate backdated time to trigger reminder
# SLA is 72 hours, first reminder at 24 hours
# Create complaint that's already past the 24-hour mark
hours_since_creation = 25 + i # 25, 26, 27... hours ago
created_at_time = timezone.now() - timedelta(hours=hours_since_creation)
if not dry_run:
try:
with transaction.atomic():
complaint = self.create_test_complaint(
hospital=hospital,
source=source,
department=scenario_config["department"],
assigned_to=scenario_config["assigned_to"],
created_at=created_at_time,
index=i + 1,
scenario=scenario_key,
)
created_complaints.append(complaint)
self.stdout.write(self.style.SUCCESS(f" ✓ Created: {complaint.reference_number}"))
self.stdout.write(f" Title: {complaint.title}")
self.stdout.write(
f" Created: {complaint.created_at.strftime('%Y-%m-%d %H:%M:%S')} ({hours_since_creation} hours ago)"
)
self.stdout.write(f" Due: {complaint.due_at.strftime('%Y-%m-%d %H:%M:%S')}")
self.stdout.write(
f" Assigned to: {complaint.assigned_to.get_full_name() if complaint.assigned_to else 'None'}"
)
self.stdout.write(
f" Department: {complaint.department.name if complaint.department else 'None'}"
)
if complaint.department and complaint.department.manager:
self.stdout.write(f" Dept Manager: {complaint.department.manager.get_full_name()}")
except Exception as e:
self.stdout.write(self.style.ERROR(f" ✗ Error creating complaint: {str(e)}"))
else:
# Dry run - show what would be created
self.stdout.write(f" [DRY RUN] Would create complaint {i + 1}")
self.stdout.write(
f" Created: {created_at_time.strftime('%Y-%m-%d %H:%M:%S')} ({hours_since_creation} hours ago)"
)
self.stdout.write(
f" Assigned to: {scenario_config['assigned_to'].get_full_name() if scenario_config['assigned_to'] else 'None'}"
)
self.stdout.write(
f" Department: {scenario_config['department'].name if scenario_config['department'] else 'None'}"
)
# Calculate expected recipient
expected_recipient = self.calculate_expected_recipient(
assigned_to=scenario_config["assigned_to"],
department=scenario_config["department"],
hospital=hospital,
)
self.stdout.write(f" Expected recipient: {expected_recipient}")
# Summary
self.stdout.write(f"\n{'=' * 80}")
self.stdout.write("📊 SUMMARY")
self.stdout.write(f"{'=' * 80}")
if dry_run:
self.stdout.write(self.style.WARNING(f" DRY RUN - No complaints created"))
else:
self.stdout.write(f" Total complaints created: {len(created_complaints)}")
for comp in created_complaints[:5]:
self.stdout.write(f" - {comp.reference_number}")
if len(created_complaints) > 5:
self.stdout.write(f" ... and {len(created_complaints) - 5} more")
# Run SLA reminder task if requested
if run_task and not dry_run:
self.stdout.write(f"\n{'=' * 80}")
self.stdout.write("🚀 RUNNING SLA REMINDER TASK")
self.stdout.write(f"{'=' * 80}\n")
from apps.complaints.tasks import send_sla_reminders
try:
result = send_sla_reminders()
self.stdout.write(self.style.SUCCESS(f"\n✓ SLA reminder task completed"))
self.stdout.write(f" Result: {result}")
except Exception as e:
self.stdout.write(self.style.ERROR(f"\n✗ Error running task: {str(e)}"))
import traceback
self.stdout.write(traceback.format_exc())
elif run_task and dry_run:
self.stdout.write(f"\n{'=' * 80}")
self.stdout.write(self.style.WARNING("⚠ Cannot run task in dry-run mode"))
self.stdout.write(f"{'=' * 80}")
# Cleanup if requested
if cleanup and not dry_run:
self.stdout.write(f"\n{'=' * 80}")
self.stdout.write("🗑️ CLEANING UP TEST DATA")
self.stdout.write(f"{'=' * 80}\n")
count = len(created_complaints)
for comp in created_complaints:
comp.delete()
self.stdout.write(self.style.SUCCESS(f"✓ Deleted {count} test complaints"))
self.stdout.write(f"\n{'=' * 80}")
self.stdout.write("✅ TEST COMPLETED")
self.stdout.write(f"{'=' * 80}\n")
def setup_test_data(self, hospital, dry_run=False):
"""Setup or get test users, departments, and sources"""
from django.contrib.auth.models import Group
# Get Hospital Admin group
hospital_admin_group = Group.objects.filter(name="Hospital Admin").first()
# Get or create test admin user
admin_user = User.objects.filter(hospital=hospital, groups=hospital_admin_group, is_active=True).first()
if not admin_user:
self.stdout.write(self.style.ERROR(f"No hospital admin found for {hospital.name}"))
self.stdout.write(" Please create a hospital admin user first")
return None
# Get PX Coordinator group
px_coordinator_group = Group.objects.filter(name="PX Coordinator").first()
# Get PX Coordinator
px_coordinator = User.objects.filter(hospital=hospital, groups=px_coordinator_group, is_active=True).first()
# Get or create department with manager
dept_with_manager = (
Department.objects.filter(hospital=hospital, status="active").exclude(manager__isnull=True).first()
)
if not dept_with_manager:
if not dry_run:
self.stdout.write(self.style.WARNING("No department with manager found, creating one..."))
dept_with_manager = Department.objects.create(
hospital=hospital,
name="Test Department",
name_ar="قسم الاختبار",
status="active",
)
# Assign admin as manager
dept_with_manager.manager = admin_user
dept_with_manager.save()
else:
self.stdout.write(self.style.WARNING("No department with manager found (dry-run mode)"))
self.stdout.write(
" Tip: Run without --dry-run to create test data, or assign a manager to an existing department"
)
# Use first available department for dry-run display
dept_with_manager = Department.objects.filter(hospital=hospital, status="active").first()
if not dept_with_manager:
return None
# Get or create department without manager
dept_without_manager = (
Department.objects.filter(
hospital=hospital,
status="active",
)
.exclude(id=dept_with_manager.id)
.first()
)
if not dept_without_manager:
if not dry_run:
self.stdout.write(self.style.WARNING("No department without manager found, creating one..."))
dept_without_manager = Department.objects.create(
hospital=hospital,
name="Test Department No Manager",
name_ar="قسم بدون مدير",
status="active",
)
else:
self.stdout.write(self.style.WARNING("No second department found (dry-run mode)"))
self.stdout.write(" Tip: Run without --dry-run to create test data")
# Reuse the same department for dry-run display
dept_without_manager = dept_with_manager
# Get or create source
source = PXSource.objects.filter(name_en="Patient", is_active=True).first()
if not source:
source = PXSource.objects.filter(is_active=True).first()
self.stdout.write(f"\n👥 Test Data:")
self.stdout.write(f" Admin User: {admin_user.get_full_name()} ({admin_user.email})")
if px_coordinator:
self.stdout.write(f" PX Coordinator: {px_coordinator.get_full_name()} ({px_coordinator.email})")
if dept_with_manager.manager:
self.stdout.write(
f" Dept with Manager: {dept_with_manager.name} (Manager: {dept_with_manager.manager.get_full_name()})"
)
else:
self.stdout.write(
f" Dept: {dept_with_manager.name} (No manager assigned - will use for 'without manager' scenario)"
)
self.stdout.write(f" Dept without Manager: {dept_without_manager.name}")
if source:
self.stdout.write(f" Source: {source.name_en}")
return admin_user, px_coordinator, dept_with_manager, dept_without_manager, source
def create_test_complaint(self, hospital, source, department, assigned_to, created_at, index, scenario):
"""Create a test complaint with backdated created_at"""
# Temporarily disable auto_now_add for created_at
from django.db.models import DateTimeField
from django.db.models.fields import Field
# Save original auto_now_add
created_at_field = Complaint._meta.get_field("created_at")
original_auto_now_add = created_at_field.auto_now_add
created_at_field.auto_now_add = False
try:
complaint = Complaint.objects.create(
hospital=hospital,
source=source,
department=department,
assigned_to=assigned_to,
category=self.get_category(),
title=f"Test SLA Reminder - {scenario.replace('-', ' ').title()} #{index}",
description=f"This is a test complaint created to verify SLA reminder functionality. Scenario: {scenario}. This complaint was created {((timezone.now() - created_at).total_seconds() / 3600):.1f} hours ago to test if reminders are sent correctly.",
severity="medium",
priority="medium",
status="open",
contact_name="Test Patient",
contact_phone="+966500000000",
contact_email="test@example.com",
created_at=created_at,
)
# If assigned_to is set, also set status to in_progress and activated_at
if assigned_to:
complaint.status = "in_progress"
complaint.activated_at = created_at + timedelta(hours=1)
complaint.save(update_fields=["status", "activated_at"])
return complaint
finally:
# Restore auto_now_add
created_at_field.auto_now_add = original_auto_now_add
def get_category(self):
"""Get a test category"""
category = ComplaintCategory.objects.filter(code="communication", is_active=True).first()
if not category:
category = ComplaintCategory.objects.filter(is_active=True).first()
return category
def calculate_expected_recipient(self, assigned_to, department, hospital):
"""Calculate expected email recipient based on complaint configuration"""
if assigned_to:
return f"{assigned_to.get_full_name()} ({assigned_to.email}) - Assigned User"
if department and department.manager:
return f"{department.manager.get_full_name()} ({department.manager.email}) - Department Manager"
# Fallback to hospital admins and coordinators
from apps.complaints.tasks import get_hospital_admins_and_coordinators
recipients = get_hospital_admins_and_coordinators(hospital)
if recipients:
names = [f"{r.get_full_name()} ({r.email})" for r in recipients]
return f"Hospital Admins/Coordinators: {', '.join(names)}"
return "NO RECIPIENTS FOUND"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -40,7 +40,7 @@ class AIService:
# Default configuration
DEFAULT_MODEL = "openrouter/z-ai/glm-4.7"
DEFAULT_MODEL = "openrouter/nvidia/nemotron-3-super-120b-a12b:free"
# DEFAULT_MODEL = "openrouter/xiaomi/mimo-v2-flash:free"
DEFAULT_TEMPERATURE = 0.3
DEFAULT_MAX_TOKENS = 500
@ -521,7 +521,7 @@ class AIService:
# Build kwargs
kwargs = {
"model": "openrouter/xiaomi/mimo-v2-flash",
"model": "openrouter/nvidia/nemotron-3-super-120b-a12b:free",
"messages": messages
}

View File

@ -1,6 +1,7 @@
"""
Context processors for global template variables
"""
from django.db.models import Q
@ -25,59 +26,36 @@ def sidebar_counts(request):
# Source Users only see their own created complaints
if user.is_source_user():
complaint_count = Complaint.objects.filter(
created_by=user,
status__in=['open', 'in_progress']
).count()
complaint_count = Complaint.objects.filter(created_by=user, status__in=["open", "in_progress"]).count()
return {
'complaint_count': complaint_count,
'feedback_count': 0,
'action_count': 0,
'current_hospital': None,
'is_px_admin': False,
'is_source_user': True,
"complaint_count": complaint_count,
"feedback_count": 0,
"action_count": 0,
"current_hospital": None,
"is_px_admin": False,
"is_source_user": True,
}
# Filter based on user role and tenant_hospital
if user.is_px_admin():
# PX Admins use their selected hospital from session
hospital = getattr(request, 'tenant_hospital', None)
hospital = getattr(request, "tenant_hospital", None)
if hospital:
complaint_count = Complaint.objects.filter(
hospital=hospital,
status__in=['open', 'in_progress']
).count()
feedback_count = Feedback.objects.filter(
hospital=hospital,
status__in=['submitted', 'reviewed']
).count()
action_count = PXAction.objects.filter(
hospital=hospital,
status__in=['open', 'in_progress']
).count()
complaint_count = Complaint.objects.filter(hospital=hospital, status__in=["open", "in_progress"]).count()
feedback_count = Feedback.objects.filter(hospital=hospital, status__in=["submitted", "reviewed"]).count()
action_count = PXAction.objects.filter(hospital=hospital, status__in=["open", "in_progress"]).count()
else:
complaint_count = 0
feedback_count = 0
action_count = 0
# Count provisional users for PX Admin
from apps.accounts.models import User
provisional_user_count = User.objects.filter(
is_provisional=True,
acknowledgement_completed=False
).count()
provisional_user_count = User.objects.filter(is_provisional=True, acknowledgement_completed=False).count()
elif user.hospital:
complaint_count = Complaint.objects.filter(
hospital=user.hospital,
status__in=['open', 'in_progress']
).count()
feedback_count = Feedback.objects.filter(
hospital=user.hospital,
status__in=['submitted', 'reviewed']
).count()
action_count = PXAction.objects.filter(
hospital=user.hospital,
status__in=['open', 'in_progress']
).count()
complaint_count = Complaint.objects.filter(hospital=user.hospital, status__in=["open", "in_progress"]).count()
feedback_count = Feedback.objects.filter(hospital=user.hospital, status__in=["submitted", "reviewed"]).count()
action_count = PXAction.objects.filter(hospital=user.hospital, status__in=["open", "in_progress"]).count()
# provisional_user_count = 0
else:
complaint_count = 0
@ -85,12 +63,12 @@ def sidebar_counts(request):
action_count = 0
return {
'complaint_count': complaint_count,
'feedback_count': feedback_count,
'action_count': action_count,
'current_hospital': getattr(request, 'tenant_hospital', None),
'is_px_admin': request.user.is_authenticated and request.user.is_px_admin(),
'is_source_user': False,
"complaint_count": complaint_count,
"feedback_count": feedback_count,
"action_count": action_count,
"current_hospital": getattr(request, "tenant_hospital", None),
"is_px_admin": request.user.is_authenticated and request.user.is_px_admin(),
"is_source_user": False,
}
@ -103,25 +81,24 @@ def hospital_context(request):
if not request.user.is_authenticated:
return {}
hospital = getattr(request, 'tenant_hospital', None)
hospital = getattr(request, "tenant_hospital", None)
# Get list of hospitals for PX Admin switcher
hospitals_list = []
if request.user.is_px_admin():
from apps.organizations.models import Hospital
hospitals_list = list(
Hospital.objects.filter(status='active').order_by('name').values('id', 'name', 'code')
)
hospitals_list = list(Hospital.objects.filter(status="active").order_by("name").values("id", "name", "code"))
# Source user context
is_source_user = request.user.is_source_user()
source_user_profile = getattr(request, 'source_user_profile', None)
source_user_profile = getattr(request, "source_user_profile", None)
return {
'current_hospital': hospital,
'is_px_admin': request.user.is_px_admin(),
'is_source_user': is_source_user,
'source_user_profile': source_user_profile,
'hospitals_list': hospitals_list,
# 'provisional_user_count': provisional_user_count,
"current_hospital": hospital,
"is_px_admin": request.user.is_px_admin(),
"is_source_user": is_source_user,
"source_user_profile": source_user_profile,
"hospitals_list": hospitals_list,
"show_hospital_selector": False,
}

View File

@ -3,135 +3,131 @@ Form mixins for tenant-aware forms.
Provides mixins to handle hospital field visibility based on user role.
"""
from django import forms
from apps.organizations.models import Hospital
class HospitalFieldMixin:
"""
Mixin to handle hospital field visibility based on user role.
- PX Admins: See dropdown with all active hospitals
- Others: Hidden field, auto-set to user's hospital
Mixin to handle hospital field - always hidden, auto-set based on user context.
- PX Admins: Hidden field, auto-set from session (request.tenant_hospital)
- Others: Hidden field, auto-set from user's hospital (User.hospital)
Usage:
class MyForm(HospitalFieldMixin, forms.ModelForm):
class Meta:
model = MyModel
fields = ['hospital', ...]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Hospital field is automatically configured
In views:
form = MyForm(user=request.user)
form = MyForm(request=request)
"""
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None)
self.request = kwargs.pop("request", None)
self.user = self.request.user if self.request else None
super().__init__(*args, **kwargs)
if self.user and 'hospital' in self.fields:
if self.user and "hospital" in self.fields:
self._setup_hospital_field()
def _setup_hospital_field(self):
"""Configure hospital field based on user role."""
hospital_field = self.fields['hospital']
"""Configure hospital field - always hidden, auto-set based on user context."""
hospital_field = self.fields["hospital"]
hospital_field.widget = forms.HiddenInput()
hospital_field.required = False
hospital = None
if self.user.is_px_admin():
# PX Admin: Show dropdown with all active hospitals
hospital_field.queryset = Hospital.objects.filter(status='active').order_by('name')
hospital_field.required = True
# Update widget attrs instead of replacing widget to preserve choices
hospital_field.widget.attrs.update({
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
})
hospital = getattr(self.request, "tenant_hospital", None)
else:
# Regular user: Hide field and set default
hospital_field.widget = forms.HiddenInput()
hospital_field.required = False
# Set initial value to user's hospital
if self.user.hospital:
hospital_field.initial = self.user.hospital
# Limit queryset to just user's hospital (for validation)
hospital_field.queryset = Hospital.objects.filter(id=self.user.hospital.id)
else:
# User has no hospital - empty queryset
hospital_field.queryset = Hospital.objects.none()
hospital = self.user.hospital
if hospital:
hospital_field.initial = hospital
hospital_field.queryset = Hospital.objects.filter(id=hospital.id)
else:
hospital_field.queryset = Hospital.objects.none()
def clean_hospital(self):
"""
Ensure non-PX admins can only use their own hospital.
PX Admins can select any hospital.
Auto-set hospital based on user context.
"""
hospital = self.cleaned_data.get('hospital')
hospital = self.cleaned_data.get("hospital")
if not self.user:
return hospital
if self.user.is_px_admin():
# PX Admin must select a hospital
hospital = getattr(self.request, "tenant_hospital", None)
if not hospital:
raise forms.ValidationError("Please select a hospital.")
raise forms.ValidationError("No hospital selected. Please select a hospital first.")
return hospital
else:
# Non-PX admins: Force user's hospital
if self.user.hospital:
return self.user.hospital
else:
raise forms.ValidationError(
"You do not have a hospital assigned. Please contact your administrator."
)
raise forms.ValidationError("You do not have a hospital assigned. Please contact your administrator.")
class DepartmentFieldMixin:
"""
Mixin to handle department field filtering based on user's hospital.
- Filters departments to only show those in the selected/current hospital
- Works with HospitalFieldMixin
Usage:
class MyForm(HospitalFieldMixin, DepartmentFieldMixin, forms.ModelForm):
class Meta:
model = MyModel
fields = ['hospital', 'department', ...]
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'department' in self.fields:
if "department" in self.fields:
self._setup_department_field()
def _setup_department_field(self):
"""Configure department field with hospital-based filtering."""
from apps.organizations.models import Department
department_field = self.fields['department']
department_field = self.fields["department"]
# Get the hospital (either from form data or user's hospital)
hospital = None
if self.data.get('hospital'):
if self.data.get("hospital"):
try:
hospital = Hospital.objects.get(id=self.data['hospital'])
hospital = Hospital.objects.get(id=self.data["hospital"])
except Hospital.DoesNotExist:
pass
elif self.initial.get('hospital'):
hospital = self.initial['hospital']
elif self.instance and self.instance.pk and self.instance.hospital:
hospital = self.instance.hospital
elif self.initial.get("hospital"):
hospital = self.initial["hospital"]
elif self.instance and self.instance.pk:
# Only access hospital if instance is saved (has pk)
try:
hospital = self.instance.hospital
except Exception:
# Hospital not set (RelatedObjectDoesNotExist or similar)
pass
elif self.user and self.user.is_px_admin():
hospital = getattr(self.request, "tenant_hospital", None)
elif self.user and self.user.hospital:
hospital = self.user.hospital
if hospital:
# Filter departments to user's hospital
department_field.queryset = Department.objects.filter(
hospital=hospital,
status='active'
).order_by('name')
department_field.queryset = Department.objects.filter(hospital=hospital, status="active").order_by("name")
else:
# No hospital context - empty queryset
department_field.queryset = Department.objects.none()

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,9 @@
"""
Tenant-aware middleware for multi-tenancy
"""
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.deprecation import MiddlewareMixin
@ -11,17 +14,28 @@ class TenantMiddleware(MiddlewareMixin):
This middleware ensures that:
- authenticated users have their tenant_hospital set from their profile
- PX admins can switch between hospitals via session
- PX admins without a selected hospital are redirected to select-hospital
- Source Users have their source context available
- All requests have tenant context available
"""
EXEMPT_PATHS = [
"/core/select-hospital/",
"/accounts/logout/",
"/accounts/password_reset/",
"/accounts/password_reset/done/",
"/accounts/reset/",
"/api/",
"/health/",
"/admin/",
"/__debug__/",
]
def process_request(self, request):
"""Set tenant hospital context on each request."""
if request.user and request.user.is_authenticated:
# Store user's role for quick access
request.user_roles = request.user.get_role_names()
# Set source user context
request.source_user = None
request.source_user_profile = None
if request.user.is_source_user():
@ -30,24 +44,19 @@ class TenantMiddleware(MiddlewareMixin):
request.source_user = profile
request.source_user_profile = profile
# PX Admins can switch hospitals via session
if request.user.is_px_admin():
hospital_id = request.session.get('selected_hospital_id')
hospital_id = request.session.get("selected_hospital_id")
if hospital_id:
from apps.organizations.models import Hospital
try:
# Validate that the hospital exists
request.tenant_hospital = Hospital.objects.get(id=hospital_id)
except Hospital.DoesNotExist:
# Invalid hospital ID, fall back to default
request.tenant_hospital = None
# Clear invalid session data
request.session.pop('selected_hospital_id', None)
request.session.pop("selected_hospital_id", None)
else:
# No hospital selected yet
request.tenant_hospital = None
else:
# Non-PX Admin users use their assigned hospital
request.tenant_hospital = request.user.hospital
else:
request.tenant_hospital = None
@ -56,3 +65,24 @@ class TenantMiddleware(MiddlewareMixin):
request.source_user_profile = None
return None
def process_view(self, request, view_func, view_args, view_kwargs):
"""Redirect PX admins without hospital to select-hospital page."""
if not request.user or not request.user.is_authenticated:
return None
if not request.user.is_px_admin():
return None
if request.tenant_hospital:
return None
path = request.path
for exempt_path in self.EXEMPT_PATHS:
if path.startswith(exempt_path):
return None
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return None
return HttpResponseRedirect(reverse("core:select_hospital"))

View File

@ -1083,7 +1083,6 @@ def admin_evaluation(request):
).distinct().select_related('hospital', 'department')
context = {
'hospitals': hospitals,
'departments': departments,
'staff_list': staff_queryset,
'selected_hospital_id': hospital_id,

View File

@ -1,161 +1,111 @@
"""
Feedback forms - Forms for feedback management
"""
from django import forms
from apps.organizations.models import Department, Hospital, Patient, Staff
from apps.core.form_mixins import HospitalFieldMixin
from .models import Feedback, FeedbackResponse, FeedbackStatus, FeedbackType, FeedbackCategory
class FeedbackForm(forms.ModelForm):
class FeedbackForm(HospitalFieldMixin, forms.ModelForm):
"""Form for creating and editing feedback"""
class Meta:
model = Feedback
fields = [
'patient',
'is_anonymous',
'contact_name',
'contact_email',
'contact_phone',
'hospital',
'department',
'staff',
'feedback_type',
'title',
'message',
'category',
'subcategory',
'rating',
'priority',
'encounter_id',
"patient",
"is_anonymous",
"contact_name",
"contact_email",
"contact_phone",
"hospital",
"department",
"staff",
"feedback_type",
"title",
"message",
"category",
"subcategory",
"rating",
"priority",
"encounter_id",
]
widgets = {
'patient': forms.Select(attrs={
'class': 'form-select',
'id': 'id_patient'
}),
'is_anonymous': forms.CheckboxInput(attrs={
'class': 'form-check-input',
'id': 'id_is_anonymous'
}),
'contact_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter contact name'
}),
'contact_email': forms.EmailInput(attrs={
'class': 'form-control',
'placeholder': 'Enter email address'
}),
'contact_phone': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter phone number'
}),
'hospital': forms.Select(attrs={
'class': 'form-select',
'required': True
}),
'department': forms.Select(attrs={
'class': 'form-select'
}),
'staff': forms.Select(attrs={
'class': 'form-select'
}),
'feedback_type': forms.Select(attrs={
'class': 'form-select',
'required': True
}),
'title': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter feedback title',
'required': True
}),
'message': forms.Textarea(attrs={
'class': 'form-control',
'rows': 5,
'placeholder': 'Enter your feedback message...',
'required': True
}),
'category': forms.Select(attrs={
'class': 'form-select',
'required': True
}),
'subcategory': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter subcategory (optional)'
}),
'rating': forms.NumberInput(attrs={
'class': 'form-control',
'min': 1,
'max': 5,
'placeholder': 'Rate from 1 to 5'
}),
'priority': forms.Select(attrs={
'class': 'form-select'
}),
'encounter_id': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter encounter ID (optional)'
}),
"patient": forms.Select(attrs={"class": "form-select", "id": "id_patient"}),
"is_anonymous": forms.CheckboxInput(attrs={"class": "form-check-input", "id": "id_is_anonymous"}),
"contact_name": forms.TextInput(attrs={"class": "form-control", "placeholder": "Enter contact name"}),
"contact_email": forms.EmailInput(attrs={"class": "form-control", "placeholder": "Enter email address"}),
"contact_phone": forms.TextInput(attrs={"class": "form-control", "placeholder": "Enter phone number"}),
"hospital": forms.Select(attrs={"class": "form-select", "required": True}),
"department": forms.Select(attrs={"class": "form-select"}),
"staff": forms.Select(attrs={"class": "form-select"}),
"feedback_type": forms.Select(attrs={"class": "form-select", "required": True}),
"title": forms.TextInput(
attrs={"class": "form-control", "placeholder": "Enter feedback title", "required": True}
),
"message": forms.Textarea(
attrs={
"class": "form-control",
"rows": 5,
"placeholder": "Enter your feedback message...",
"required": True,
}
),
"category": forms.Select(attrs={"class": "form-select", "required": True}),
"subcategory": forms.TextInput(
attrs={"class": "form-control", "placeholder": "Enter subcategory (optional)"}
),
"rating": forms.NumberInput(
attrs={"class": "form-control", "min": 1, "max": 5, "placeholder": "Rate from 1 to 5"}
),
"priority": forms.Select(attrs={"class": "form-select"}),
"encounter_id": forms.TextInput(
attrs={"class": "form-control", "placeholder": "Enter encounter ID (optional)"}
),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Filter hospitals based on user permissions
if user:
if not user.is_px_admin() and user.hospital:
self.fields['hospital'].queryset = Hospital.objects.filter(
id=user.hospital.id,
status='active'
)
else:
self.fields['hospital'].queryset = Hospital.objects.filter(status='active')
# Hospital field is configured by HospitalFieldMixin (always hidden)
# Filter departments and staff based on hospital
hospital = None
if self.instance.pk and hasattr(self.instance, "hospital") and self.instance.hospital_id:
hospital = self.instance.hospital
elif self.user and self.user.is_px_admin():
hospital = getattr(self.request, "tenant_hospital", None)
elif self.user and self.user.hospital:
hospital = self.user.hospital
# Set initial hospital if user has one
if user and user.hospital and not self.instance.pk:
self.fields['hospital'].initial = user.hospital
# Filter departments and physicians based on selected hospital
if self.instance.pk and hasattr(self.instance, 'hospital') and self.instance.hospital_id:
self.fields['department'].queryset = Department.objects.filter(
hospital=self.instance.hospital,
status='active'
)
self.fields['staff'].queryset = Staff.objects.filter(
hospital=self.instance.hospital,
status='active'
)
if hospital:
self.fields["department"].queryset = Department.objects.filter(hospital=hospital, status="active")
self.fields["staff"].queryset = Staff.objects.filter(hospital=hospital, status="active")
else:
self.fields['department'].queryset = Department.objects.none()
self.fields['staff'].queryset = Staff.objects.none()
self.fields["department"].queryset = Department.objects.none()
self.fields["staff"].queryset = Staff.objects.none()
# Make patient optional if anonymous
if self.data.get('is_anonymous'):
self.fields['patient'].required = False
if self.data.get("is_anonymous"):
self.fields["patient"].required = False
def clean(self):
cleaned_data = super().clean()
is_anonymous = cleaned_data.get('is_anonymous')
patient = cleaned_data.get('patient')
contact_name = cleaned_data.get('contact_name')
is_anonymous = cleaned_data.get("is_anonymous")
patient = cleaned_data.get("patient")
contact_name = cleaned_data.get("contact_name")
# Validate anonymous feedback
if is_anonymous:
if not contact_name:
raise forms.ValidationError(
"Contact name is required for anonymous feedback."
)
raise forms.ValidationError("Contact name is required for anonymous feedback.")
else:
if not patient:
raise forms.ValidationError(
"Please select a patient or mark as anonymous."
)
raise forms.ValidationError("Please select a patient or mark as anonymous.")
# Validate rating
rating = cleaned_data.get('rating')
rating = cleaned_data.get("rating")
if rating is not None and (rating < 1 or rating > 5):
raise forms.ValidationError("Rating must be between 1 and 5.")
@ -167,21 +117,13 @@ class FeedbackResponseForm(forms.ModelForm):
class Meta:
model = FeedbackResponse
fields = ['response_type', 'message', 'is_internal']
fields = ["response_type", "message", "is_internal"]
widgets = {
'response_type': forms.Select(attrs={
'class': 'form-select',
'required': True
}),
'message': forms.Textarea(attrs={
'class': 'form-control',
'rows': 4,
'placeholder': 'Enter your response...',
'required': True
}),
'is_internal': forms.CheckboxInput(attrs={
'class': 'form-check-input'
}),
"response_type": forms.Select(attrs={"class": "form-select", "required": True}),
"message": forms.Textarea(
attrs={"class": "form-control", "rows": 4, "placeholder": "Enter your response...", "required": True}
),
"is_internal": forms.CheckboxInput(attrs={"class": "form-check-input"}),
}
@ -190,111 +132,86 @@ class FeedbackFilterForm(forms.Form):
search = forms.CharField(
required=False,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Search by title, message, patient...'
})
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Search by title, message, patient..."}),
)
feedback_type = forms.ChoiceField(
required=False,
choices=[('', 'All Types')] + list(FeedbackType.choices),
widget=forms.Select(attrs={'class': 'form-select'})
choices=[("", "All Types")] + list(FeedbackType.choices),
widget=forms.Select(attrs={"class": "form-select"}),
)
status = forms.ChoiceField(
required=False,
choices=[('', 'All Statuses')] + list(FeedbackStatus.choices),
widget=forms.Select(attrs={'class': 'form-select'})
choices=[("", "All Statuses")] + list(FeedbackStatus.choices),
widget=forms.Select(attrs={"class": "form-select"}),
)
category = forms.ChoiceField(
required=False,
choices=[('', 'All Categories')] + list(FeedbackCategory.choices),
widget=forms.Select(attrs={'class': 'form-select'})
choices=[("", "All Categories")] + list(FeedbackCategory.choices),
widget=forms.Select(attrs={"class": "form-select"}),
)
sentiment = forms.ChoiceField(
required=False,
choices=[
('', 'All Sentiments'),
('positive', 'Positive'),
('neutral', 'Neutral'),
('negative', 'Negative'),
("", "All Sentiments"),
("positive", "Positive"),
("neutral", "Neutral"),
("negative", "Negative"),
],
widget=forms.Select(attrs={'class': 'form-select'})
widget=forms.Select(attrs={"class": "form-select"}),
)
priority = forms.ChoiceField(
required=False,
choices=[
('', 'All Priorities'),
('low', 'Low'),
('medium', 'Medium'),
('high', 'High'),
('urgent', 'Urgent'),
("", "All Priorities"),
("low", "Low"),
("medium", "Medium"),
("high", "High"),
("urgent", "Urgent"),
],
widget=forms.Select(attrs={'class': 'form-select'})
widget=forms.Select(attrs={"class": "form-select"}),
)
hospital = forms.ModelChoiceField(
required=False,
queryset=Hospital.objects.filter(status='active'),
widget=forms.Select(attrs={'class': 'form-select'}),
empty_label='All Hospitals'
queryset=Hospital.objects.filter(status="active"),
widget=forms.Select(attrs={"class": "form-select"}),
empty_label="All Hospitals",
)
department = forms.ModelChoiceField(
required=False,
queryset=Department.objects.filter(status='active'),
widget=forms.Select(attrs={'class': 'form-select'}),
empty_label='All Departments'
queryset=Department.objects.filter(status="active"),
widget=forms.Select(attrs={"class": "form-select"}),
empty_label="All Departments",
)
rating_min = forms.IntegerField(
required=False,
min_value=1,
max_value=5,
widget=forms.NumberInput(attrs={
'class': 'form-control',
'placeholder': 'Min rating'
})
widget=forms.NumberInput(attrs={"class": "form-control", "placeholder": "Min rating"}),
)
rating_max = forms.IntegerField(
required=False,
min_value=1,
max_value=5,
widget=forms.NumberInput(attrs={
'class': 'form-control',
'placeholder': 'Max rating'
})
widget=forms.NumberInput(attrs={"class": "form-control", "placeholder": "Max rating"}),
)
date_from = forms.DateField(
required=False,
widget=forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
})
)
date_from = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}))
date_to = forms.DateField(
required=False,
widget=forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
})
)
date_to = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}))
is_featured = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
is_featured = forms.BooleanField(required=False, widget=forms.CheckboxInput(attrs={"class": "form-check-input"}))
requires_follow_up = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
required=False, widget=forms.CheckboxInput(attrs={"class": "form-check-input"})
)
@ -302,38 +219,29 @@ class FeedbackStatusChangeForm(forms.Form):
"""Form for changing feedback status"""
status = forms.ChoiceField(
choices=FeedbackStatus.choices,
widget=forms.Select(attrs={
'class': 'form-select',
'required': True
})
choices=FeedbackStatus.choices, widget=forms.Select(attrs={"class": "form-select", "required": True})
)
note = forms.CharField(
required=False,
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Add a note about this status change (optional)...'
})
widget=forms.Textarea(
attrs={
"class": "form-control",
"rows": 3,
"placeholder": "Add a note about this status change (optional)...",
}
),
)
class FeedbackAssignForm(forms.Form):
"""Form for assigning feedback to a user"""
user_id = forms.UUIDField(
widget=forms.Select(attrs={
'class': 'form-select',
'required': True
})
)
user_id = forms.UUIDField(widget=forms.Select(attrs={"class": "form-select", "required": True}))
note = forms.CharField(
required=False,
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'Add a note about this assignment (optional)...'
})
widget=forms.Textarea(
attrs={"class": "form-control", "rows": 2, "placeholder": "Add a note about this assignment (optional)..."}
),
)

View File

@ -1,6 +1,7 @@
"""
Feedback views - Server-rendered templates for feedback management
"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
@ -42,15 +43,14 @@ def feedback_list(request):
"""
# Base queryset with optimizations
queryset = Feedback.objects.select_related(
'patient', 'hospital', 'department', 'staff',
'assigned_to', 'reviewed_by', 'acknowledged_by', 'closed_by'
"patient", "hospital", "department", "staff", "assigned_to", "reviewed_by", "acknowledged_by", "closed_by"
).filter(is_deleted=False)
# Apply RBAC filters
user = request.user
# Get selected hospital for PX Admins (from middleware)
selected_hospital = getattr(request, 'tenant_hospital', None)
selected_hospital = getattr(request, "tenant_hospital", None)
if user.is_px_admin():
# PX Admins see all, but filter by selected hospital if set
if selected_hospital:
@ -65,95 +65,90 @@ def feedback_list(request):
queryset = queryset.none()
# Apply filters from request
feedback_type_filter = request.GET.get('feedback_type')
feedback_type_filter = request.GET.get("feedback_type")
if feedback_type_filter:
queryset = queryset.filter(feedback_type=feedback_type_filter)
status_filter = request.GET.get('status')
status_filter = request.GET.get("status")
if status_filter:
queryset = queryset.filter(status=status_filter)
category_filter = request.GET.get('category')
category_filter = request.GET.get("category")
if category_filter:
queryset = queryset.filter(category=category_filter)
sentiment_filter = request.GET.get('sentiment')
sentiment_filter = request.GET.get("sentiment")
if sentiment_filter:
queryset = queryset.filter(sentiment=sentiment_filter)
priority_filter = request.GET.get('priority')
priority_filter = request.GET.get("priority")
if priority_filter:
queryset = queryset.filter(priority=priority_filter)
hospital_filter = request.GET.get('hospital')
hospital_filter = request.GET.get("hospital")
if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter)
department_filter = request.GET.get('department')
department_filter = request.GET.get("department")
if department_filter:
queryset = queryset.filter(department_id=department_filter)
staff_filter = request.GET.get('staff')
staff_filter = request.GET.get("staff")
if staff_filter:
queryset = queryset.filter(staff_id=staff_filter)
assigned_to_filter = request.GET.get('assigned_to')
assigned_to_filter = request.GET.get("assigned_to")
if assigned_to_filter:
queryset = queryset.filter(assigned_to_id=assigned_to_filter)
rating_min = request.GET.get('rating_min')
rating_min = request.GET.get("rating_min")
if rating_min:
queryset = queryset.filter(rating__gte=rating_min)
rating_max = request.GET.get('rating_max')
rating_max = request.GET.get("rating_max")
if rating_max:
queryset = queryset.filter(rating__lte=rating_max)
is_featured = request.GET.get('is_featured')
if is_featured == 'true':
is_featured = request.GET.get("is_featured")
if is_featured == "true":
queryset = queryset.filter(is_featured=True)
requires_follow_up = request.GET.get('requires_follow_up')
if requires_follow_up == 'true':
requires_follow_up = request.GET.get("requires_follow_up")
if requires_follow_up == "true":
queryset = queryset.filter(requires_follow_up=True)
# Search
search_query = request.GET.get('search')
search_query = request.GET.get("search")
if search_query:
queryset = queryset.filter(
Q(title__icontains=search_query) |
Q(message__icontains=search_query) |
Q(patient__mrn__icontains=search_query) |
Q(patient__first_name__icontains=search_query) |
Q(patient__last_name__icontains=search_query) |
Q(contact_name__icontains=search_query)
Q(title__icontains=search_query)
| Q(message__icontains=search_query)
| Q(patient__mrn__icontains=search_query)
| Q(patient__first_name__icontains=search_query)
| Q(patient__last_name__icontains=search_query)
| Q(contact_name__icontains=search_query)
)
# Date range filters
date_from = request.GET.get('date_from')
date_from = request.GET.get("date_from")
if date_from:
queryset = queryset.filter(created_at__gte=date_from)
date_to = request.GET.get('date_to')
date_to = request.GET.get("date_to")
if date_to:
queryset = queryset.filter(created_at__lte=date_to)
# Ordering
order_by = request.GET.get('order_by', '-created_at')
order_by = request.GET.get("order_by", "-created_at")
queryset = queryset.order_by(order_by)
# Pagination
page_size = int(request.GET.get('page_size', 25))
page_size = int(request.GET.get("page_size", 25))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get('page', 1)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
# Get filter options
hospitals = Hospital.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
departments = Department.objects.filter(status='active')
departments = Department.objects.filter(status="active")
if not user.is_px_admin() and user.hospital:
departments = departments.filter(hospital=user.hospital)
@ -164,31 +159,30 @@ def feedback_list(request):
# Statistics
stats = {
'total': queryset.count(),
'submitted': queryset.filter(status=FeedbackStatus.SUBMITTED).count(),
'reviewed': queryset.filter(status=FeedbackStatus.REVIEWED).count(),
'acknowledged': queryset.filter(status=FeedbackStatus.ACKNOWLEDGED).count(),
'compliments': queryset.filter(feedback_type=FeedbackType.COMPLIMENT).count(),
'suggestions': queryset.filter(feedback_type=FeedbackType.SUGGESTION).count(),
'avg_rating': queryset.aggregate(Avg('rating'))['rating__avg'] or 0,
'positive': queryset.filter(sentiment='positive').count(),
'negative': queryset.filter(sentiment='negative').count(),
"total": queryset.count(),
"submitted": queryset.filter(status=FeedbackStatus.SUBMITTED).count(),
"reviewed": queryset.filter(status=FeedbackStatus.REVIEWED).count(),
"acknowledged": queryset.filter(status=FeedbackStatus.ACKNOWLEDGED).count(),
"compliments": queryset.filter(feedback_type=FeedbackType.COMPLIMENT).count(),
"suggestions": queryset.filter(feedback_type=FeedbackType.SUGGESTION).count(),
"avg_rating": queryset.aggregate(Avg("rating"))["rating__avg"] or 0,
"positive": queryset.filter(sentiment="positive").count(),
"negative": queryset.filter(sentiment="negative").count(),
}
context = {
'page_obj': page_obj,
'feedbacks': page_obj.object_list,
'stats': stats,
'hospitals': hospitals,
'departments': departments,
'assignable_users': assignable_users,
'status_choices': FeedbackStatus.choices,
'type_choices': FeedbackType.choices,
'category_choices': FeedbackCategory.choices,
'filters': request.GET,
"page_obj": page_obj,
"feedbacks": page_obj.object_list,
"stats": stats,
"departments": departments,
"assignable_users": assignable_users,
"status_choices": FeedbackStatus.choices,
"type_choices": FeedbackType.choices,
"category_choices": FeedbackCategory.choices,
"filters": request.GET,
}
return render(request, 'feedback/feedback_list.html', context)
return render(request, "feedback/feedback_list.html", context)
@login_required
@ -204,14 +198,10 @@ def feedback_detail(request, pk):
"""
feedback = get_object_or_404(
Feedback.objects.select_related(
'patient', 'hospital', 'department', 'staff',
'assigned_to', 'reviewed_by', 'acknowledged_by', 'closed_by'
).prefetch_related(
'attachments',
'responses__created_by'
),
"patient", "hospital", "department", "staff", "assigned_to", "reviewed_by", "acknowledged_by", "closed_by"
).prefetch_related("attachments", "responses__created_by"),
pk=pk,
is_deleted=False
is_deleted=False,
)
# Check access
@ -219,19 +209,19 @@ def feedback_detail(request, pk):
if not user.is_px_admin():
if user.is_hospital_admin() and feedback.hospital != user.hospital:
messages.error(request, "You don't have permission to view this feedback.")
return redirect('feedback:feedback_list')
return redirect("feedback:feedback_list")
elif user.is_department_manager() and feedback.department != user.department:
messages.error(request, "You don't have permission to view this feedback.")
return redirect('feedback:feedback_list')
return redirect("feedback:feedback_list")
elif user.hospital and feedback.hospital != user.hospital:
messages.error(request, "You don't have permission to view this feedback.")
return redirect('feedback:feedback_list')
return redirect("feedback:feedback_list")
# Get timeline (responses)
timeline = feedback.responses.all().order_by('-created_at')
timeline = feedback.responses.all().order_by("-created_at")
# Get attachments
attachments = feedback.attachments.all().order_by('-created_at')
attachments = feedback.attachments.all().order_by("-created_at")
# Get assignable users
assignable_users = User.objects.filter(is_active=True)
@ -239,77 +229,77 @@ def feedback_detail(request, pk):
assignable_users = assignable_users.filter(hospital=feedback.hospital)
context = {
'feedback': feedback,
'timeline': timeline,
'attachments': attachments,
'assignable_users': assignable_users,
'status_choices': FeedbackStatus.choices,
'can_edit': user.is_px_admin() or user.is_hospital_admin(),
"feedback": feedback,
"timeline": timeline,
"attachments": attachments,
"assignable_users": assignable_users,
"status_choices": FeedbackStatus.choices,
"can_edit": user.is_px_admin() or user.is_hospital_admin(),
}
return render(request, 'feedback/feedback_detail.html', context)
return render(request, "feedback/feedback_detail.html", context)
@login_required
@require_http_methods(["GET", "POST"])
def feedback_create(request):
"""Create new feedback"""
if request.method == 'POST':
form = FeedbackForm(request.POST, user=request.user)
if request.method == "POST":
form = FeedbackForm(request.POST, request=request)
if form.is_valid():
try:
feedback = form.save(commit=False)
# Set default sentiment if not set
if not feedback.sentiment:
feedback.sentiment = 'neutral'
feedback.sentiment = "neutral"
feedback.save()
# Create initial response
FeedbackResponse.objects.create(
feedback=feedback,
response_type='note',
response_type="note",
message=f"Feedback submitted by {request.user.get_full_name()}",
created_by=request.user,
is_internal=True
is_internal=True,
)
# Log audit
AuditService.log_event(
event_type='feedback_created',
event_type="feedback_created",
description=f"Feedback created: {feedback.title}",
user=request.user,
content_object=feedback,
metadata={
'feedback_type': feedback.feedback_type,
'category': feedback.category,
'rating': feedback.rating
}
"feedback_type": feedback.feedback_type,
"category": feedback.category,
"rating": feedback.rating,
},
)
messages.success(request, f"Feedback #{feedback.id} created successfully.")
return redirect('feedback:feedback_detail', pk=feedback.id)
return redirect("feedback:feedback_detail", pk=feedback.id)
except Exception as e:
messages.error(request, f"Error creating feedback: {str(e)}")
else:
messages.error(request, "Please correct the errors below.")
else:
form = FeedbackForm(user=request.user)
form = FeedbackForm(request=request)
# Get patients for selection
patients = Patient.objects.filter(status='active')
patients = Patient.objects.filter(status="active")
if request.user.hospital:
patients = patients.filter(primary_hospital=request.user.hospital)
context = {
'form': form,
'patients': patients,
'is_create': True,
"form": form,
"patients": patients,
"is_create": True,
}
return render(request, 'feedback/feedback_form.html', context)
return render(request, "feedback/feedback_form.html", context)
@login_required
@ -322,10 +312,10 @@ def feedback_update(request, pk):
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to edit feedback.")
return redirect('feedback:feedback_detail', pk=pk)
return redirect("feedback:feedback_detail", pk=pk)
if request.method == 'POST':
form = FeedbackForm(request.POST, instance=feedback, user=request.user)
if request.method == "POST":
form = FeedbackForm(request.POST, instance=feedback, request=request)
if form.is_valid():
try:
feedback = form.save()
@ -333,43 +323,43 @@ def feedback_update(request, pk):
# Create update response
FeedbackResponse.objects.create(
feedback=feedback,
response_type='note',
response_type="note",
message=f"Feedback updated by {request.user.get_full_name()}",
created_by=request.user,
is_internal=True
is_internal=True,
)
# Log audit
AuditService.log_event(
event_type='feedback_updated',
event_type="feedback_updated",
description=f"Feedback updated: {feedback.title}",
user=request.user,
content_object=feedback
content_object=feedback,
)
messages.success(request, "Feedback updated successfully.")
return redirect('feedback:feedback_detail', pk=feedback.id)
return redirect("feedback:feedback_detail", pk=feedback.id)
except Exception as e:
messages.error(request, f"Error updating feedback: {str(e)}")
else:
messages.error(request, "Please correct the errors below.")
else:
form = FeedbackForm(instance=feedback, user=request.user)
form = FeedbackForm(instance=feedback, request=request)
# Get patients for selection
patients = Patient.objects.filter(status='active')
patients = Patient.objects.filter(status="active")
if request.user.hospital:
patients = patients.filter(primary_hospital=request.user.hospital)
context = {
'form': form,
'feedback': feedback,
'patients': patients,
'is_create': False,
"form": form,
"feedback": feedback,
"patients": patients,
"is_create": False,
}
return render(request, 'feedback/feedback_form.html', context)
return render(request, "feedback/feedback_form.html", context)
@login_required
@ -382,32 +372,32 @@ def feedback_delete(request, pk):
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to delete feedback.")
return redirect('feedback:feedback_detail', pk=pk)
return redirect("feedback:feedback_detail", pk=pk)
if request.method == 'POST':
if request.method == "POST":
try:
feedback.soft_delete(user=request.user)
# Log audit
AuditService.log_event(
event_type='feedback_deleted',
event_type="feedback_deleted",
description=f"Feedback deleted: {feedback.title}",
user=request.user,
content_object=feedback
content_object=feedback,
)
messages.success(request, "Feedback deleted successfully.")
return redirect('feedback:feedback_list')
return redirect("feedback:feedback_list")
except Exception as e:
messages.error(request, f"Error deleting feedback: {str(e)}")
return redirect('feedback:feedback_detail', pk=pk)
return redirect("feedback:feedback_detail", pk=pk)
context = {
'feedback': feedback,
"feedback": feedback,
}
return render(request, 'feedback/feedback_delete_confirm.html', context)
return render(request, "feedback/feedback_delete_confirm.html", context)
@login_required
@ -420,20 +410,20 @@ def feedback_assign(request, pk):
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to assign feedback.")
return redirect('feedback:feedback_detail', pk=pk)
return redirect("feedback:feedback_detail", pk=pk)
user_id = request.POST.get('user_id')
note = request.POST.get('note', '')
user_id = request.POST.get("user_id")
note = request.POST.get("note", "")
if not user_id:
messages.error(request, "Please select a user to assign.")
return redirect('feedback:feedback_detail', pk=pk)
return redirect("feedback:feedback_detail", pk=pk)
try:
assignee = User.objects.get(id=user_id)
feedback.assigned_to = assignee
feedback.assigned_at = timezone.now()
feedback.save(update_fields=['assigned_to', 'assigned_at'])
feedback.save(update_fields=["assigned_to", "assigned_at"])
# Create response
message = f"Assigned to {assignee.get_full_name()}"
@ -441,19 +431,15 @@ def feedback_assign(request, pk):
message += f"\nNote: {note}"
FeedbackResponse.objects.create(
feedback=feedback,
response_type='assignment',
message=message,
created_by=request.user,
is_internal=True
feedback=feedback, response_type="assignment", message=message, created_by=request.user, is_internal=True
)
# Log audit
AuditService.log_event(
event_type='assignment',
event_type="assignment",
description=f"Feedback assigned to {assignee.get_full_name()}",
user=request.user,
content_object=feedback
content_object=feedback,
)
messages.success(request, f"Feedback assigned to {assignee.get_full_name()}.")
@ -461,7 +447,7 @@ def feedback_assign(request, pk):
except User.DoesNotExist:
messages.error(request, "User not found.")
return redirect('feedback:feedback_detail', pk=pk)
return redirect("feedback:feedback_detail", pk=pk)
@login_required
@ -474,14 +460,14 @@ def feedback_change_status(request, pk):
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to change feedback status.")
return redirect('feedback:feedback_detail', pk=pk)
return redirect("feedback:feedback_detail", pk=pk)
new_status = request.POST.get('status')
note = request.POST.get('note', '')
new_status = request.POST.get("status")
note = request.POST.get("note", "")
if not new_status:
messages.error(request, "Please select a status.")
return redirect('feedback:feedback_detail', pk=pk)
return redirect("feedback:feedback_detail", pk=pk)
old_status = feedback.status
feedback.status = new_status
@ -504,25 +490,25 @@ def feedback_change_status(request, pk):
FeedbackResponse.objects.create(
feedback=feedback,
response_type='status_change',
response_type="status_change",
message=message,
created_by=request.user,
old_status=old_status,
new_status=new_status,
is_internal=True
is_internal=True,
)
# Log audit
AuditService.log_event(
event_type='status_change',
event_type="status_change",
description=f"Feedback status changed from {old_status} to {new_status}",
user=request.user,
content_object=feedback,
metadata={'old_status': old_status, 'new_status': new_status}
metadata={"old_status": old_status, "new_status": new_status},
)
messages.success(request, f"Feedback status changed to {new_status}.")
return redirect('feedback:feedback_detail', pk=pk)
return redirect("feedback:feedback_detail", pk=pk)
@login_required
@ -531,13 +517,13 @@ def feedback_add_response(request, pk):
"""Add response to feedback"""
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
response_type = request.POST.get('response_type', 'response')
message = request.POST.get('message')
is_internal = request.POST.get('is_internal') == 'on'
response_type = request.POST.get("response_type", "response")
message = request.POST.get("message")
is_internal = request.POST.get("is_internal") == "on"
if not message:
messages.error(request, "Please enter a response message.")
return redirect('feedback:feedback_detail', pk=pk)
return redirect("feedback:feedback_detail", pk=pk)
# Create response
FeedbackResponse.objects.create(
@ -545,11 +531,11 @@ def feedback_add_response(request, pk):
response_type=response_type,
message=message,
created_by=request.user,
is_internal=is_internal
is_internal=is_internal,
)
messages.success(request, "Response added successfully.")
return redirect('feedback:feedback_detail', pk=pk)
return redirect("feedback:feedback_detail", pk=pk)
@login_required
@ -562,15 +548,15 @@ def feedback_toggle_featured(request, pk):
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to feature feedback.")
return redirect('feedback:feedback_detail', pk=pk)
return redirect("feedback:feedback_detail", pk=pk)
feedback.is_featured = not feedback.is_featured
feedback.save(update_fields=['is_featured'])
feedback.save(update_fields=["is_featured"])
status = "featured" if feedback.is_featured else "unfeatured"
messages.success(request, f"Feedback {status} successfully.")
return redirect('feedback:feedback_detail', pk=pk)
return redirect("feedback:feedback_detail", pk=pk)
@login_required
@ -583,12 +569,12 @@ def feedback_toggle_follow_up(request, pk):
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to modify feedback.")
return redirect('feedback:feedback_detail', pk=pk)
return redirect("feedback:feedback_detail", pk=pk)
feedback.requires_follow_up = not feedback.requires_follow_up
feedback.save(update_fields=['requires_follow_up'])
feedback.save(update_fields=["requires_follow_up"])
status = "marked for follow-up" if feedback.requires_follow_up else "unmarked for follow-up"
messages.success(request, f"Feedback {status} successfully.")
return redirect('feedback:feedback_detail', pk=pk)
return redirect("feedback:feedback_detail", pk=pk)

View File

@ -257,7 +257,13 @@ class SurveyTemplateMapping(UUIDModel, TimeStampedModel):
db_index=True,
help_text="Whether this mapping is active"
)
# Delay configuration
send_delay_hours = models.IntegerField(
default=1,
help_text="Hours after discharge to send survey"
)
class Meta:
ordering = ['hospital', 'patient_type']
indexes = [

View File

@ -7,11 +7,13 @@ internal format for sending surveys based on PatientType.
Simplified Flow:
1. Parse HIS patient data
2. Determine survey type from PatientType
3. Create survey instance
4. Send survey via SMS
3. Create survey instance with PENDING status
4. Queue delayed send task
5. Survey sent after delay (e.g., 1 hour for OPD)
"""
from datetime import datetime
from datetime import datetime, timedelta
from typing import Dict, Optional, Tuple
import logging
from django.utils import timezone
@ -19,6 +21,8 @@ from apps.organizations.models import Hospital, Patient
from apps.surveys.models import SurveyTemplate, SurveyInstance, SurveyStatus
from apps.integrations.models import InboundEvent
logger = logging.getLogger(__name__)
class HISAdapter:
"""
@ -172,25 +176,73 @@ class HISAdapter:
def get_survey_template(patient_type: str, hospital: Hospital) -> Optional[SurveyTemplate]:
"""
Get appropriate survey template based on PatientType using explicit mapping.
Uses SurveyTemplateMapping to determine which template to send.
Args:
patient_type: HIS PatientType code (1, 2, 3, 4, O, E, APPOINTMENT)
hospital: Hospital instance
Returns:
SurveyTemplate or None if not found
"""
from apps.integrations.models import SurveyTemplateMapping
# Use explicit mapping to get template
survey_template = SurveyTemplateMapping.get_template_for_patient_type(
patient_type, hospital
)
return survey_template
@staticmethod
def get_delay_for_patient_type(patient_type: str, hospital) -> int:
"""
Get delay hours from SurveyTemplateMapping.
Falls back to default delays if no mapping found.
Args:
patient_type: HIS PatientType code (1, 2, 3, 4, O, E)
hospital: Hospital instance
Returns:
Delay in hours
"""
from apps.integrations.models import SurveyTemplateMapping
# Try to get mapping with delay (hospital-specific)
mapping = SurveyTemplateMapping.objects.filter(
patient_type=patient_type,
hospital=hospital,
is_active=True
).first()
if mapping and mapping.send_delay_hours:
return mapping.send_delay_hours
# Fallback to global mapping
mapping = SurveyTemplateMapping.objects.filter(
patient_type=patient_type,
hospital__isnull=True,
is_active=True
).first()
if mapping and mapping.send_delay_hours:
return mapping.send_delay_hours
# Default delays by patient type
default_delays = {
'1': 24, # Inpatient - 24 hours
'2': 1, # OPD - 1 hour
'3': 2, # EMS - 2 hours
'O': 1, # OPD - 1 hour
'E': 2, # EMS - 2 hours
'4': 4, # Daycase - 4 hours
}
return default_delays.get(patient_type, 1) # Default 1 hour
@staticmethod
def create_and_send_survey(
patient: Patient,
@ -199,20 +251,24 @@ class HISAdapter:
survey_template: SurveyTemplate
) -> Optional[SurveyInstance]:
"""
Create survey instance and send via SMS.
Create survey instance and queue for delayed sending.
NEW: Survey is created with PENDING status and sent after delay.
Args:
patient: Patient instance
hospital: Hospital instance
patient_data: HIS patient data
survey_template: SurveyTemplate instance
Returns:
SurveyInstance or None if failed
"""
from apps.surveys.tasks import send_scheduled_survey
admission_id = patient_data.get("AdmissionID")
discharge_date_str = patient_data.get("DischargeDate")
discharge_date = HISAdapter.parse_date(discharge_date_str) if discharge_date_str else None
patient_type = patient_data.get("PatientType")
# Check if survey already sent for this admission
existing_survey = SurveyInstance.objects.filter(
@ -222,58 +278,65 @@ class HISAdapter:
).first()
if existing_survey:
logger.info(f"Survey already exists for admission {admission_id}")
return existing_survey
# Create survey instance
# Get delay from SurveyTemplateMapping
delay_hours = HISAdapter.get_delay_for_patient_type(patient_type, hospital)
# Calculate scheduled send time
scheduled_send_at = timezone.now() + timedelta(hours=delay_hours)
# Create survey with PENDING status (NOT SENT)
survey = SurveyInstance.objects.create(
survey_template=survey_template,
patient=patient,
hospital=hospital,
status=SurveyStatus.SENT, # Set to SENT as it will be sent immediately
delivery_channel="SMS", # Send via SMS
status=SurveyStatus.PENDING, # Changed from SENT
delivery_channel="SMS",
recipient_phone=patient.phone,
recipient_email=patient.email,
scheduled_send_at=scheduled_send_at,
metadata={
'admission_id': admission_id,
'patient_type': patient_data.get("PatientType"),
'patient_type': patient_type,
'hospital_id': patient_data.get("HospitalID"),
'insurance_company': patient_data.get("InsuranceCompanyName"),
'is_vip': patient_data.get("IsVIP") == "1"
'is_vip': patient_data.get("IsVIP") == "1",
'discharge_date': discharge_date_str,
'scheduled_send_at': scheduled_send_at.isoformat(),
'delay_hours': delay_hours,
}
)
# Send survey via SMS
try:
from apps.surveys.services import SurveyDeliveryService
delivery_success = SurveyDeliveryService.deliver_survey(survey)
if delivery_success:
return survey
else:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Survey created but SMS delivery failed for survey {survey.id}")
return survey
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error sending survey SMS: {str(e)}", exc_info=True)
return survey
# Queue delayed send task
send_scheduled_survey.apply_async(
args=[str(survey.id)],
countdown=delay_hours * 3600 # Convert to seconds
)
logger.info(
f"Survey {survey.id} created for {patient_type}, "
f"will send in {delay_hours}h at {scheduled_send_at}"
)
return survey
@staticmethod
def process_his_data(his_data: Dict) -> Dict:
"""
Main method to process HIS patient data and send surveys.
Simplified Flow:
1. Extract patient data
2. Get or create patient and hospital
3. Determine survey type from PatientType
4. Create and send survey via SMS
4. Create survey with PENDING status
5. Queue delayed send task
Args:
his_data: HIS data in real format
Returns:
Dict with processing results
"""
@ -282,77 +345,76 @@ class HISAdapter:
'message': '',
'patient': None,
'survey': None,
'survey_sent': False
'survey_queued': False
}
try:
# Extract patient data
patient_list = his_data.get("FetchPatientDataTimeStampList", [])
if not patient_list:
result['message'] = "No patient data found"
return result
patient_data = patient_list[0]
# Validate status
if his_data.get("Code") != 200 or his_data.get("Status") != "Success":
result['message'] = f"HIS Error: {his_data.get('Message', 'Unknown error')}"
return result
# Check if patient is discharged (required for ALL patient types)
patient_type = patient_data.get("PatientType")
discharge_date_str = patient_data.get("DischargeDate")
# All patient types require discharge date
if not discharge_date_str:
result['message'] = f'Patient type {patient_type} not discharged - no survey sent'
result['success'] = True # Not an error, just no action needed
return result
# Get or create hospital
hospital = HISAdapter.get_or_create_hospital(patient_data)
if not hospital:
result['message'] = "Could not determine hospital"
return result
# Get or create patient
patient = HISAdapter.get_or_create_patient(patient_data, hospital)
# Get survey template based on PatientType
patient_type = patient_data.get("PatientType")
survey_template = HISAdapter.get_survey_template(patient_type, hospital)
if not survey_template:
result['message'] = f"No survey template found for patient type '{patient_type}'"
return result
# Create and send survey
# Create and queue survey (delayed sending)
survey = HISAdapter.create_and_send_survey(
patient, hospital, patient_data, survey_template
)
if survey:
from apps.surveys.models import SurveyStatus
survey_sent = survey.status == SurveyStatus.SENT
# Survey is queued with PENDING status
survey_queued = survey.status == SurveyStatus.PENDING
else:
survey_sent = False
survey_queued = False
result.update({
'success': True,
'message': 'Patient data processed successfully',
'patient': patient,
'patient_type': patient_type,
'survey': survey,
'survey_sent': survey_sent,
'survey_queued': survey_queued,
'scheduled_send_at': survey.scheduled_send_at.isoformat() if survey and survey.scheduled_send_at else None,
'survey_url': survey.get_survey_url() if survey else None
})
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error processing HIS data: {str(e)}", exc_info=True)
result['message'] = f"Error processing HIS data: {str(e)}"
result['success'] = False
return result

View File

@ -68,7 +68,7 @@ class HISClient:
"""Get API URL from configuration or environment."""
if not self.config:
# Fallback to environment variable
return os.getenv("HIS_API_URL", "")
return os.getenv("HIS_API_URL", "https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps")
return self.config.api_url
def _get_auth(self) -> Optional[tuple]:

View File

@ -208,6 +208,127 @@ def process_pending_events():
# =============================================================================
@shared_task
def test_fetch_his_surveys_from_json():
"""
TEST TASK - Fetch surveys from local JSON file instead of HIS API.
This is a clone of fetch_his_surveys for testing purposes.
Reads from /home/ismail/projects/HH/data.json
TODO: Remove this task after testing is complete.
Returns:
dict: Summary of fetched and processed surveys
"""
import json
from pathlib import Path
from apps.integrations.services.his_adapter import HISAdapter
logger.info("Starting TEST HIS survey fetch from JSON file")
result = {
"success": False,
"patients_fetched": 0,
"surveys_created": 0,
"surveys_queued": 0,
"errors": [],
"details": [],
}
try:
# Read JSON file
json_path = Path("/home/ismail/projects/HH/data.json")
if not json_path.exists():
error_msg = f"JSON file not found: {json_path}"
logger.error(error_msg)
result["errors"].append(error_msg)
return result
with open(json_path, 'r') as f:
his_data = json.load(f)
# Extract patient list
patient_list = his_data.get("FetchPatientDataTimeStampList", [])
if not patient_list:
logger.warning("No patient data found in JSON file")
result["errors"].append("No patient data found")
return result
logger.info(f"Found {len(patient_list)} patients in JSON file")
result["patients_fetched"] = len(patient_list)
# Process each patient
for patient_data in patient_list:
try:
# Wrap in proper format for HISAdapter
patient_payload = {
"FetchPatientDataTimeStampList": [patient_data],
"FetchPatientDataTimeStampVisitDataList": [],
"Code": 200,
"Status": "Success",
}
# Process using HISAdapter
process_result = HISAdapter.process_his_data(patient_payload)
if process_result["success"]:
result["surveys_created"] += 1
if process_result.get("survey_queued"):
result["surveys_queued"] += 1
# Log survey details
survey = process_result.get("survey")
if survey:
logger.info(
f"Survey queued for {patient_data.get('PatientName')}: "
f"Type={patient_data.get('PatientType')}, "
f"Scheduled={survey.scheduled_send_at}, "
f"Delay={process_result.get('metadata', {}).get('delay_hours', 'N/A')}h"
)
else:
logger.info(
f"Survey created but not queued for {patient_data.get('PatientName')}"
)
else:
# Not an error - patient may not be discharged
if "not discharged" in process_result.get("message", ""):
logger.debug(
f"Skipping {patient_data.get('PatientName')}: Not discharged"
)
else:
logger.warning(
f"Failed to process {patient_data.get('PatientName')}: "
f"{process_result.get('message', 'Unknown error')}"
)
result["errors"].append(
f"{patient_data.get('PatientName')}: {process_result.get('message')}"
)
except Exception as e:
error_msg = f"Error processing patient {patient_data.get('PatientName', 'Unknown')}: {str(e)}"
logger.error(error_msg, exc_info=True)
result["errors"].append(error_msg)
result["success"] = True
logger.info(
f"TEST HIS survey fetch completed: "
f"{result['patients_fetched']} patients, "
f"{result['surveys_created']} surveys created, "
f"{result['surveys_queued']} surveys queued"
)
except Exception as e:
error_msg = f"Fatal error in test_fetch_his_surveys_from_json: {str(e)}"
logger.error(error_msg, exc_info=True)
result["errors"].append(error_msg)
return result
@shared_task
def fetch_his_surveys():
"""

View File

@ -1,6 +1,7 @@
"""
Integrations UI Views
"""
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
@ -15,92 +16,93 @@ from .serializers import SurveyTemplateMappingSerializer
class SurveyTemplateMappingViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing survey template mappings.
Provides CRUD operations for mapping patient types to survey templates.
"""
queryset = SurveyTemplateMapping.objects.select_related(
'hospital', 'survey_template'
).all()
queryset = SurveyTemplateMapping.objects.select_related("hospital", "survey_template").all()
serializer_class = SurveyTemplateMappingSerializer
filterset_fields = ['hospital', 'patient_type', 'is_active']
search_fields = ['hospital__name', 'patient_type', 'survey_template__name']
ordering_fields = ['hospital', 'patient_type', 'is_active']
ordering = ['hospital', 'patient_type', 'is_active']
filterset_fields = ["hospital", "patient_type", "is_active"]
search_fields = ["hospital__name", "patient_type", "survey_template__name"]
ordering_fields = ["hospital", "patient_type", "is_active"]
ordering = ["hospital", "patient_type", "is_active"]
def get_queryset(self):
"""
Filter mappings by user's accessible hospitals.
"""
queryset = super().get_queryset()
user = self.request.user
# If user is not superuser, filter by their hospital
if not user.is_superuser and user.hospital:
queryset = queryset.filter(hospital=user.hospital)
elif not user.is_superuser and not user.hospital:
# User without hospital assignment - no access
queryset = queryset.none()
return queryset
# Superusers see all mappings
if user.is_superuser:
return queryset
# PX Admins filter by session hospital
if user.is_px_admin():
tenant_hospital = getattr(self.request, "tenant_hospital", None)
if tenant_hospital:
return queryset.filter(hospital=tenant_hospital)
# If no session hospital, show all (for management)
return queryset
# Hospital users filter by their assigned hospital
if hasattr(user, "hospital") and user.hospital:
return queryset.filter(hospital=user.hospital)
# User without hospital assignment - no access
return queryset.none()
def perform_create(self, serializer):
"""Add created_by information"""
serializer.save()
@action(detail=False, methods=['get'])
@action(detail=False, methods=["get"])
def by_hospital(self, request):
"""
Get all mappings for a specific hospital.
Query param: hospital_id
"""
hospital_id = request.query_params.get('hospital_id')
hospital_id = request.query_params.get("hospital_id")
if not hospital_id:
return Response(
{'error': 'hospital_id parameter is required'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "hospital_id parameter is required"}, status=status.HTTP_400_BAD_REQUEST)
mappings = self.get_queryset().filter(hospital_id=hospital_id)
serializer = self.get_serializer(mappings, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
@action(detail=False, methods=["get"])
def by_patient_type(self, request):
"""
Get mapping for a specific patient type and hospital.
Query params: hospital_id, patient_type_code
"""
hospital_id = request.query_params.get('hospital_id')
patient_type_code = request.query_params.get('patient_type_code')
hospital_id = request.query_params.get("hospital_id")
patient_type_code = request.query_params.get("patient_type_code")
if not hospital_id or not patient_type_code:
return Response(
{'error': 'hospital_id and patient_type_code parameters are required'},
status=status.HTTP_400_BAD_REQUEST
{"error": "hospital_id and patient_type_code parameters are required"},
status=status.HTTP_400_BAD_REQUEST,
)
mapping = SurveyTemplateMapping.get_template_for_patient_type(
patient_type_code, hospital_id
)
mapping = SurveyTemplateMapping.get_template_for_patient_type(patient_type_code, hospital_id)
if not mapping:
return Response(
{'error': 'No mapping found'},
status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "No mapping found"}, status=status.HTTP_404_NOT_FOUND)
serializer = self.get_serializer(mapping)
return Response(serializer.data)
@action(detail=False, methods=['post'])
@action(detail=False, methods=["post"])
def bulk_create(self, request):
"""
Create multiple mappings at once.
Expects: {
"mappings": [
{
@ -114,72 +116,92 @@ class SurveyTemplateMappingViewSet(viewsets.ModelViewSet):
]
}
"""
mappings_data = request.data.get('mappings', [])
mappings_data = request.data.get("mappings", [])
if not mappings_data:
return Response(
{'error': 'mappings array is required'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "mappings array is required"}, status=status.HTTP_400_BAD_REQUEST)
created_mappings = []
for mapping_data in mappings_data:
serializer = self.get_serializer(data=mapping_data)
if serializer.is_valid():
serializer.save()
created_mappings.append(serializer.data)
return Response(
{'created': len(created_mappings), 'mappings': created_mappings},
status=status.HTTP_201_CREATED
{"created": len(created_mappings), "mappings": created_mappings}, status=status.HTTP_201_CREATED
)
def survey_mapping_settings(request):
"""
Survey mapping settings page.
Allows administrators to configure which survey templates
are sent for each patient type at each hospital.
"""
from apps.organizations.models import Hospital
user = request.user
# Determine user's hospital context
user_hospital = None
if user.is_px_admin():
user_hospital = getattr(request, "tenant_hospital", None)
elif user.hospital:
user_hospital = user.hospital
# Get user's accessible hospitals based on role
if user.is_superuser:
# Superusers can see all hospitals
hospitals = Hospital.objects.all()
hospitals = Hospital.objects.filter(status="active")
elif user.is_px_admin():
# PX Admins see all active hospitals for the dropdown
# They use session-based hospital selection (request.tenant_hospital)
hospitals = Hospital.objects.filter(status="active")
elif user.hospital:
# Regular users can only see their assigned hospital
hospitals = Hospital.objects.filter(id=user.hospital.id)
else:
# User without hospital assignment - no access
hospitals = []
# Get all mappings
hospitals = Hospital.objects.none()
# Get all mappings based on user role
if user.is_superuser:
mappings = SurveyTemplateMapping.objects.select_related(
'hospital', 'survey_template'
).all()
# Superusers see all mappings
mappings = SurveyTemplateMapping.objects.select_related("hospital", "survey_template").all()
elif user.is_px_admin():
# PX Admins filter by session hospital
if user_hospital:
mappings = SurveyTemplateMapping.objects.filter(hospital=user_hospital).select_related(
"hospital", "survey_template"
)
else:
# No session hospital - show all (for management)
mappings = SurveyTemplateMapping.objects.select_related("hospital", "survey_template").all()
else:
mappings = SurveyTemplateMapping.objects.filter(
hospital__in=hospitals
).select_related('hospital', 'survey_template')
# Regular users see only their hospital's mappings
mappings = SurveyTemplateMapping.objects.filter(hospital__in=hospitals).select_related(
"hospital", "survey_template"
)
# Group mappings by hospital
mappings_by_hospital = {}
for mapping in mappings:
# Skip mappings with missing hospital (orphaned records)
if mapping.hospital is None:
continue
hospital_name = mapping.hospital.name
if hospital_name not in mappings_by_hospital:
mappings_by_hospital[hospital_name] = []
mappings_by_hospital[hospital_name].append(mapping)
context = {
'hospitals': hospitals,
'mappings': mappings,
'mappings_by_hospital': mappings_by_hospital,
'page_title': _('Survey Template Mappings'),
"hospitals": hospitals,
"mappings": mappings,
"mappings_by_hospital": mappings_by_hospital,
"page_title": _("Survey Template Mappings"),
"user_hospital": user_hospital,
}
return render(request, 'integrations/survey_mapping_settings.html', context)
return render(request, "integrations/survey_mapping_settings.html", context)

View File

@ -105,10 +105,6 @@ def journey_instance_list(request):
page_number = request.GET.get('page', 1)
page_obj = paginator.get_page(page_number)
# Get filter options
hospitals = Hospital.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
departments = Department.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
@ -125,7 +121,6 @@ def journey_instance_list(request):
'page_obj': page_obj,
'journeys': page_obj.object_list,
'stats': stats,
'hospitals': hospitals,
'departments': departments,
'filters': request.GET,
}
@ -221,15 +216,10 @@ def journey_template_list(request):
page_number = request.GET.get('page', 1)
page_obj = paginator.get_page(page_number)
# Get filter options
hospitals = Hospital.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
context = {
'page_obj': page_obj,
'templates': page_obj.object_list,
'hospitals': hospitals,
'filters': request.GET,
}

View File

@ -1,171 +1,161 @@
"""
Organizations forms - Patient, Staff, Department management
"""
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.organizations.models import Patient, Staff, Department, Hospital
from apps.core.form_mixins import HospitalFieldMixin, DepartmentFieldMixin
class PatientForm(forms.ModelForm):
"""Form for creating and editing patients"""
class Meta:
model = Patient
fields = [
'mrn', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar',
'national_id', 'date_of_birth', 'gender',
'phone', 'email', 'address', 'city',
'primary_hospital', 'status'
"mrn",
"first_name",
"last_name",
"first_name_ar",
"last_name_ar",
"national_id",
"date_of_birth",
"gender",
"phone",
"email",
"address",
"city",
"primary_hospital",
"status",
]
widgets = {
'mrn': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'e.g., PTN-20240101-123456'
}),
'first_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'First name in English'
}),
'last_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Last name in English'
}),
'first_name_ar': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'الاسم الأول',
'dir': 'rtl'
}),
'last_name_ar': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'اسم العائلة',
'dir': 'rtl'
}),
'national_id': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'National ID / Iqama number'
}),
'date_of_birth': forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
}),
'gender': forms.Select(attrs={
'class': 'form-select'
}),
'phone': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '+966501234567'
}),
'email': forms.EmailInput(attrs={
'class': 'form-control',
'placeholder': 'patient@example.com'
}),
'address': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'Street address'
}),
'city': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'City'
}),
'primary_hospital': forms.Select(attrs={
'class': 'form-select'
}),
'status': forms.Select(attrs={
'class': 'form-select'
}),
"mrn": forms.TextInput(attrs={"class": "form-control", "placeholder": "e.g., PTN-20240101-123456"}),
"first_name": forms.TextInput(attrs={"class": "form-control", "placeholder": "First name in English"}),
"last_name": forms.TextInput(attrs={"class": "form-control", "placeholder": "Last name in English"}),
"first_name_ar": forms.TextInput(
attrs={"class": "form-control", "placeholder": "الاسم الأول", "dir": "rtl"}
),
"last_name_ar": forms.TextInput(
attrs={"class": "form-control", "placeholder": "اسم العائلة", "dir": "rtl"}
),
"national_id": forms.TextInput(
attrs={"class": "form-control", "placeholder": "National ID / Iqama number"}
),
"date_of_birth": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
"gender": forms.Select(attrs={"class": "form-select"}),
"phone": forms.TextInput(attrs={"class": "form-control", "placeholder": "+966501234567"}),
"email": forms.EmailInput(attrs={"class": "form-control", "placeholder": "patient@example.com"}),
"address": forms.Textarea(attrs={"class": "form-control", "rows": 2, "placeholder": "Street address"}),
"city": forms.TextInput(attrs={"class": "form-control", "placeholder": "City"}),
"primary_hospital": forms.Select(attrs={"class": "form-select"}),
"status": forms.Select(attrs={"class": "form-select"}),
}
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
# Filter hospital choices based on user permissions
if user.hospital and not user.is_px_admin():
self.fields['primary_hospital'].queryset = Hospital.objects.filter(
id=user.hospital.id,
status='active'
)
self.fields['primary_hospital'].initial = user.hospital
self.fields["primary_hospital"].queryset = Hospital.objects.filter(id=user.hospital.id, status="active")
self.fields["primary_hospital"].initial = user.hospital
else:
self.fields['primary_hospital'].queryset = Hospital.objects.filter(status='active')
self.fields["primary_hospital"].queryset = Hospital.objects.filter(status="active")
# Make MRN optional for creation (will auto-generate if empty)
if not self.instance.pk:
self.fields['mrn'].required = False
self.fields['mrn'].help_text = _('Leave blank to auto-generate')
self.fields["mrn"].required = False
self.fields["mrn"].help_text = _("Leave blank to auto-generate")
def clean_mrn(self):
"""Validate MRN is unique"""
mrn = self.cleaned_data.get('mrn')
mrn = self.cleaned_data.get("mrn")
if not mrn:
return mrn
# Check uniqueness (excluding current instance)
queryset = Patient.objects.filter(mrn=mrn)
if self.instance.pk:
queryset = queryset.exclude(pk=self.instance.pk)
if queryset.exists():
raise forms.ValidationError(_('A patient with this MRN already exists.'))
raise forms.ValidationError(_("A patient with this MRN already exists."))
return mrn
def clean_phone(self):
"""Normalize phone number"""
phone = self.cleaned_data.get('phone', '')
phone = self.cleaned_data.get("phone", "")
if phone:
# Remove spaces and dashes
phone = phone.replace(' ', '').replace('-', '')
phone = phone.replace(" ", "").replace("-", "")
return phone
def save(self, commit=True):
"""Auto-generate MRN if not provided"""
instance = super().save(commit=False)
if not instance.mrn:
instance.mrn = Patient.generate_mrn()
if commit:
instance.save()
return instance
class StaffForm(forms.ModelForm):
class StaffForm(HospitalFieldMixin, DepartmentFieldMixin, forms.ModelForm):
"""Form for creating and editing staff"""
class Meta:
model = Staff
fields = [
'employee_id', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar',
'name', 'name_ar', 'staff_type', 'job_title', 'job_title_ar',
'specialization', 'license_number', 'email', 'phone',
'hospital', 'department', 'section_fk', 'subsection_fk',
'report_to', 'is_head', 'gender', 'status'
"employee_id",
"first_name",
"last_name",
"first_name_ar",
"last_name_ar",
"name",
"name_ar",
"staff_type",
"job_title",
"job_title_ar",
"specialization",
"license_number",
"email",
"phone",
"hospital",
"department",
"section_fk",
"subsection_fk",
"report_to",
"is_head",
"gender",
"status",
]
widgets = {
'employee_id': forms.TextInput(attrs={'class': 'form-control'}),
'first_name': forms.TextInput(attrs={'class': 'form-control'}),
'last_name': forms.TextInput(attrs={'class': 'form-control'}),
'first_name_ar': forms.TextInput(attrs={'class': 'form-control', 'dir': 'rtl'}),
'last_name_ar': forms.TextInput(attrs={'class': 'form-control', 'dir': 'rtl'}),
'name': forms.TextInput(attrs={'class': 'form-control'}),
'name_ar': forms.TextInput(attrs={'class': 'form-control', 'dir': 'rtl'}),
'staff_type': forms.Select(attrs={'class': 'form-select'}),
'job_title': forms.TextInput(attrs={'class': 'form-control'}),
'job_title_ar': forms.TextInput(attrs={'class': 'form-control', 'dir': 'rtl'}),
'specialization': forms.TextInput(attrs={'class': 'form-control'}),
'license_number': forms.TextInput(attrs={'class': 'form-control'}),
'email': forms.EmailInput(attrs={'class': 'form-control'}),
'phone': forms.TextInput(attrs={'class': 'form-control'}),
'hospital': forms.Select(attrs={'class': 'form-select'}),
'department': forms.Select(attrs={'class': 'form-select'}),
'section_fk': forms.Select(attrs={'class': 'form-select'}),
'subsection_fk': forms.Select(attrs={'class': 'form-select'}),
'report_to': forms.Select(attrs={'class': 'form-select'}),
'is_head': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'gender': forms.Select(attrs={'class': 'form-select'}),
'status': forms.Select(attrs={'class': 'form-select'}),
"employee_id": forms.TextInput(attrs={"class": "form-control"}),
"first_name": forms.TextInput(attrs={"class": "form-control"}),
"last_name": forms.TextInput(attrs={"class": "form-control"}),
"first_name_ar": forms.TextInput(attrs={"class": "form-control", "dir": "rtl"}),
"last_name_ar": forms.TextInput(attrs={"class": "form-control", "dir": "rtl"}),
"name": forms.TextInput(attrs={"class": "form-control"}),
"name_ar": forms.TextInput(attrs={"class": "form-control", "dir": "rtl"}),
"staff_type": forms.Select(attrs={"class": "form-select"}),
"job_title": forms.TextInput(attrs={"class": "form-control"}),
"job_title_ar": forms.TextInput(attrs={"class": "form-control", "dir": "rtl"}),
"specialization": forms.TextInput(attrs={"class": "form-control"}),
"license_number": forms.TextInput(attrs={"class": "form-control"}),
"email": forms.EmailInput(attrs={"class": "form-control"}),
"phone": forms.TextInput(attrs={"class": "form-control"}),
"hospital": forms.Select(attrs={"class": "form-select"}),
"department": forms.Select(attrs={"class": "form-select"}),
"section_fk": forms.Select(attrs={"class": "form-select"}),
"subsection_fk": forms.Select(attrs={"class": "form-select"}),
"report_to": forms.Select(attrs={"class": "form-select"}),
"is_head": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"gender": forms.Select(attrs={"class": "form-select"}),
"status": forms.Select(attrs={"class": "form-select"}),
}

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,7 @@ from .views import (
api_staff_hierarchy,
api_staff_hierarchy_children,
api_subsection_list,
ajax_departments,
ajax_main_sections,
ajax_subsections,
)
@ -89,7 +90,8 @@ urlpatterns = [
# AJAX Routes for cascading dropdowns in complaint form
path('ajax/main-sections/', ajax_main_sections, name='ajax_main_sections'),
path('ajax/subsections/', ajax_subsections, name='ajax_subsections'),
path('ajax/departments/', ajax_departments, name='ajax_departments'),
# Staff Hierarchy API (for D3 visualization)
path('api/staff/hierarchy/', api_staff_hierarchy, name='api_staff_hierarchy'),
path('api/staff/hierarchy/<uuid:staff_id>/children/', api_staff_hierarchy_children, name='api_staff_hierarchy_children'),

View File

@ -781,6 +781,27 @@ def ajax_subsections(request):
return Response({'subsections': serializer.data})
@api_view(['GET'])
@permission_classes([])
def ajax_departments(request):
"""
AJAX endpoint for departments filtered by hospital.
Used in complaint form for cascading dropdown.
"""
hospital_id = request.GET.get('hospital_id')
if hospital_id:
departments = Department.objects.filter(
hospital_id=hospital_id,
status='active'
).order_by('name_en')
else:
departments = Department.objects.none()
serializer = DepartmentSerializer(departments, many=True)
return Response({'departments': serializer.data})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def api_staff_hierarchy(request):

View File

@ -4,6 +4,7 @@ Physician Rating Import Views
UI views for manual CSV upload of doctor ratings.
Similar to HIS Patient Import flow.
"""
import csv
import io
import logging
@ -31,108 +32,108 @@ logger = logging.getLogger(__name__)
def doctor_rating_import(request):
"""
Import doctor ratings from CSV (Doctor Rating Report format).
CSV Format (Doctor Rating Report):
- Header rows (rows 1-6 contain metadata)
- Column headers in row 7: UHID, Patient Name, Gender, Full Age, Nationality,
Mobile No, Patient Type, Admit Date, Discharge Date, Doctor Name, Rating,
- Column headers in row 7: UHID, Patient Name, Gender, Full Age, Nationality,
Mobile No, Patient Type, Admit Date, Discharge Date, Doctor Name, Rating,
Feed Back, Rating Date
- Department headers appear as rows with only first column filled
- Data rows follow
"""
user = request.user
# Check permission
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to import doctor ratings.")
return redirect('physicians:physician_list')
return redirect("physicians:physician_list")
# Session storage for imported ratings
session_key = f'doctor_rating_import_{user.id}'
if request.method == 'POST':
form = DoctorRatingImportForm(request.POST, request.FILES, user=user)
session_key = f"doctor_rating_import_{user.id}"
if request.method == "POST":
form = DoctorRatingImportForm(request.POST, request.FILES, request=request)
if form.is_valid():
try:
hospital = form.cleaned_data['hospital']
csv_file = form.cleaned_data['csv_file']
skip_rows = form.cleaned_data['skip_header_rows']
hospital = form.cleaned_data["hospital"]
csv_file = form.cleaned_data["csv_file"]
skip_rows = form.cleaned_data["skip_header_rows"]
# Parse CSV
decoded_file = csv_file.read().decode('utf-8-sig')
decoded_file = csv_file.read().decode("utf-8-sig")
io_string = io.StringIO(decoded_file)
reader = csv.reader(io_string)
# Skip header/metadata rows
for _ in range(skip_rows):
next(reader, None)
# Read header row
header = next(reader, None)
if not header:
messages.error(request, "CSV file is empty or has no data rows.")
return render(request, 'physicians/doctor_rating_import.html', {'form': form})
return render(request, "physicians/doctor_rating_import.html", {"form": form})
# Find column indices (handle different possible column names)
header = [h.strip().lower() for h in header]
col_map = {
'uhid': _find_column(header, ['uhid', 'file number', 'file_number', 'mrn', 'patient id']),
'patient_name': _find_column(header, ['patient name', 'patient_name', 'name']),
'gender': _find_column(header, ['gender', 'sex']),
'age': _find_column(header, ['full age', 'age', 'years']),
'nationality': _find_column(header, ['nationality', 'country']),
'mobile_no': _find_column(header, ['mobile no', 'mobile_no', 'mobile', 'phone', 'contact']),
'patient_type': _find_column(header, ['patient type', 'patient_type', 'type', 'visit type']),
'admit_date': _find_column(header, ['admit date', 'admit_date', 'admission date', 'visit date']),
'discharge_date': _find_column(header, ['discharge date', 'discharge_date']),
'doctor_name': _find_column(header, ['doctor name', 'doctor_name', 'physician name', 'physician']),
'rating': _find_column(header, ['rating', 'score', 'rate']),
'feedback': _find_column(header, ['feed back', 'feedback', 'comments', 'comment']),
'rating_date': _find_column(header, ['rating date', 'rating_date', 'date']),
'department': _find_column(header, ['department', 'dept', 'specialty']),
"uhid": _find_column(header, ["uhid", "file number", "file_number", "mrn", "patient id"]),
"patient_name": _find_column(header, ["patient name", "patient_name", "name"]),
"gender": _find_column(header, ["gender", "sex"]),
"age": _find_column(header, ["full age", "age", "years"]),
"nationality": _find_column(header, ["nationality", "country"]),
"mobile_no": _find_column(header, ["mobile no", "mobile_no", "mobile", "phone", "contact"]),
"patient_type": _find_column(header, ["patient type", "patient_type", "type", "visit type"]),
"admit_date": _find_column(header, ["admit date", "admit_date", "admission date", "visit date"]),
"discharge_date": _find_column(header, ["discharge date", "discharge_date"]),
"doctor_name": _find_column(header, ["doctor name", "doctor_name", "physician name", "physician"]),
"rating": _find_column(header, ["rating", "score", "rate"]),
"feedback": _find_column(header, ["feed back", "feedback", "comments", "comment"]),
"rating_date": _find_column(header, ["rating date", "rating_date", "date"]),
"department": _find_column(header, ["department", "dept", "specialty"]),
}
# Check required columns
if col_map['uhid'] is None:
if col_map["uhid"] is None:
messages.error(request, "Could not find 'UHID' column in CSV.")
return render(request, 'physicians/doctor_rating_import.html', {'form': form})
if col_map['doctor_name'] is None:
return render(request, "physicians/doctor_rating_import.html", {"form": form})
if col_map["doctor_name"] is None:
messages.error(request, "Could not find 'Doctor Name' column in CSV.")
return render(request, 'physicians/doctor_rating_import.html', {'form': form})
if col_map['rating'] is None:
return render(request, "physicians/doctor_rating_import.html", {"form": form})
if col_map["rating"] is None:
messages.error(request, "Could not find 'Rating' column in CSV.")
return render(request, 'physicians/doctor_rating_import.html', {'form': form})
return render(request, "physicians/doctor_rating_import.html", {"form": form})
# Process data rows
imported_ratings = []
errors = []
row_num = skip_rows + 1
current_department = ""
for row in reader:
row_num += 1
if not row or not any(row): # Skip empty rows
continue
try:
# Check if this is a department header row (only first column has value)
if _is_department_header(row, col_map):
current_department = row[0].strip()
continue
# Extract data
uhid = _get_cell(row, col_map['uhid'], '').strip()
patient_name = _get_cell(row, col_map['patient_name'], '').strip()
doctor_name_raw = _get_cell(row, col_map['doctor_name'], '').strip()
rating_str = _get_cell(row, col_map['rating'], '').strip()
uhid = _get_cell(row, col_map["uhid"], "").strip()
patient_name = _get_cell(row, col_map["patient_name"], "").strip()
doctor_name_raw = _get_cell(row, col_map["doctor_name"], "").strip()
rating_str = _get_cell(row, col_map["rating"], "").strip()
# Skip if missing required fields
if not uhid or not doctor_name_raw:
continue
# Validate rating
try:
rating = int(float(rating_str))
@ -142,85 +143,87 @@ def doctor_rating_import(request):
except (ValueError, TypeError):
errors.append(f"Row {row_num}: Invalid rating format '{rating_str}'")
continue
# Extract optional fields
gender = _get_cell(row, col_map['gender'], '').strip()
age = _get_cell(row, col_map['age'], '').strip()
nationality = _get_cell(row, col_map['nationality'], '').strip()
mobile_no = _get_cell(row, col_map['mobile_no'], '').strip()
patient_type = _get_cell(row, col_map['patient_type'], '').strip()
admit_date = _get_cell(row, col_map['admit_date'], '').strip()
discharge_date = _get_cell(row, col_map['discharge_date'], '').strip()
feedback = _get_cell(row, col_map['feedback'], '').strip()
rating_date = _get_cell(row, col_map['rating_date'], '').strip()
department = _get_cell(row, col_map['department'], '').strip() or current_department
gender = _get_cell(row, col_map["gender"], "").strip()
age = _get_cell(row, col_map["age"], "").strip()
nationality = _get_cell(row, col_map["nationality"], "").strip()
mobile_no = _get_cell(row, col_map["mobile_no"], "").strip()
patient_type = _get_cell(row, col_map["patient_type"], "").strip()
admit_date = _get_cell(row, col_map["admit_date"], "").strip()
discharge_date = _get_cell(row, col_map["discharge_date"], "").strip()
feedback = _get_cell(row, col_map["feedback"], "").strip()
rating_date = _get_cell(row, col_map["rating_date"], "").strip()
department = _get_cell(row, col_map["department"], "").strip() or current_department
# Parse doctor name to extract ID
doctor_id, doctor_name_clean = DoctorRatingAdapter.parse_doctor_name(doctor_name_raw)
imported_ratings.append({
'row_num': row_num,
'uhid': uhid,
'patient_name': patient_name,
'doctor_name_raw': doctor_name_raw,
'doctor_id': doctor_id,
'doctor_name': doctor_name_clean,
'rating': rating,
'gender': gender,
'age': age,
'nationality': nationality,
'mobile_no': mobile_no,
'patient_type': patient_type,
'admit_date': admit_date,
'discharge_date': discharge_date,
'feedback': feedback,
'rating_date': rating_date,
'department': department,
})
imported_ratings.append(
{
"row_num": row_num,
"uhid": uhid,
"patient_name": patient_name,
"doctor_name_raw": doctor_name_raw,
"doctor_id": doctor_id,
"doctor_name": doctor_name_clean,
"rating": rating,
"gender": gender,
"age": age,
"nationality": nationality,
"mobile_no": mobile_no,
"patient_type": patient_type,
"admit_date": admit_date,
"discharge_date": discharge_date,
"feedback": feedback,
"rating_date": rating_date,
"department": department,
}
)
except Exception as e:
errors.append(f"Row {row_num}: {str(e)}")
logger.error(f"Error processing row {row_num}: {e}")
# Store in session for review step
request.session[session_key] = {
'hospital_id': str(hospital.id),
'ratings': imported_ratings,
'errors': errors,
'total_count': len(imported_ratings)
"hospital_id": str(hospital.id),
"ratings": imported_ratings,
"errors": errors,
"total_count": len(imported_ratings),
}
# Log audit
AuditService.log_event(
event_type='doctor_rating_csv_import',
event_type="doctor_rating_csv_import",
description=f"Parsed {len(imported_ratings)} doctor ratings from CSV by {user.get_full_name()}",
user=user,
metadata={
'hospital': hospital.name,
'total_count': len(imported_ratings),
'error_count': len(errors)
}
"hospital": hospital.name,
"total_count": len(imported_ratings),
"error_count": len(errors),
},
)
if imported_ratings:
messages.success(
request,
f"Successfully parsed {len(imported_ratings)} doctor rating records. Please review before importing."
f"Successfully parsed {len(imported_ratings)} doctor rating records. Please review before importing.",
)
return redirect('physicians:doctor_rating_review')
return redirect("physicians:doctor_rating_review")
else:
messages.error(request, "No valid doctor rating records found in CSV.")
except Exception as e:
logger.error(f"Error processing Doctor Rating CSV: {str(e)}", exc_info=True)
messages.error(request, f"Error processing CSV: {str(e)}")
else:
form = DoctorRatingImportForm(user=user)
form = DoctorRatingImportForm(request=request)
context = {
'form': form,
"form": form,
}
return render(request, 'physicians/doctor_rating_import.html', context)
return render(request, "physicians/doctor_rating_import.html", context)
@login_required
@ -229,29 +232,27 @@ def doctor_rating_review(request):
Review imported doctor ratings before creating records.
"""
user = request.user
session_key = f'doctor_rating_import_{user.id}'
session_key = f"doctor_rating_import_{user.id}"
import_data = request.session.get(session_key)
if not import_data:
messages.error(request, "No import data found. Please upload CSV first.")
return redirect('physicians:doctor_rating_import')
hospital = get_object_or_404(Hospital, id=import_data['hospital_id'])
ratings = import_data['ratings']
errors = import_data.get('errors', [])
return redirect("physicians:doctor_rating_import")
hospital = get_object_or_404(Hospital, id=import_data["hospital_id"])
ratings = import_data["ratings"]
errors = import_data.get("errors", [])
# Check for staff matches
for r in ratings:
staff = DoctorRatingAdapter.find_staff_by_doctor_id(
r['doctor_id'], hospital, r['doctor_name']
)
r['staff_matched'] = staff is not None
r['staff_name'] = staff.get_full_name() if staff else None
if request.method == 'POST':
action = request.POST.get('action')
if action == 'import':
staff = DoctorRatingAdapter.find_staff_by_doctor_id(r["doctor_id"], hospital, r["doctor_name"])
r["staff_matched"] = staff is not None
r["staff_name"] = staff.get_full_name() if staff else None
if request.method == "POST":
action = request.POST.get("action")
if action == "import":
# Queue bulk import job
job = DoctorRatingImportJob.objects.create(
name=f"CSV Import - {hospital.name} - {len(ratings)} ratings",
@ -260,53 +261,46 @@ def doctor_rating_review(request):
created_by=user,
hospital=hospital,
total_records=len(ratings),
raw_data=ratings
raw_data=ratings,
)
# Queue the background task
process_doctor_rating_job.delay(str(job.id))
# Log audit
AuditService.log_event(
event_type='doctor_rating_import_queued',
event_type="doctor_rating_import_queued",
description=f"Queued {len(ratings)} doctor ratings for import",
user=user,
metadata={
'job_id': str(job.id),
'hospital': hospital.name,
'total_records': len(ratings)
}
metadata={"job_id": str(job.id), "hospital": hospital.name, "total_records": len(ratings)},
)
# Clear session
del request.session[session_key]
messages.success(
request,
f"Import job queued for {len(ratings)} ratings. You can check the status below."
)
return redirect('physicians:doctor_rating_job_status', job_id=job.id)
elif action == 'cancel':
messages.success(request, f"Import job queued for {len(ratings)} ratings. You can check the status below.")
return redirect("physicians:doctor_rating_job_status", job_id=job.id)
elif action == "cancel":
del request.session[session_key]
messages.info(request, "Import cancelled.")
return redirect('physicians:doctor_rating_import')
return redirect("physicians:doctor_rating_import")
# Pagination
paginator = Paginator(ratings, 50)
page_number = request.GET.get('page')
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
context = {
'hospital': hospital,
'ratings': ratings,
'page_obj': page_obj,
'errors': errors,
'total_count': len(ratings),
'matched_count': sum(1 for r in ratings if r['staff_matched']),
'unmatched_count': sum(1 for r in ratings if not r['staff_matched']),
"hospital": hospital,
"ratings": ratings,
"page_obj": page_obj,
"errors": errors,
"total_count": len(ratings),
"matched_count": sum(1 for r in ratings if r["staff_matched"]),
"unmatched_count": sum(1 for r in ratings if not r["staff_matched"]),
}
return render(request, 'physicians/doctor_rating_review.html', context)
return render(request, "physicians/doctor_rating_review.html", context)
@login_required
@ -316,12 +310,12 @@ def doctor_rating_job_status(request, job_id):
"""
user = request.user
job = get_object_or_404(DoctorRatingImportJob, id=job_id)
# Check permission
if not user.is_px_admin() and job.hospital != user.hospital:
messages.error(request, "You don't have permission to view this job.")
return redirect('physicians:physician_list')
return redirect("physicians:physician_list")
# Calculate progress circle stroke-dashoffset
# Circle circumference is 326.73 (2 * pi * r, where r=52)
# When progress is 0%, offset should be 326.73 (empty)
@ -329,15 +323,15 @@ def doctor_rating_job_status(request, job_id):
circumference = 2 * 3.14159 * 52 # ~326.73
progress = job.progress_percentage
stroke_dashoffset = circumference * (1 - progress / 100)
context = {
'job': job,
'progress': progress,
'stroke_dashoffset': stroke_dashoffset,
'is_complete': job.is_complete,
'results': job.results,
"job": job,
"progress": progress,
"stroke_dashoffset": stroke_dashoffset,
"is_complete": job.is_complete,
"results": job.results,
}
return render(request, 'physicians/doctor_rating_job_status.html', context)
return render(request, "physicians/doctor_rating_job_status.html", context)
@login_required
@ -346,7 +340,7 @@ def doctor_rating_job_list(request):
List all doctor rating import jobs for the user.
"""
user = request.user
# Filter jobs
if user.is_px_admin():
jobs = DoctorRatingImportJob.objects.all()
@ -354,13 +348,13 @@ def doctor_rating_job_list(request):
jobs = DoctorRatingImportJob.objects.filter(hospital=user.hospital)
else:
jobs = DoctorRatingImportJob.objects.filter(created_by=user)
jobs = jobs.order_by('-created_at')[:50] # Last 50 jobs
jobs = jobs.order_by("-created_at")[:50] # Last 50 jobs
context = {
'jobs': jobs,
"jobs": jobs,
}
return render(request, 'physicians/doctor_rating_job_list.html', context)
return render(request, "physicians/doctor_rating_job_list.html", context)
@login_required
@ -369,28 +363,26 @@ def individual_ratings_list(request):
List individual doctor ratings with filtering.
"""
user = request.user
# Base queryset
queryset = PhysicianIndividualRating.objects.select_related(
'hospital', 'staff', 'staff__department'
)
queryset = PhysicianIndividualRating.objects.select_related("hospital", "staff", "staff__department")
# Apply RBAC
if not user.is_px_admin():
if user.hospital:
queryset = queryset.filter(hospital=user.hospital)
else:
queryset = queryset.none()
# Filters
hospital_id = request.GET.get('hospital')
doctor_id = request.GET.get('doctor_id')
rating_min = request.GET.get('rating_min')
rating_max = request.GET.get('rating_max')
date_from = request.GET.get('date_from')
date_to = request.GET.get('date_to')
source = request.GET.get('source')
hospital_id = request.GET.get("hospital")
doctor_id = request.GET.get("doctor_id")
rating_min = request.GET.get("rating_min")
rating_max = request.GET.get("rating_max")
date_from = request.GET.get("date_from")
date_to = request.GET.get("date_to")
source = request.GET.get("source")
if hospital_id:
queryset = queryset.filter(hospital_id=hospital_id)
if doctor_id:
@ -405,43 +397,45 @@ def individual_ratings_list(request):
queryset = queryset.filter(rating_date__date__lte=date_to)
if source:
queryset = queryset.filter(source=source)
# Ordering
queryset = queryset.order_by('-rating_date')
queryset = queryset.order_by("-rating_date")
# Pagination
paginator = Paginator(queryset, 25)
page_number = request.GET.get('page')
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
# Get hospitals for filter
from apps.organizations.models import Hospital
if user.is_px_admin():
hospitals = Hospital.objects.filter(status='active')
hospitals = Hospital.objects.filter(status="active")
else:
hospitals = Hospital.objects.filter(id=user.hospital.id) if user.hospital else Hospital.objects.none()
context = {
'page_obj': page_obj,
'hospitals': hospitals,
'sources': PhysicianIndividualRating.RatingSource.choices,
'filters': {
'hospital': hospital_id,
'doctor_id': doctor_id,
'rating_min': rating_min,
'rating_max': rating_max,
'date_from': date_from,
'date_to': date_to,
'source': source,
}
"page_obj": page_obj,
"hospitals": hospitals,
"sources": PhysicianIndividualRating.RatingSource.choices,
"filters": {
"hospital": hospital_id,
"doctor_id": doctor_id,
"rating_min": rating_min,
"rating_max": rating_max,
"date_from": date_from,
"date_to": date_to,
"source": source,
},
}
return render(request, 'physicians/individual_ratings_list.html', context)
return render(request, "physicians/individual_ratings_list.html", context)
# ============================================================================
# Helper Functions
# ============================================================================
def _find_column(header, possible_names):
"""Find column index by possible names."""
for name in possible_names:
@ -451,7 +445,7 @@ def _find_column(header, possible_names):
return None
def _get_cell(row, index, default=''):
def _get_cell(row, index, default=""):
"""Safely get cell value."""
if index is None or index >= len(row):
return default
@ -461,29 +455,29 @@ def _get_cell(row, index, default=''):
def _is_department_header(row, col_map):
"""
Check if a row is a department header row.
Department headers typically have:
- First column has text (department name)
- All other columns are empty
"""
if not row or not row[0]:
return False
# Check if first column has text
first_col = row[0].strip()
if not first_col:
return False
# Check if other important columns are empty
# If UHID, Doctor Name, Rating are all empty, it's likely a header
uhid = _get_cell(row, col_map.get('uhid'), '').strip()
doctor_name = _get_cell(row, col_map.get('doctor_name'), '').strip()
rating = _get_cell(row, col_map.get('rating'), '').strip()
uhid = _get_cell(row, col_map.get("uhid"), "").strip()
doctor_name = _get_cell(row, col_map.get("doctor_name"), "").strip()
rating = _get_cell(row, col_map.get("rating"), "").strip()
# If these key fields are empty but first column has text, it's a department header
if not uhid and not doctor_name and not rating:
return True
return False
@ -491,70 +485,66 @@ def _is_department_header(row, col_map):
# AJAX Endpoints
# ============================================================================
@login_required
def api_job_progress(request, job_id):
"""AJAX endpoint to get job progress."""
user = request.user
job = get_object_or_404(DoctorRatingImportJob, id=job_id)
# Check permission
if not user.is_px_admin() and job.hospital != user.hospital:
return JsonResponse({'error': 'Permission denied'}, status=403)
return JsonResponse({
'job_id': str(job.id),
'status': job.status,
'progress_percentage': job.progress_percentage,
'processed_count': job.processed_count,
'total_records': job.total_records,
'success_count': job.success_count,
'failed_count': job.failed_count,
'is_complete': job.is_complete,
})
return JsonResponse({"error": "Permission denied"}, status=403)
return JsonResponse(
{
"job_id": str(job.id),
"status": job.status,
"progress_percentage": job.progress_percentage,
"processed_count": job.processed_count,
"total_records": job.total_records,
"success_count": job.success_count,
"failed_count": job.failed_count,
"is_complete": job.is_complete,
}
)
@login_required
def api_match_doctor(request):
"""
AJAX endpoint to manually match a doctor to a staff record.
POST data:
- doctor_id: The doctor ID from the rating
- doctor_name: The doctor name
- staff_id: The staff ID to match to
"""
if request.method != 'POST':
return JsonResponse({'error': 'POST required'}, status=405)
if request.method != "POST":
return JsonResponse({"error": "POST required"}, status=405)
user = request.user
doctor_id = request.POST.get('doctor_id')
doctor_name = request.POST.get('doctor_name')
staff_id = request.POST.get('staff_id')
doctor_id = request.POST.get("doctor_id")
doctor_name = request.POST.get("doctor_name")
staff_id = request.POST.get("staff_id")
if not staff_id:
return JsonResponse({'error': 'staff_id required'}, status=400)
return JsonResponse({"error": "staff_id required"}, status=400)
try:
staff = Staff.objects.get(id=staff_id)
# Check permission
if not user.is_px_admin() and staff.hospital != user.hospital:
return JsonResponse({'error': 'Permission denied'}, status=403)
return JsonResponse({"error": "Permission denied"}, status=403)
# Update all unaggregated ratings for this doctor
count = PhysicianIndividualRating.objects.filter(
doctor_id=doctor_id,
is_aggregated=False
).update(staff=staff)
return JsonResponse({
'success': True,
'matched_count': count,
'staff_name': staff.get_full_name()
})
count = PhysicianIndividualRating.objects.filter(doctor_id=doctor_id, is_aggregated=False).update(staff=staff)
return JsonResponse({"success": True, "matched_count": count, "staff_name": staff.get_full_name()})
except Staff.DoesNotExist:
return JsonResponse({'error': 'Staff not found'}, status=404)
return JsonResponse({"error": "Staff not found"}, status=404)
except Exception as e:
logger.error(f"Error matching doctor: {str(e)}", exc_info=True)
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({"error": str(e)}, status=500)

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,9 @@ QI Projects Forms
Forms for creating and managing Quality Improvement projects and tasks.
"""
from django import forms
from django.forms import inlineformset_factory
from django.utils.translation import gettext_lazy as _
from apps.core.form_mixins import HospitalFieldMixin
@ -16,220 +18,276 @@ from .models import QIProject, QIProjectTask
class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
"""
Form for creating and editing QI Projects.
Hospital field visibility:
- PX Admins: See dropdown with all hospitals
- Others: Hidden field, auto-set to user's hospital
"""
class Meta:
model = QIProject
fields = [
'name', 'name_ar', 'description', 'hospital', 'department',
'project_lead', 'team_members', 'status',
'start_date', 'target_completion_date', 'outcome_description'
"name",
"name_ar",
"description",
"hospital",
"department",
"project_lead",
"team_members",
"status",
"start_date",
"target_completion_date",
"outcome_description",
]
widgets = {
'name': forms.TextInput(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
'placeholder': _('Project name')
}),
'name_ar': forms.TextInput(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
'placeholder': _('اسم المشروع'),
'dir': 'rtl'
}),
'description': forms.Textarea(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none',
'rows': 4,
'placeholder': _('Describe the project objectives and scope...')
}),
'hospital': forms.Select(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
}),
'department': forms.Select(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
}),
'project_lead': forms.Select(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
}),
'team_members': forms.SelectMultiple(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white h-40'
}),
'status': forms.Select(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
}),
'start_date': forms.DateInput(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
'type': 'date'
}),
'target_completion_date': forms.DateInput(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
'type': 'date'
}),
'outcome_description': forms.Textarea(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none',
'rows': 3,
'placeholder': _('Document project outcomes and results...')
}),
"name": forms.TextInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"placeholder": _("Project name"),
}
),
"name_ar": forms.TextInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"placeholder": _("اسم المشروع"),
"dir": "rtl",
}
),
"description": forms.Textarea(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none",
"rows": 4,
"placeholder": _("Describe the project objectives and scope..."),
}
),
"hospital": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"department": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"project_lead": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"team_members": forms.SelectMultiple(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white h-40"
}
),
"status": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"start_date": forms.DateInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"type": "date",
}
),
"target_completion_date": forms.DateInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"type": "date",
}
),
"outcome_description": forms.Textarea(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none",
"rows": 3,
"placeholder": _("Document project outcomes and results..."),
}
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Filter department choices based on hospital
hospital_id = None
if self.data.get('hospital'):
hospital_id = self.data.get('hospital')
elif self.initial.get('hospital'):
hospital_id = self.initial.get('hospital')
if self.data.get("hospital"):
hospital_id = self.data.get("hospital")
elif self.initial.get("hospital"):
hospital_id = self.initial.get("hospital")
elif self.instance and self.instance.pk and self.instance.hospital:
hospital_id = self.instance.hospital.id
elif self.user and self.user.is_px_admin():
tenant_hospital = getattr(self.request, "tenant_hospital", None)
if tenant_hospital:
hospital_id = tenant_hospital.id
elif self.user and self.user.hospital:
hospital_id = self.user.hospital.id
if hospital_id:
self.fields['department'].queryset = Department.objects.filter(
hospital_id=hospital_id,
status='active'
).order_by('name')
self.fields["department"].queryset = Department.objects.filter(
hospital_id=hospital_id, status="active"
).order_by("name")
# Filter user choices based on hospital
self.fields['project_lead'].queryset = User.objects.filter(
hospital_id=hospital_id,
is_active=True
).order_by('first_name', 'last_name')
self.fields['team_members'].queryset = User.objects.filter(
hospital_id=hospital_id,
is_active=True
).order_by('first_name', 'last_name')
self.fields["project_lead"].queryset = User.objects.filter(
hospital_id=hospital_id, is_active=True
).order_by("first_name", "last_name")
self.fields["team_members"].queryset = User.objects.filter(
hospital_id=hospital_id, is_active=True
).order_by("first_name", "last_name")
else:
self.fields['department'].queryset = Department.objects.none()
self.fields['project_lead'].queryset = User.objects.none()
self.fields['team_members'].queryset = User.objects.none()
self.fields["department"].queryset = Department.objects.none()
self.fields["project_lead"].queryset = User.objects.none()
self.fields["team_members"].queryset = User.objects.none()
class QIProjectTaskForm(forms.ModelForm):
"""
Form for creating and editing QI Project tasks.
"""
class Meta:
model = QIProjectTask
fields = ['title', 'description', 'assigned_to', 'status', 'due_date', 'order']
fields = ["title", "description", "assigned_to", "status", "due_date", "order"]
widgets = {
'title': forms.TextInput(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
'placeholder': _('Task title')
}),
'description': forms.Textarea(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none',
'rows': 3,
'placeholder': _('Task description...')
}),
'assigned_to': forms.Select(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
}),
'status': forms.Select(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
}),
'due_date': forms.DateInput(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
'type': 'date'
}),
'order': forms.NumberInput(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
'min': 0
}),
"title": forms.TextInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"placeholder": _("Task title"),
}
),
"description": forms.Textarea(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none",
"rows": 3,
"placeholder": _("Task description..."),
}
),
"assigned_to": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"status": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"due_date": forms.DateInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"type": "date",
}
),
"order": forms.NumberInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"min": 0,
}
),
}
def __init__(self, *args, **kwargs):
self.project = kwargs.pop('project', None)
self.project = kwargs.pop("project", None)
super().__init__(*args, **kwargs)
# Make order field not required (has default value of 0)
self.fields['order'].required = False
self.fields["order"].required = False
# Filter assigned_to choices based on project hospital
if self.project and self.project.hospital:
self.fields['assigned_to'].queryset = User.objects.filter(
hospital=self.project.hospital,
is_active=True
).order_by('first_name', 'last_name')
self.fields["assigned_to"].queryset = User.objects.filter(
hospital=self.project.hospital, is_active=True
).order_by("first_name", "last_name")
else:
self.fields['assigned_to'].queryset = User.objects.none()
self.fields["assigned_to"].queryset = User.objects.none()
class QIProjectTemplateForm(HospitalFieldMixin, forms.ModelForm):
"""
Form for creating and editing QI Project templates.
Hospital field visibility:
- PX Admins: See dropdown with all hospitals
- Others: Hidden field, auto-set to user's hospital
Templates can be:
- Global (hospital=None) - available to all
- Hospital-specific - available only to that hospital
"""
class Meta:
model = QIProject
fields = [
'name', 'name_ar', 'description', 'hospital',
'department', 'target_completion_date'
]
fields = ["name", "name_ar", "description", "hospital", "department", "target_completion_date"]
widgets = {
'name': forms.TextInput(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
'placeholder': _('Template name')
}),
'name_ar': forms.TextInput(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
'placeholder': _('اسم القالب'),
'dir': 'rtl'
}),
'description': forms.Textarea(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none',
'rows': 4,
'placeholder': _('Describe the project template...')
}),
'hospital': forms.Select(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
}),
'department': forms.Select(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
}),
'target_completion_date': forms.DateInput(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
'type': 'date'
}),
"name": forms.TextInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"placeholder": _("Template name"),
}
),
"name_ar": forms.TextInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"placeholder": _("اسم القالب"),
"dir": "rtl",
}
),
"description": forms.Textarea(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm resize-none",
"rows": 4,
"placeholder": _("Describe the project template..."),
}
),
"hospital": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"department": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
"target_completion_date": forms.DateInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"type": "date",
}
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make hospital optional for templates (global templates)
self.fields['hospital'].required = False
self.fields['hospital'].empty_label = _('Global (All Hospitals)')
self.fields["hospital"].required = False
self.fields["hospital"].empty_label = _("Global (All Hospitals)")
# Filter department choices based on hospital
hospital_id = None
if self.data.get('hospital'):
hospital_id = self.data.get('hospital')
elif self.initial.get('hospital'):
hospital_id = self.initial.get('hospital')
if self.data.get("hospital"):
hospital_id = self.data.get("hospital")
elif self.initial.get("hospital"):
hospital_id = self.initial.get("hospital")
elif self.instance and self.instance.pk and self.instance.hospital:
hospital_id = self.instance.hospital.id
elif self.user and self.user.is_px_admin():
tenant_hospital = getattr(self.request, "tenant_hospital", None)
if tenant_hospital:
hospital_id = tenant_hospital.id
elif self.user and self.user.hospital:
hospital_id = self.user.hospital.id
if hospital_id:
self.fields['department'].queryset = Department.objects.filter(
hospital_id=hospital_id,
status='active'
).order_by('name')
self.fields["department"].queryset = Department.objects.filter(
hospital_id=hospital_id, status="active"
).order_by("name")
else:
self.fields['department'].queryset = Department.objects.none()
self.fields["department"].queryset = Department.objects.none()
class ConvertToProjectForm(forms.Form):
@ -237,62 +295,104 @@ class ConvertToProjectForm(forms.Form):
Form for converting a PX Action to a QI Project.
Allows selecting a template and customizing the project details.
"""
template = forms.ModelChoiceField(
queryset=QIProject.objects.none(),
required=False,
empty_label=_('Blank Project'),
label=_('Project Template'),
widget=forms.Select(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
})
empty_label=_("Blank Project"),
label=_("Project Template"),
widget=forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
)
project_name = forms.CharField(
max_length=200,
label=_('Project Name'),
widget=forms.TextInput(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
'placeholder': _('Enter project name')
})
label=_("Project Name"),
widget=forms.TextInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"placeholder": _("Enter project name"),
}
),
)
project_lead = forms.ModelChoiceField(
queryset=User.objects.none(),
required=True,
label=_('Project Lead'),
widget=forms.Select(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white'
})
label=_("Project Lead"),
widget=forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
)
target_completion_date = forms.DateField(
required=False,
label=_('Target Completion Date'),
widget=forms.DateInput(attrs={
'class': 'w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm',
'type': 'date'
})
label=_("Target Completion Date"),
widget=forms.DateInput(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm",
"type": "date",
}
),
)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None)
self.action = kwargs.pop('action', None)
self.request = kwargs.pop("request", None)
self.user = self.request.user if self.request else None
self.action = kwargs.pop("action", None)
super().__init__(*args, **kwargs)
if self.user and self.user.hospital:
# Filter templates by hospital (or global)
from django.db.models import Q
self.fields['template'].queryset = QIProject.objects.filter(
self.fields["template"].queryset = QIProject.objects.filter(
Q(hospital=self.user.hospital) | Q(hospital__isnull=True),
status='template' # We'll add this status or use metadata
).order_by('name')
status="template", # We'll add this status or use metadata
).order_by("name")
# Filter project lead by hospital
self.fields['project_lead'].queryset = User.objects.filter(
hospital=self.user.hospital,
is_active=True
).order_by('first_name', 'last_name')
self.fields["project_lead"].queryset = User.objects.filter(
hospital=self.user.hospital, is_active=True
).order_by("first_name", "last_name")
else:
self.fields['template'].queryset = QIProject.objects.none()
self.fields['project_lead'].queryset = User.objects.none()
self.fields["template"].queryset = QIProject.objects.none()
self.fields["project_lead"].queryset = User.objects.none()
# Inline formset for task templates (used with QIProject templates)
class TaskTemplateForm(forms.ModelForm):
"""Simplified form for task templates (no project field needed)"""
class Meta:
model = QIProjectTask
fields = ["title", "description"]
widgets = {
"title": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": _("Task title"),
}
),
"description": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": _("Description"),
}
),
}
TaskTemplateFormSet = inlineformset_factory(
QIProject,
QIProjectTask,
form=TaskTemplateForm,
fields=["title", "description"],
extra=1,
can_delete=True,
)

View File

@ -4,6 +4,7 @@ QI Projects Console UI views
Provides full CRUD functionality for Quality Improvement projects,
task management, and template handling.
"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
@ -15,7 +16,7 @@ from apps.core.decorators import block_source_user
from apps.organizations.models import Hospital
from apps.px_action_center.models import PXAction
from .forms import ConvertToProjectForm, QIProjectForm, QIProjectTaskForm, QIProjectTemplateForm
from .forms import ConvertToProjectForm, QIProjectForm, QIProjectTaskForm, QIProjectTemplateForm, TaskTemplateFormSet
from .models import QIProject, QIProjectTask
@ -24,71 +25,72 @@ from .models import QIProject, QIProjectTask
def project_list(request):
"""QI Projects list view with filtering and pagination"""
# Exclude templates from the list
queryset = QIProject.objects.filter(is_template=False).select_related(
'hospital', 'department', 'project_lead'
).prefetch_related('team_members', 'related_actions')
queryset = (
QIProject.objects.filter(is_template=False)
.select_related("hospital", "department", "project_lead")
.prefetch_related("team_members", "related_actions")
)
# Apply RBAC filters
user = request.user
# Get selected hospital for PX Admins (from middleware)
selected_hospital = getattr(request, 'tenant_hospital', None)
selected_hospital = getattr(request, "tenant_hospital", None)
if user.is_px_admin():
# PX Admins see all, but filter by selected hospital if set
if selected_hospital:
queryset = queryset.filter(hospital=selected_hospital)
elif user.hospital:
queryset = queryset.filter(hospital=user.hospital)
# Apply filters
status_filter = request.GET.get('status')
status_filter = request.GET.get("status")
if status_filter:
queryset = queryset.filter(status=status_filter)
hospital_filter = request.GET.get('hospital')
hospital_filter = request.GET.get("hospital")
if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter)
# Search
search_query = request.GET.get('search')
search_query = request.GET.get("search")
if search_query:
queryset = queryset.filter(
Q(name__icontains=search_query) |
Q(description__icontains=search_query) |
Q(name_ar__icontains=search_query)
Q(name__icontains=search_query)
| Q(description__icontains=search_query)
| Q(name_ar__icontains=search_query)
)
# Ordering
queryset = queryset.order_by('-created_at')
queryset = queryset.order_by("-created_at")
# Pagination
page_size = int(request.GET.get('page_size', 25))
page_size = int(request.GET.get("page_size", 25))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get('page', 1)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
# Get hospitals for filter
hospitals = Hospital.objects.filter(status='active')
hospitals = Hospital.objects.filter(status="active")
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
# Statistics
stats = {
'total': queryset.count(),
'active': queryset.filter(status='active').count(),
'completed': queryset.filter(status='completed').count(),
'pending': queryset.filter(status='pending').count(),
"total": queryset.count(),
"active": queryset.filter(status="active").count(),
"completed": queryset.filter(status="completed").count(),
"pending": queryset.filter(status="pending").count(),
}
context = {
'page_obj': page_obj,
'projects': page_obj.object_list,
'hospitals': hospitals,
'stats': stats,
'filters': request.GET,
"page_obj": page_obj,
"projects": page_obj.object_list,
"stats": stats,
"filters": request.GET,
}
return render(request, 'projects/project_list.html', context)
return render(request, "projects/project_list.html", context)
@block_source_user
@ -96,34 +98,32 @@ def project_list(request):
def project_detail(request, pk):
"""QI Project detail view with task management"""
project = get_object_or_404(
QIProject.objects.filter(is_template=False).select_related(
'hospital', 'department', 'project_lead'
).prefetch_related(
'team_members', 'related_actions', 'tasks'
),
pk=pk
QIProject.objects.filter(is_template=False)
.select_related("hospital", "department", "project_lead")
.prefetch_related("team_members", "related_actions", "tasks"),
pk=pk,
)
# Check permission
user = request.user
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to view this project."))
return redirect('projects:project_list')
return redirect("projects:project_list")
# Get tasks
tasks = project.tasks.all().order_by('order', 'created_at')
tasks = project.tasks.all().order_by("order", "created_at")
# Get related actions
related_actions = project.related_actions.all()
context = {
'project': project,
'tasks': tasks,
'related_actions': related_actions,
'can_edit': user.is_px_admin() or user.is_hospital_admin or user.is_department_manager,
"project": project,
"tasks": tasks,
"related_actions": related_actions,
"can_edit": user.is_px_admin() or user.is_hospital_admin or user.is_department_manager,
}
return render(request, 'projects/project_detail.html', context)
return render(request, "projects/project_detail.html", context)
@block_source_user
@ -131,63 +131,63 @@ def project_detail(request, pk):
def project_create(request, template_pk=None):
"""Create a new QI Project"""
user = request.user
# Check permission (PX Admin, Hospital Admin, or Department Manager)
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
messages.error(request, _("You don't have permission to create projects."))
return redirect('projects:project_list')
return redirect("projects:project_list")
# Check for template parameter (from URL or GET)
template_id = template_pk or request.GET.get('template')
template_id = template_pk or request.GET.get("template")
initial_data = {}
template = None
if template_id:
try:
template = QIProject.objects.get(pk=template_id, is_template=True)
initial_data = {
'name': template.name,
'name_ar': template.name_ar,
'description': template.description,
'department': template.department,
'target_completion_date': template.target_completion_date,
"name": template.name,
"name_ar": template.name_ar,
"description": template.description,
"department": template.department,
"target_completion_date": template.target_completion_date,
}
if not user.is_px_admin() and user.hospital:
initial_data['hospital'] = user.hospital
initial_data["hospital"] = user.hospital
except QIProject.DoesNotExist:
pass
# Check for PX Action parameter (convert action to project)
action_id = request.GET.get('action')
action_id = request.GET.get("action")
if action_id:
try:
action = PXAction.objects.get(pk=action_id)
initial_data['name'] = f"QI Project: {action.title}"
initial_data['description'] = action.description
initial_data["name"] = f"QI Project: {action.title}"
initial_data["description"] = action.description
if not user.is_px_admin() and user.hospital:
initial_data['hospital'] = user.hospital
initial_data["hospital"] = user.hospital
else:
initial_data['hospital'] = action.hospital
initial_data["hospital"] = action.hospital
except PXAction.DoesNotExist:
pass
if request.method == 'POST':
form = QIProjectForm(request.POST, user=user)
if request.method == "POST":
form = QIProjectForm(request.POST, request=request)
# Check for template in POST data (hidden field)
template_id_post = request.POST.get('template_id')
template_id_post = request.POST.get("template_id")
if template_id_post:
try:
template = QIProject.objects.get(pk=template_id_post, is_template=True)
except QIProject.DoesNotExist:
template = None
if form.is_valid():
project = form.save(commit=False)
project.created_by = user
project.save()
form.save_m2m() # Save many-to-many relationships
# If created from template, copy tasks
task_count = 0
if template:
@ -197,10 +197,10 @@ def project_create(request, template_pk=None):
title=template_task.title,
description=template_task.description,
order=template_task.order,
status='pending'
status="pending",
)
task_count += 1
# If created from action, link it
if action_id:
try:
@ -208,25 +208,25 @@ def project_create(request, template_pk=None):
project.related_actions.add(action)
except PXAction.DoesNotExist:
pass
if template and task_count > 0:
messages.success(
request,
_('QI Project created successfully with %(count)d task(s) from template.') % {'count': task_count}
request,
_("QI Project created successfully with %(count)d task(s) from template.") % {"count": task_count},
)
else:
messages.success(request, _("QI Project created successfully."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
else:
form = QIProjectForm(user=user, initial=initial_data)
form = QIProjectForm(request=request, initial=initial_data)
context = {
'form': form,
'is_create': True,
'template': template,
"form": form,
"is_create": True,
"template": template,
}
return render(request, 'projects/project_form.html', context)
return render(request, "projects/project_form.html", context)
@block_source_user
@ -235,33 +235,33 @@ def project_edit(request, pk):
"""Edit an existing QI Project"""
user = request.user
project = get_object_or_404(QIProject, pk=pk, is_template=False)
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to edit this project."))
return redirect('projects:project_list')
return redirect("projects:project_list")
# Check edit permission (PX Admin, Hospital Admin, or Department Manager)
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
messages.error(request, _("You don't have permission to edit projects."))
return redirect('projects:project_detail', pk=project.pk)
if request.method == 'POST':
form = QIProjectForm(request.POST, instance=project, user=user)
return redirect("projects:project_detail", pk=project.pk)
if request.method == "POST":
form = QIProjectForm(request.POST, instance=project, request=request)
if form.is_valid():
form.save()
messages.success(request, _("QI Project updated successfully."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
else:
form = QIProjectForm(instance=project, user=user)
form = QIProjectForm(instance=project, request=request)
context = {
'form': form,
'project': project,
'is_create': False,
"form": form,
"project": project,
"is_create": False,
}
return render(request, 'projects/project_form.html', context)
return render(request, "projects/project_form.html", context)
@block_source_user
@ -270,27 +270,27 @@ def project_delete(request, pk):
"""Delete a QI Project"""
user = request.user
project = get_object_or_404(QIProject, pk=pk, is_template=False)
# Check permission (only PX Admin or Hospital Admin can delete)
if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to delete projects."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to delete this project."))
return redirect('projects:project_list')
if request.method == 'POST':
return redirect("projects:project_list")
if request.method == "POST":
project_name = project.name
project.delete()
messages.success(request, _('Project "%(name)s" deleted successfully.') % {'name': project_name})
return redirect('projects:project_list')
messages.success(request, _('Project "%(name)s" deleted successfully.') % {"name": project_name})
return redirect("projects:project_list")
context = {
'project': project,
"project": project,
}
return render(request, 'projects/project_delete_confirm.html', context)
return render(request, "projects/project_delete_confirm.html", context)
@block_source_user
@ -299,26 +299,26 @@ def project_save_as_template(request, pk):
"""Save an existing project and its tasks as a template"""
user = request.user
project = get_object_or_404(QIProject, pk=pk, is_template=False)
# Check permission (only PX Admin or Hospital Admin can create templates)
if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to create templates."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
# Check hospital access
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to create templates from this project."))
return redirect('projects:project_list')
if request.method == 'POST':
template_name = request.POST.get('template_name', '').strip()
template_description = request.POST.get('template_description', '').strip()
make_global = request.POST.get('make_global') == 'on'
return redirect("projects:project_list")
if request.method == "POST":
template_name = request.POST.get("template_name", "").strip()
template_description = request.POST.get("template_description", "").strip()
make_global = request.POST.get("make_global") == "on"
if not template_name:
messages.error(request, _('Please provide a template name.'))
return redirect('projects:project_save_as_template', pk=project.pk)
messages.error(request, _("Please provide a template name."))
return redirect("projects:project_save_as_template", pk=project.pk)
# Create template from project
template = QIProject.objects.create(
name=template_name,
@ -328,10 +328,10 @@ def project_save_as_template(request, pk):
# If global, hospital is None; otherwise use project's hospital
hospital=None if make_global else project.hospital,
department=project.department,
status='pending', # Default status for templates
created_by=user
status="pending", # Default status for templates
created_by=user,
)
# Copy tasks from project to template
for task in project.tasks.all():
QIProjectTask.objects.create(
@ -339,60 +339,59 @@ def project_save_as_template(request, pk):
title=task.title,
description=task.description,
order=task.order,
status='pending' # Reset status for template
status="pending", # Reset status for template
)
messages.success(
request,
_('Template "%(name)s" created successfully with %(count)d task(s).') % {
'name': template_name,
'count': project.tasks.count()
}
request,
_('Template "%(name)s" created successfully with %(count)d task(s).')
% {"name": template_name, "count": project.tasks.count()},
)
return redirect('projects:template_list')
return redirect("projects:template_list")
context = {
'project': project,
'suggested_name': f"Template: {project.name}",
"project": project,
"suggested_name": f"Template: {project.name}",
}
return render(request, 'projects/project_save_as_template.html', context)
return render(request, "projects/project_save_as_template.html", context)
# =============================================================================
# Task Management Views
# =============================================================================
@block_source_user
@login_required
def task_create(request, project_pk):
"""Add a new task to a project"""
user = request.user
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to add tasks to this project."))
return redirect('projects:project_detail', pk=project.pk)
if request.method == 'POST':
return redirect("projects:project_detail", pk=project.pk)
if request.method == "POST":
form = QIProjectTaskForm(request.POST, project=project)
if form.is_valid():
task = form.save(commit=False)
task.project = project
task.save()
messages.success(request, _("Task added successfully."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
else:
form = QIProjectTaskForm(project=project)
context = {
'form': form,
'project': project,
'is_create': True,
"form": form,
"project": project,
"is_create": True,
}
return render(request, 'projects/task_form.html', context)
return render(request, "projects/task_form.html", context)
@block_source_user
@ -402,34 +401,35 @@ def task_edit(request, project_pk, task_pk):
user = request.user
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to edit tasks in this project."))
return redirect('projects:project_detail', pk=project.pk)
if request.method == 'POST':
return redirect("projects:project_detail", pk=project.pk)
if request.method == "POST":
form = QIProjectTaskForm(request.POST, instance=task, project=project)
if form.is_valid():
task = form.save()
# If status changed to completed, set completed_date
if task.status == 'completed' and not task.completed_date:
if task.status == "completed" and not task.completed_date:
from django.utils import timezone
task.completed_date = timezone.now().date()
task.save()
messages.success(request, _("Task updated successfully."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
else:
form = QIProjectTaskForm(instance=task, project=project)
context = {
'form': form,
'project': project,
'task': task,
'is_create': False,
"form": form,
"project": project,
"task": task,
"is_create": False,
}
return render(request, 'projects/task_form.html', context)
return render(request, "projects/task_form.html", context)
@block_source_user
@ -439,23 +439,23 @@ def task_delete(request, project_pk, task_pk):
user = request.user
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to delete tasks in this project."))
return redirect('projects:project_detail', pk=project.pk)
if request.method == 'POST':
return redirect("projects:project_detail", pk=project.pk)
if request.method == "POST":
task.delete()
messages.success(request, _("Task deleted successfully."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
context = {
'project': project,
'task': task,
"project": project,
"task": task,
}
return render(request, 'projects/task_delete_confirm.html', context)
return render(request, "projects/task_delete_confirm.html", context)
@block_source_user
@ -465,68 +465,68 @@ def task_toggle_status(request, project_pk, task_pk):
user = request.user
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
messages.error(request, _("You don't have permission to update tasks in this project."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
from django.utils import timezone
if task.status == 'completed':
task.status = 'pending'
if task.status == "completed":
task.status = "pending"
task.completed_date = None
else:
task.status = 'completed'
task.status = "completed"
task.completed_date = timezone.now().date()
task.save()
messages.success(request, _("Task status updated."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
# =============================================================================
# Template Management Views
# =============================================================================
@block_source_user
@login_required
def template_list(request):
"""List QI Project templates"""
user = request.user
# Only admins can manage templates
if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to view templates."))
return redirect('projects:project_list')
queryset = QIProject.objects.filter(is_template=True).select_related('hospital', 'department')
return redirect("projects:project_list")
queryset = QIProject.objects.filter(is_template=True).select_related("hospital", "department")
# Apply RBAC filters
if not user.is_px_admin():
from django.db.models import Q
queryset = queryset.filter(
Q(hospital=user.hospital) | Q(hospital__isnull=True)
)
queryset = queryset.filter(Q(hospital=user.hospital) | Q(hospital__isnull=True))
# Search
search_query = request.GET.get('search')
search_query = request.GET.get("search")
if search_query:
queryset = queryset.filter(
Q(name__icontains=search_query) |
Q(description__icontains=search_query) |
Q(name_ar__icontains=search_query)
Q(name__icontains=search_query)
| Q(description__icontains=search_query)
| Q(name_ar__icontains=search_query)
)
queryset = queryset.order_by('name')
queryset = queryset.order_by("name")
context = {
'templates': queryset,
'can_create': user.is_px_admin() or user.is_hospital_admin,
'can_edit': user.is_px_admin() or user.is_hospital_admin,
"templates": queryset,
"can_create": user.is_px_admin() or user.is_hospital_admin,
"can_edit": user.is_px_admin() or user.is_hospital_admin,
}
return render(request, 'projects/template_list.html', context)
return render(request, "projects/template_list.html", context)
@block_source_user
@ -534,33 +534,33 @@ def template_list(request):
def template_detail(request, pk):
"""View template details with tasks"""
user = request.user
# Only admins can view templates
if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to view templates."))
return redirect('projects:project_list')
return redirect("projects:project_list")
template = get_object_or_404(
QIProject.objects.filter(is_template=True).select_related('hospital', 'department'),
pk=pk
QIProject.objects.filter(is_template=True).select_related("hospital", "department"), pk=pk
)
# Check permission for hospital-specific templates
if not user.is_px_admin():
from django.db.models import Q
if template.hospital and template.hospital != user.hospital:
messages.error(request, _("You don't have permission to view this template."))
return redirect('projects:template_list')
return redirect("projects:template_list")
# Get tasks
tasks = template.tasks.all().order_by('order', 'created_at')
tasks = template.tasks.all().order_by("order", "created_at")
context = {
'template': template,
'tasks': tasks,
"template": template,
"tasks": tasks,
}
return render(request, 'projects/template_detail.html', context)
return render(request, "projects/template_detail.html", context)
@block_source_user
@ -568,30 +568,41 @@ def template_detail(request, pk):
def template_create(request):
"""Create a new project template"""
user = request.user
# Only admins can create templates
if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to create templates."))
return redirect('projects:project_list')
if request.method == 'POST':
form = QIProjectTemplateForm(request.POST, user=user)
return redirect("projects:project_list")
if request.method == "POST":
form = QIProjectTemplateForm(request.POST, request=request)
if form.is_valid():
template = form.save(commit=False)
template.is_template = True
template.created_by = user
template.save()
# Save task templates formset
formset = TaskTemplateFormSet(request.POST, instance=template, prefix='tasktemplate_set')
if formset.is_valid():
formset.save()
messages.success(request, _("Project template created successfully."))
return redirect('projects:template_list')
return redirect("projects:template_list")
else:
# Form is invalid, show formset with errors
formset = TaskTemplateFormSet(request.POST, prefix='tasktemplate_set')
else:
form = QIProjectTemplateForm(user=user)
form = QIProjectTemplateForm(request=request)
formset = TaskTemplateFormSet(prefix='tasktemplate_set')
context = {
'form': form,
'is_create': True,
"form": form,
"formset": formset,
"is_create": True,
}
return render(request, 'projects/template_form.html', context)
return render(request, "projects/template_form.html", context)
@block_source_user
@ -600,29 +611,33 @@ def template_edit(request, pk):
"""Edit an existing project template"""
user = request.user
template = get_object_or_404(QIProject, pk=pk, is_template=True)
# Check permission
if not user.is_px_admin():
if template.hospital and template.hospital != user.hospital:
messages.error(request, _("You don't have permission to edit this template."))
return redirect('projects:template_list')
if request.method == 'POST':
form = QIProjectTemplateForm(request.POST, instance=template, user=user)
if form.is_valid():
return redirect("projects:template_list")
if request.method == "POST":
form = QIProjectTemplateForm(request.POST, instance=template, request=request)
formset = TaskTemplateFormSet(request.POST, instance=template, prefix='tasktemplate_set')
if form.is_valid() and formset.is_valid():
form.save()
formset.save()
messages.success(request, _("Project template updated successfully."))
return redirect('projects:template_list')
return redirect("projects:template_list")
else:
form = QIProjectTemplateForm(instance=template, user=user)
form = QIProjectTemplateForm(instance=template, request=request)
formset = TaskTemplateFormSet(instance=template, prefix='tasktemplate_set')
context = {
'form': form,
'template': template,
'is_create': False,
"form": form,
"formset": formset,
"template": template,
"is_create": False,
}
return render(request, 'projects/template_form.html', context)
return render(request, "projects/template_form.html", context)
@block_source_user
@ -631,66 +646,67 @@ def template_delete(request, pk):
"""Delete a project template"""
user = request.user
template = get_object_or_404(QIProject, pk=pk, is_template=True)
# Check permission
if not user.is_px_admin():
if template.hospital and template.hospital != user.hospital:
messages.error(request, _("You don't have permission to delete this template."))
return redirect('projects:template_list')
if request.method == 'POST':
return redirect("projects:template_list")
if request.method == "POST":
template_name = template.name
template.delete()
messages.success(request, _('Template "%(name)s" deleted successfully.') % {'name': template_name})
return redirect('projects:template_list')
messages.success(request, _('Template "%(name)s" deleted successfully.') % {"name": template_name})
return redirect("projects:template_list")
context = {
'template': template,
"template": template,
}
return render(request, 'projects/template_delete_confirm.html', context)
return render(request, "projects/template_delete_confirm.html", context)
# =============================================================================
# PX Action Conversion View
# =============================================================================
@block_source_user
@login_required
def convert_action_to_project(request, action_pk):
"""Convert a PX Action to a QI Project"""
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager):
messages.error(request, _("You don't have permission to create projects."))
return redirect('px_action_center:action_detail', pk=action_pk)
return redirect("px_action_center:action_detail", pk=action_pk)
action = get_object_or_404(PXAction, pk=action_pk)
# Check hospital access
if not user.is_px_admin() and user.hospital and action.hospital != user.hospital:
messages.error(request, _("You don't have permission to convert this action."))
return redirect('px_action_center:action_detail', pk=action_pk)
if request.method == 'POST':
form = ConvertToProjectForm(request.POST, user=user, action=action)
return redirect("px_action_center:action_detail", pk=action_pk)
if request.method == "POST":
form = ConvertToProjectForm(request.POST, request=request, action=action)
if form.is_valid():
# Create project from template or blank
template = form.cleaned_data.get('template')
template = form.cleaned_data.get("template")
if template:
# Copy from template
project = QIProject.objects.create(
name=form.cleaned_data['project_name'],
name=form.cleaned_data["project_name"],
name_ar=template.name_ar,
description=template.description,
hospital=action.hospital,
department=template.department,
project_lead=form.cleaned_data['project_lead'],
target_completion_date=form.cleaned_data['target_completion_date'],
status='pending',
created_by=user
project_lead=form.cleaned_data["project_lead"],
target_completion_date=form.cleaned_data["target_completion_date"],
status="pending",
created_by=user,
)
# Copy tasks from template
for template_task in template.tasks.all():
@ -699,34 +715,34 @@ def convert_action_to_project(request, action_pk):
title=template_task.title,
description=template_task.description,
order=template_task.order,
status='pending'
status="pending",
)
else:
# Create blank project
project = QIProject.objects.create(
name=form.cleaned_data['project_name'],
name=form.cleaned_data["project_name"],
description=action.description,
hospital=action.hospital,
project_lead=form.cleaned_data['project_lead'],
target_completion_date=form.cleaned_data['target_completion_date'],
status='pending',
created_by=user
project_lead=form.cleaned_data["project_lead"],
target_completion_date=form.cleaned_data["target_completion_date"],
status="pending",
created_by=user,
)
# Link to the action
project.related_actions.add(action)
messages.success(request, _("PX Action converted to QI Project successfully."))
return redirect('projects:project_detail', pk=project.pk)
return redirect("projects:project_detail", pk=project.pk)
else:
initial_data = {
'project_name': f"QI Project: {action.title}",
"project_name": f"QI Project: {action.title}",
}
form = ConvertToProjectForm(user=user, action=action, initial=initial_data)
form = ConvertToProjectForm(request=request, action=action, initial=initial_data)
context = {
'form': form,
'action': action,
"form": form,
"action": action,
}
return render(request, 'projects/convert_action.html', context)
return render(request, "projects/convert_action.html", context)

View File

@ -1,6 +1,7 @@
"""
PX Action Center forms - Manual action creation and management
"""
from django import forms
from django.db.models import Q
@ -12,127 +13,128 @@ from .models import PXAction, ActionSource, ActionStatus
class ManualActionForm(forms.ModelForm):
"""
Form for manually creating PX Actions.
Features:
- Source type selection including meeting types
- User permission-based filtering
- Cascading dropdowns (hospital department)
- Assignment to users
"""
class Meta:
model = PXAction
fields = [
'source_type', 'title', 'description',
'hospital', 'department', 'category',
'priority', 'severity', 'assigned_to',
'due_at', 'requires_approval', 'action_plan'
"source_type",
"title",
"description",
"hospital",
"department",
"category",
"priority",
"severity",
"assigned_to",
"due_at",
"requires_approval",
"action_plan",
]
widgets = {
'title': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Brief title for the action plan'
}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 4,
'placeholder': 'Detailed description of the issue or improvement needed'
}),
'due_at': forms.DateTimeInput(attrs={
'type': 'datetime-local',
'class': 'form-control'
}),
'action_plan': forms.Textarea(attrs={
'class': 'form-control',
'rows': 4,
'placeholder': 'Proposed action steps to address the issue'
}),
"title": forms.TextInput(attrs={"class": "form-control", "placeholder": "Brief title for the action plan"}),
"description": forms.Textarea(
attrs={
"class": "form-control",
"rows": 4,
"placeholder": "Detailed description of the issue or improvement needed",
}
),
"due_at": forms.DateTimeInput(attrs={"type": "datetime-local", "class": "form-control"}),
"action_plan": forms.Textarea(
attrs={"class": "form-control", "rows": 4, "placeholder": "Proposed action steps to address the issue"}
),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
request = kwargs.pop("request", None)
user = request.user if request else None
tenant_hospital = getattr(request, "tenant_hospital", None) if request else None
super().__init__(*args, **kwargs)
# Make hospital field required
self.fields['hospital'].required = True
self.fields['hospital'].widget.attrs['class'] = 'form-select'
# Make source type required
self.fields['source_type'].required = True
self.fields['source_type'].widget.attrs['class'] = 'form-select'
# Make category required
self.fields['category'].required = True
self.fields['category'].widget.attrs['class'] = 'form-select'
# Make priority and severity required
self.fields['priority'].required = True
self.fields['priority'].widget.attrs['class'] = 'form-select'
self.fields['severity'].required = True
self.fields['severity'].widget.attrs['class'] = 'form-select'
# Filter hospitals based on user permissions
if user and not user.is_px_admin():
if user.hospital:
self.fields['hospital'].queryset = self.fields['hospital'].queryset.filter(
id=user.hospital.id
)
self.fields['hospital'].initial = user.hospital.id
self.fields['hospital'].widget.attrs['disabled'] = True
# Make hospital field hidden and auto-set based on user context
self.fields["hospital"].widget = forms.HiddenInput()
self.fields["hospital"].required = False
# Set hospital value based on user context
if user:
if user.is_px_admin() and tenant_hospital:
self.fields["hospital"].initial = tenant_hospital
self.fields["hospital"].queryset = self.fields["hospital"].queryset.filter(id=tenant_hospital.id)
hospital = tenant_hospital
elif user.hospital:
self.fields["hospital"].initial = user.hospital
self.fields["hospital"].queryset = self.fields["hospital"].queryset.filter(id=user.hospital.id)
hospital = user.hospital
else:
# User doesn't have a hospital, can't create actions
self.fields['hospital'].queryset = self.fields['hospital'].queryset.none()
# Filter departments based on selected hospital
if user and user.hospital:
self.fields['department'].queryset = self.fields['department'].queryset.filter(
hospital=user.hospital
)
self.fields['department'].widget.attrs['class'] = 'form-select'
hospital = None
self.fields["hospital"].queryset = self.fields["hospital"].queryset.none()
else:
hospital = None
# Make source type required
self.fields["source_type"].required = True
self.fields["source_type"].widget.attrs["class"] = "form-select"
# Make category required
self.fields["category"].required = True
self.fields["category"].widget.attrs["class"] = "form-select"
# Make priority and severity required
self.fields["priority"].required = True
self.fields["priority"].widget.attrs["class"] = "form-select"
self.fields["severity"].required = True
self.fields["severity"].widget.attrs["class"] = "form-select"
# Filter departments based on hospital
if hospital:
self.fields["department"].queryset = self.fields["department"].queryset.filter(hospital=hospital)
self.fields["department"].widget.attrs["class"] = "form-select"
# Filter assignable users
from apps.accounts.models import User
if user and user.hospital:
self.fields['assigned_to'].queryset = User.objects.filter(
is_active=True,
hospital=user.hospital
).order_by('first_name', 'last_name')
self.fields['assigned_to'].widget.attrs['class'] = 'form-select'
elif user and user.is_px_admin():
# PX admins can assign to any active user
self.fields['assigned_to'].queryset = User.objects.filter(
is_active=True
).order_by('first_name', 'last_name')
self.fields['assigned_to'].widget.attrs['class'] = 'form-select'
if hospital:
self.fields["assigned_to"].queryset = User.objects.filter(is_active=True, hospital=hospital).order_by(
"first_name", "last_name"
)
self.fields["assigned_to"].widget.attrs["class"] = "form-select"
else:
self.fields['assigned_to'].queryset = User.objects.none()
self.fields["assigned_to"].queryset = User.objects.none()
# Set default for requires_approval
self.fields['requires_approval'].initial = True
self.fields['requires_approval'].widget.attrs['class'] = 'form-check-input'
self.fields["requires_approval"].initial = True
self.fields["requires_approval"].widget.attrs["class"] = "form-check-input"
def clean_hospital(self):
"""Validate hospital field"""
hospital = self.cleaned_data.get('hospital')
hospital = self.cleaned_data.get("hospital")
if not hospital:
raise forms.ValidationError("Hospital is required.")
return hospital
def clean_due_at(self):
"""Validate due date is in the future"""
due_at = self.cleaned_data.get('due_at')
due_at = self.cleaned_data.get("due_at")
if due_at:
from django.utils import timezone
if due_at <= timezone.now():
raise forms.ValidationError("Due date must be in the future.")
return due_at
def save(self, commit=True):
"""Save action with manual source type"""
action = super().save(commit=False)
action.source_type = ActionSource.MANUAL
if commit:
action.save()
return action
return action

View File

@ -1,6 +1,7 @@
"""
PX Action Center UI views - Server-rendered templates for action center console
"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType
@ -29,7 +30,7 @@ from .models import (
def action_list(request):
"""
PX Actions list view with advanced filters and views.
Features:
- Multiple views (All, My Actions, Overdue, Escalated, By Source)
- Server-side pagination
@ -39,15 +40,14 @@ def action_list(request):
"""
# Base queryset with optimizations
queryset = PXAction.objects.select_related(
'hospital', 'department', 'assigned_to',
'approved_by', 'closed_by', 'content_type'
"hospital", "department", "assigned_to", "approved_by", "closed_by", "content_type"
)
# Apply RBAC filters
user = request.user
# Get selected hospital for PX Admins (from middleware)
selected_hospital = getattr(request, 'tenant_hospital', None)
selected_hospital = getattr(request, "tenant_hospital", None)
if user.is_px_admin():
# PX Admins see all, but filter by selected hospital if set
if selected_hospital:
@ -60,129 +60,126 @@ def action_list(request):
queryset = queryset.filter(hospital=user.hospital)
else:
queryset = queryset.none()
# View filter (My Actions, Overdue, etc.)
view_filter = request.GET.get('view', 'all')
if view_filter == 'my_actions':
view_filter = request.GET.get("view", "all")
if view_filter == "my_actions":
queryset = queryset.filter(assigned_to=user)
elif view_filter == 'overdue':
elif view_filter == "overdue":
queryset = queryset.filter(is_overdue=True, status__in=[ActionStatus.OPEN, ActionStatus.IN_PROGRESS])
elif view_filter == 'escalated':
elif view_filter == "escalated":
queryset = queryset.filter(escalation_level__gt=0)
elif view_filter == 'pending_approval':
elif view_filter == "pending_approval":
queryset = queryset.filter(status=ActionStatus.PENDING_APPROVAL)
elif view_filter == 'from_surveys':
elif view_filter == "from_surveys":
queryset = queryset.filter(source_type=ActionSource.SURVEY)
elif view_filter == 'from_complaints':
elif view_filter == "from_complaints":
queryset = queryset.filter(source_type__in=[ActionSource.COMPLAINT, ActionSource.COMPLAINT_RESOLUTION])
elif view_filter == 'from_social':
elif view_filter == "from_social":
queryset = queryset.filter(source_type=ActionSource.SOCIAL_MEDIA)
# Apply filters from request
status_filter = request.GET.get('status')
status_filter = request.GET.get("status")
if status_filter:
queryset = queryset.filter(status=status_filter)
severity_filter = request.GET.get('severity')
severity_filter = request.GET.get("severity")
if severity_filter:
queryset = queryset.filter(severity=severity_filter)
priority_filter = request.GET.get('priority')
priority_filter = request.GET.get("priority")
if priority_filter:
queryset = queryset.filter(priority=priority_filter)
category_filter = request.GET.get('category')
category_filter = request.GET.get("category")
if category_filter:
queryset = queryset.filter(category=category_filter)
source_type_filter = request.GET.get('source_type')
source_type_filter = request.GET.get("source_type")
if source_type_filter:
queryset = queryset.filter(source_type=source_type_filter)
hospital_filter = request.GET.get('hospital')
hospital_filter = request.GET.get("hospital")
if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter)
department_filter = request.GET.get('department')
department_filter = request.GET.get("department")
if department_filter:
queryset = queryset.filter(department_id=department_filter)
assigned_to_filter = request.GET.get('assigned_to')
assigned_to_filter = request.GET.get("assigned_to")
if assigned_to_filter:
queryset = queryset.filter(assigned_to_id=assigned_to_filter)
# Search
search_query = request.GET.get('search')
search_query = request.GET.get("search")
if search_query:
queryset = queryset.filter(
Q(title__icontains=search_query) |
Q(description__icontains=search_query)
)
queryset = queryset.filter(Q(title__icontains=search_query) | Q(description__icontains=search_query))
# Date range filters
date_from = request.GET.get('date_from')
date_from = request.GET.get("date_from")
if date_from:
queryset = queryset.filter(created_at__gte=date_from)
date_to = request.GET.get('date_to')
date_to = request.GET.get("date_to")
if date_to:
queryset = queryset.filter(created_at__lte=date_to)
# Ordering
order_by = request.GET.get('order_by', '-created_at')
order_by = request.GET.get("order_by", "-created_at")
queryset = queryset.order_by(order_by)
# Pagination
page_size = int(request.GET.get('page_size', 25))
page_size = int(request.GET.get("page_size", 25))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get('page', 1)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
# Get filter options
hospitals = Hospital.objects.filter(status='active')
hospitals = Hospital.objects.filter(status="active")
if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id)
departments = Department.objects.filter(status='active')
departments = Department.objects.filter(status="active")
if not user.is_px_admin() and user.hospital:
departments = departments.filter(hospital=user.hospital)
# Get assignable users
assignable_users = User.objects.filter(is_active=True)
if user.hospital:
assignable_users = assignable_users.filter(hospital=user.hospital)
# Statistics
stats = {
'total': queryset.count(),
'open': queryset.filter(status=ActionStatus.OPEN).count(),
'in_progress': queryset.filter(status=ActionStatus.IN_PROGRESS).count(),
'overdue': queryset.filter(is_overdue=True).count(),
'pending_approval': queryset.filter(status=ActionStatus.PENDING_APPROVAL).count(),
'my_actions': queryset.filter(assigned_to=user).count(),
"total": queryset.count(),
"open": queryset.filter(status=ActionStatus.OPEN).count(),
"in_progress": queryset.filter(status=ActionStatus.IN_PROGRESS).count(),
"overdue": queryset.filter(is_overdue=True).count(),
"pending_approval": queryset.filter(status=ActionStatus.PENDING_APPROVAL).count(),
"my_actions": queryset.filter(assigned_to=user).count(),
}
context = {
'page_obj': page_obj,
'actions': page_obj.object_list,
'stats': stats,
'hospitals': hospitals,
'departments': departments,
'assignable_users': assignable_users,
'status_choices': ActionStatus.choices,
'source_choices': ActionSource.choices,
'filters': request.GET,
'current_view': view_filter,
"page_obj": page_obj,
"actions": page_obj.object_list,
"stats": stats,
"hospitals": hospitals,
"departments": departments,
"assignable_users": assignable_users,
"status_choices": ActionStatus.choices,
"source_choices": ActionSource.choices,
"filters": request.GET,
"current_view": view_filter,
}
return render(request, 'actions/action_list.html', context)
return render(request, "actions/action_list.html", context)
@login_required
def action_detail(request, pk):
"""
PX Action detail view with logs, attachments, and workflow actions.
Features:
- Full action details
- Activity log timeline
@ -193,43 +190,39 @@ def action_detail(request, pk):
"""
action = get_object_or_404(
PXAction.objects.select_related(
'hospital', 'department', 'assigned_to',
'approved_by', 'closed_by', 'content_type'
).prefetch_related(
'logs__created_by',
'attachments__uploaded_by'
),
pk=pk
"hospital", "department", "assigned_to", "approved_by", "closed_by", "content_type"
).prefetch_related("logs__created_by", "attachments__uploaded_by"),
pk=pk,
)
# Check access
user = request.user
if not user.is_px_admin():
if user.is_hospital_admin() and action.hospital != user.hospital:
messages.error(request, "You don't have permission to view this action.")
return redirect('actions:action_list')
return redirect("actions:action_list")
elif user.is_department_manager() and action.department != user.department:
messages.error(request, "You don't have permission to view this action.")
return redirect('actions:action_list')
return redirect("actions:action_list")
elif user.hospital and action.hospital != user.hospital:
messages.error(request, "You don't have permission to view this action.")
return redirect('actions:action_list')
return redirect("actions:action_list")
# Get logs (timeline)
logs = action.logs.all().order_by('-created_at')
logs = action.logs.all().order_by("-created_at")
# Get attachments
attachments = action.attachments.all().order_by('-created_at')
attachments = action.attachments.all().order_by("-created_at")
evidence_attachments = attachments.filter(is_evidence=True)
# Get assignable users
assignable_users = User.objects.filter(is_active=True)
if action.hospital:
assignable_users = assignable_users.filter(hospital=action.hospital)
# Check if overdue
action.check_overdue()
# Calculate SLA progress percentage
if action.created_at and action.due_at:
total_duration = (action.due_at - action.created_at).total_seconds()
@ -237,20 +230,20 @@ def action_detail(request, pk):
sla_progress = min(100, int((elapsed_duration / total_duration) * 100)) if total_duration > 0 else 0
else:
sla_progress = 0
context = {
'action': action,
'logs': logs,
'attachments': attachments,
'evidence_attachments': evidence_attachments,
'assignable_users': assignable_users,
'status_choices': ActionStatus.choices,
'sla_progress': sla_progress,
'can_edit': user.is_px_admin() or user.is_hospital_admin() or action.assigned_to == user,
'can_approve': user.is_px_admin(),
"action": action,
"logs": logs,
"attachments": attachments,
"evidence_attachments": evidence_attachments,
"assignable_users": assignable_users,
"status_choices": ActionStatus.choices,
"sla_progress": sla_progress,
"can_edit": user.is_px_admin() or user.is_hospital_admin() or action.assigned_to == user,
"can_approve": user.is_px_admin(),
}
return render(request, 'actions/action_detail.html', context)
return render(request, "actions/action_detail.html", context)
@login_required
@ -258,46 +251,46 @@ def action_detail(request, pk):
def action_assign(request, pk):
"""Assign action to user"""
action = get_object_or_404(PXAction, pk=pk)
# Check permission
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to assign actions.")
return redirect('actions:action_detail', pk=pk)
user_id = request.POST.get('user_id')
return redirect("actions:action_detail", pk=pk)
user_id = request.POST.get("user_id")
if not user_id:
messages.error(request, "Please select a user to assign.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
try:
assignee = User.objects.get(id=user_id)
action.assigned_to = assignee
action.assigned_at = timezone.now()
action.save(update_fields=['assigned_to', 'assigned_at'])
action.save(update_fields=["assigned_to", "assigned_at"])
# Create log
PXActionLog.objects.create(
action=action,
log_type='assignment',
log_type="assignment",
message=f"Assigned to {assignee.get_full_name()}",
created_by=request.user
created_by=request.user,
)
# Audit log
AuditService.log_event(
event_type='assignment',
event_type="assignment",
description=f"Action assigned to {assignee.get_full_name()}",
user=request.user,
content_object=action
content_object=action,
)
messages.success(request, f"Action assigned to {assignee.get_full_name()}.")
except User.DoesNotExist:
messages.error(request, "User not found.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
@login_required
@ -305,64 +298,64 @@ def action_assign(request, pk):
def action_change_status(request, pk):
"""Change action status"""
action = get_object_or_404(PXAction, pk=pk)
# Check permission
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin() or action.assigned_to == user):
messages.error(request, "You don't have permission to change action status.")
return redirect('actions:action_detail', pk=pk)
new_status = request.POST.get('status')
note = request.POST.get('note', '')
return redirect("actions:action_detail", pk=pk)
new_status = request.POST.get("status")
note = request.POST.get("note", "")
if not new_status:
messages.error(request, "Please select a status.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
# Validate status transitions
if new_status == ActionStatus.PENDING_APPROVAL:
if action.requires_approval:
evidence_count = action.attachments.filter(is_evidence=True).count()
if evidence_count == 0:
messages.error(request, "Evidence is required before requesting approval.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
elif new_status == ActionStatus.APPROVED:
if not user.is_px_admin():
messages.error(request, "Only PX Admins can approve actions.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
action.approved_by = user
action.approved_at = timezone.now()
elif new_status == ActionStatus.CLOSED:
action.closed_at = timezone.now()
action.closed_by = user
old_status = action.status
action.status = new_status
action.save()
# Create log
PXActionLog.objects.create(
action=action,
log_type='status_change',
log_type="status_change",
message=note or f"Status changed from {old_status} to {new_status}",
created_by=user,
old_status=old_status,
new_status=new_status
new_status=new_status,
)
# Audit log
AuditService.log_event(
event_type='status_change',
event_type="status_change",
description=f"Action status changed from {old_status} to {new_status}",
user=user,
content_object=action,
metadata={'old_status': old_status, 'new_status': new_status}
metadata={"old_status": old_status, "new_status": new_status},
)
messages.success(request, f"Action status changed to {new_status}.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
@login_required
@ -370,22 +363,17 @@ def action_change_status(request, pk):
def action_add_note(request, pk):
"""Add note to action"""
action = get_object_or_404(PXAction, pk=pk)
note = request.POST.get('note')
note = request.POST.get("note")
if not note:
messages.error(request, "Please enter a note.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
# Create log
PXActionLog.objects.create(
action=action,
log_type='note',
message=note,
created_by=request.user
)
PXActionLog.objects.create(action=action, log_type="note", message=note, created_by=request.user)
messages.success(request, "Note added successfully.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
@login_required
@ -393,39 +381,39 @@ def action_add_note(request, pk):
def action_escalate(request, pk):
"""Escalate action"""
action = get_object_or_404(PXAction, pk=pk)
# Check permission
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to escalate actions.")
return redirect('actions:action_detail', pk=pk)
reason = request.POST.get('reason', '')
return redirect("actions:action_detail", pk=pk)
reason = request.POST.get("reason", "")
# Increment escalation level
action.escalation_level += 1
action.escalated_at = timezone.now()
action.save(update_fields=['escalation_level', 'escalated_at'])
action.save(update_fields=["escalation_level", "escalated_at"])
# Create log
PXActionLog.objects.create(
action=action,
log_type='escalation',
log_type="escalation",
message=f"Action escalated (Level {action.escalation_level}). Reason: {reason}",
created_by=user
created_by=user,
)
# Audit log
AuditService.log_event(
event_type='escalation',
event_type="escalation",
description=f"Action escalated to level {action.escalation_level}",
user=user,
content_object=action,
metadata={'reason': reason, 'escalation_level': action.escalation_level}
metadata={"reason": reason, "escalation_level": action.escalation_level},
)
messages.success(request, f"Action escalated to level {action.escalation_level}.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
@login_required
@ -433,49 +421,46 @@ def action_escalate(request, pk):
def action_approve(request, pk):
"""Approve action (PX Admin only)"""
action = get_object_or_404(PXAction, pk=pk)
# Check permission
if not request.user.is_px_admin():
messages.error(request, "Only PX Admins can approve actions.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
if action.status != ActionStatus.PENDING_APPROVAL:
messages.error(request, "Action is not pending approval.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
# Approve
action.status = ActionStatus.APPROVED
action.approved_by = request.user
action.approved_at = timezone.now()
action.save()
# Create log
PXActionLog.objects.create(
action=action,
log_type='approval',
log_type="approval",
message=f"Action approved by {request.user.get_full_name()}",
created_by=request.user,
old_status=ActionStatus.PENDING_APPROVAL,
new_status=ActionStatus.APPROVED
new_status=ActionStatus.APPROVED,
)
# Audit log
AuditService.log_event(
event_type='approval',
description="Action approved",
user=request.user,
content_object=action
event_type="approval", description="Action approved", user=request.user, content_object=action
)
messages.success(request, "Action approved successfully.")
return redirect('actions:action_detail', pk=pk)
return redirect("actions:action_detail", pk=pk)
@login_required
def action_create(request):
"""
Create a new PX Action manually.
Features:
- Source type selection (including meeting types)
- Hospital/department selection (filtered by permissions)
@ -488,67 +473,68 @@ def action_create(request):
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager()):
messages.error(request, "You don't have permission to create actions.")
return redirect('actions:action_list')
if request.method == 'POST':
form = ManualActionForm(request.POST, user=user)
return redirect("actions:action_list")
if request.method == "POST":
form = ManualActionForm(request.POST, request=request)
if form.is_valid():
action = form.save(commit=False)
action.created_by = user
# Set status to OPEN
action.status = ActionStatus.OPEN
# If assigned, set assignment timestamp
if action.assigned_to:
action.assigned_at = timezone.now()
action.save()
# Create log
PXActionLog.objects.create(
action=action,
log_type='status_change',
log_type="status_change",
message=f"Action created manually from {action.get_source_type_display()}",
created_by=user
created_by=user,
)
# Audit log
AuditService.log_event(
event_type='action_created',
event_type="action_created",
description=f"Manual action created from {action.get_source_type_display()}",
user=user,
content_object=action
content_object=action,
)
# Notify assigned user
if action.assigned_to:
from apps.notifications.services import NotificationService
NotificationService.send_notification(
recipient=action.assigned_to,
title=f"New Action Assigned: {action.title}",
message=f"You have been assigned a new action. Due: {action.due_at.strftime('%Y-%m-%d %H:%M')}",
notification_type='action_assigned',
metadata={'link': f"/actions/{action.id}/"}
notification_type="action_assigned",
metadata={"link": f"/actions/{action.id}/"},
)
messages.success(request, "Action created successfully.")
return redirect('actions:action_detail', pk=action.id)
return redirect("actions:action_detail", pk=action.id)
else:
# Pre-fill hospital if user has one
initial = {}
if user.hospital:
initial['hospital'] = user.hospital.id
form = ManualActionForm(user=user, initial=initial)
initial["hospital"] = user.hospital.id
form = ManualActionForm(request=request)
context = {
'form': form,
'source_choices': ActionSource.choices,
'status_choices': ActionStatus.choices,
"form": form,
"source_choices": ActionSource.choices,
"status_choices": ActionStatus.choices,
}
return render(request, 'actions/action_create.html', context)
return render(request, "actions/action_create.html", context)
@login_required
@ -556,45 +542,35 @@ def action_create(request):
def action_create_from_ai(request, complaint_id):
"""
Create a PX Action automatically from AI suggestion.
Creates action in background and returns JSON response.
Links action to the complaint automatically.
"""
from apps.complaints.models import Complaint
import json
complaint = get_object_or_404(Complaint, id=complaint_id)
# Check permission
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
return JsonResponse({
'success': False,
'error': 'You do not have permission to create actions.'
}, status=403)
return JsonResponse({"success": False, "error": "You do not have permission to create actions."}, status=403)
# Get action data from POST
action_text = request.POST.get('action', '')
priority = request.POST.get('priority', 'medium')
category = request.POST.get('category', 'process_improvement')
action_text = request.POST.get("action", "")
priority = request.POST.get("priority", "medium")
category = request.POST.get("category", "process_improvement")
if not action_text:
return JsonResponse({
'success': False,
'error': 'Action description is required.'
}, status=400)
return JsonResponse({"success": False, "error": "Action description is required."}, status=400)
try:
# Map priority/severity
priority_map = {
'high': 'high',
'medium': 'medium',
'low': 'low'
}
priority_map = {"high": "high", "medium": "medium", "low": "low"}
# Create action
action = PXAction.objects.create(
source_type='complaint',
source_type="complaint",
content_type=ContentType.objects.get_for_model(Complaint),
object_id=complaint.id,
title=f"AI Suggested Action - {complaint.reference_number}",
@ -602,63 +578,60 @@ def action_create_from_ai(request, complaint_id):
hospital=complaint.hospital,
department=complaint.department,
category=category,
priority=priority_map.get(priority, 'medium'),
severity=priority_map.get(priority, 'medium'),
priority=priority_map.get(priority, "medium"),
severity=priority_map.get(priority, "medium"),
status=ActionStatus.OPEN,
assigned_to=complaint.assigned_to, # Assign to same person as complaint
assigned_at=timezone.now() if complaint.assigned_to else None,
)
# Set due date based on priority (SLA)
from datetime import timedelta
due_days = {
'high': 3,
'medium': 7,
'low': 14
}
due_days = {"high": 3, "medium": 7, "low": 14}
action.due_at = timezone.now() + timedelta(days=due_days.get(priority, 7))
action.save()
# Create log
PXActionLog.objects.create(
action=action,
log_type='status_change',
log_type="status_change",
message=f"Action created automatically from AI suggestion for complaint {complaint.reference_number}",
)
# Audit log
AuditService.log_event(
event_type='action_created',
event_type="action_created",
description=f"Action created automatically from AI suggestion",
user=user,
content_object=action,
metadata={
'complaint_id': str(complaint.id),
'complaint_reference': complaint.reference_number,
'source': 'ai_suggestion'
}
"complaint_id": str(complaint.id),
"complaint_reference": complaint.reference_number,
"source": "ai_suggestion",
},
)
# Notify assigned user
if action.assigned_to:
from apps.notifications.services import NotificationService
NotificationService.send_notification(
recipient=action.assigned_to,
title=f"New Action from AI: {action.title}",
message=f"AI suggested a new action for complaint {complaint.reference_number}. Due: {action.due_at.strftime('%Y-%m-%d')}",
notification_type='action_assigned',
metadata={'link': f"/actions/{action.id}/"}
notification_type="action_assigned",
metadata={"link": f"/actions/{action.id}/"},
)
return JsonResponse({
'success': True,
'action_id': str(action.id),
'action_url': f"/actions/{action.id}/",
'message': 'PX Action created successfully from AI suggestion.'
})
return JsonResponse(
{
"success": True,
"action_id": str(action.id),
"action_url": f"/actions/{action.id}/",
"message": "PX Action created successfully from AI suggestion.",
}
)
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
return JsonResponse({"success": False, "error": str(e)}, status=500)

View File

@ -1,11 +1,11 @@
"""
RCA (Root Cause Analysis) forms
"""
from django import forms
from django.utils import timezone
from apps.core.models import PriorityChoices
from apps.core.form_mixins import HospitalFieldMixin
from .models import (
RCAActionStatus,
RCAActionType,
@ -19,79 +19,52 @@ from .models import (
)
class RootCauseAnalysisForm(forms.ModelForm):
class RootCauseAnalysisForm(HospitalFieldMixin, forms.ModelForm):
"""Form for creating and editing RootCauseAnalysis"""
class Meta:
model = RootCauseAnalysis
fields = [
'title',
'description',
'background',
'hospital',
'department',
'status',
'severity',
'priority',
'assigned_to',
'target_completion_date',
'root_cause_summary',
"title",
"description",
"background",
"hospital",
"department",
"status",
"severity",
"priority",
"assigned_to",
"target_completion_date",
"root_cause_summary",
]
widgets = {
'title': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter RCA title'
}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 4,
'placeholder': 'Describe the incident or issue'
}),
'background': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Provide background information and context'
}),
'hospital': forms.Select(attrs={'class': 'form-select'}),
'department': forms.Select(attrs={'class': 'form-select'}),
'status': forms.Select(attrs={'class': 'form-select'}),
'severity': forms.Select(attrs={'class': 'form-select'}),
'priority': forms.Select(attrs={'class': 'form-select'}),
'assigned_to': forms.Select(attrs={'class': 'form-select'}),
'target_completion_date': forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
}),
'root_cause_summary': forms.Textarea(attrs={
'class': 'form-control',
'rows': 4,
'placeholder': 'Summary of root cause analysis findings'
}),
"title": forms.TextInput(attrs={"class": "form-control", "placeholder": "Enter RCA title"}),
"description": forms.Textarea(
attrs={"class": "form-control", "rows": 4, "placeholder": "Describe the incident or issue"}
),
"background": forms.Textarea(
attrs={"class": "form-control", "rows": 3, "placeholder": "Provide background information and context"}
),
"hospital": forms.Select(attrs={"class": "form-select"}),
"department": forms.Select(attrs={"class": "form-select"}),
"status": forms.Select(attrs={"class": "form-select"}),
"severity": forms.Select(attrs={"class": "form-select"}),
"priority": forms.Select(attrs={"class": "form-select"}),
"assigned_to": forms.Select(attrs={"class": "form-select"}),
"target_completion_date": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
"root_cause_summary": forms.Textarea(
attrs={"class": "form-control", "rows": 4, "placeholder": "Summary of root cause analysis findings"}
),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Filter assigned_to to show only active users
if 'assigned_to' in self.fields:
if "assigned_to" in self.fields:
from apps.accounts.models import User
self.fields['assigned_to'].queryset = User.objects.filter(
is_active=True
).order_by('email')
# Set initial hospital if user has one
if user and not self.instance.pk:
from apps.organizations.models import Hospital
try:
user_hospital = Hospital.objects.filter(
staff__user=user
).first()
if user_hospital:
self.fields['hospital'].initial = user_hospital
self.fields['hospital'].widget.attrs['readonly'] = True
except:
pass
self.fields["assigned_to"].queryset = User.objects.filter(is_active=True).order_by("email")
class RCARootCauseForm(forms.ModelForm):
@ -100,54 +73,38 @@ class RCARootCauseForm(forms.ModelForm):
class Meta:
model = RCARootCause
fields = [
'description',
'category',
'contributing_factors',
'likelihood',
'impact',
'evidence',
"description",
"category",
"contributing_factors",
"likelihood",
"impact",
"evidence",
]
widgets = {
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Describe the root cause'
}),
'category': forms.Select(attrs={'class': 'form-select'}),
'contributing_factors': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'Factors that contributed to this root cause'
}),
'likelihood': forms.NumberInput(attrs={
'class': 'form-control',
'min': 1,
'max': 5,
'placeholder': '1-5'
}),
'impact': forms.NumberInput(attrs={
'class': 'form-control',
'min': 1,
'max': 5,
'placeholder': '1-5'
}),
'evidence': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'Evidence supporting this root cause'
}),
"description": forms.Textarea(
attrs={"class": "form-control", "rows": 3, "placeholder": "Describe the root cause"}
),
"category": forms.Select(attrs={"class": "form-select"}),
"contributing_factors": forms.Textarea(
attrs={"class": "form-control", "rows": 2, "placeholder": "Factors that contributed to this root cause"}
),
"likelihood": forms.NumberInput(attrs={"class": "form-control", "min": 1, "max": 5, "placeholder": "1-5"}),
"impact": forms.NumberInput(attrs={"class": "form-control", "min": 1, "max": 5, "placeholder": "1-5"}),
"evidence": forms.Textarea(
attrs={"class": "form-control", "rows": 2, "placeholder": "Evidence supporting this root cause"}
),
}
def clean_likelihood(self):
likelihood = self.cleaned_data.get('likelihood')
likelihood = self.cleaned_data.get("likelihood")
if likelihood and (likelihood < 1 or likelihood > 5):
raise forms.ValidationError('Likelihood must be between 1 and 5')
raise forms.ValidationError("Likelihood must be between 1 and 5")
return likelihood
def clean_impact(self):
impact = self.cleaned_data.get('impact')
impact = self.cleaned_data.get("impact")
if impact and (impact < 1 or impact > 5):
raise forms.ValidationError('Impact must be between 1 and 5')
raise forms.ValidationError("Impact must be between 1 and 5")
return impact
@ -157,209 +114,159 @@ class RCACorrectiveActionForm(forms.ModelForm):
class Meta:
model = RCACorrectiveAction
fields = [
'description',
'action_type',
'root_cause',
'responsible_person',
'target_date',
'completion_date',
'status',
'effectiveness_measure',
'effectiveness_assessment',
'effectiveness_score',
'obstacles',
"description",
"action_type",
"root_cause",
"responsible_person",
"target_date",
"completion_date",
"status",
"effectiveness_measure",
"effectiveness_assessment",
"effectiveness_score",
"obstacles",
]
widgets = {
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Describe the corrective action'
}),
'action_type': forms.Select(attrs={'class': 'form-select'}),
'root_cause': forms.Select(attrs={'class': 'form-select'}),
'responsible_person': forms.Select(attrs={'class': 'form-select'}),
'target_date': forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
}),
'completion_date': forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
}),
'status': forms.Select(attrs={'class': 'form-select'}),
'effectiveness_measure': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'How will effectiveness be measured?'
}),
'effectiveness_assessment': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'Assessment of action effectiveness'
}),
'effectiveness_score': forms.NumberInput(attrs={
'class': 'form-control',
'min': 1,
'max': 5,
'placeholder': '1-5'
}),
'obstacles': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'Obstacles encountered during implementation'
}),
"description": forms.Textarea(
attrs={"class": "form-control", "rows": 3, "placeholder": "Describe the corrective action"}
),
"action_type": forms.Select(attrs={"class": "form-select"}),
"root_cause": forms.Select(attrs={"class": "form-select"}),
"responsible_person": forms.Select(attrs={"class": "form-select"}),
"target_date": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
"completion_date": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
"status": forms.Select(attrs={"class": "form-select"}),
"effectiveness_measure": forms.Textarea(
attrs={"class": "form-control", "rows": 2, "placeholder": "How will effectiveness be measured?"}
),
"effectiveness_assessment": forms.Textarea(
attrs={"class": "form-control", "rows": 2, "placeholder": "Assessment of action effectiveness"}
),
"effectiveness_score": forms.NumberInput(
attrs={"class": "form-control", "min": 1, "max": 5, "placeholder": "1-5"}
),
"obstacles": forms.Textarea(
attrs={"class": "form-control", "rows": 2, "placeholder": "Obstacles encountered during implementation"}
),
}
def __init__(self, *args, **kwargs):
rca = kwargs.pop('rca', None)
rca = kwargs.pop("rca", None)
super().__init__(*args, **kwargs)
# Filter root_cause to show only for this RCA
if 'root_cause' in self.fields and rca:
self.fields['root_cause'].queryset = rca.root_causes.all()
if "root_cause" in self.fields and rca:
self.fields["root_cause"].queryset = rca.root_causes.all()
def clean_effectiveness_score(self):
score = self.cleaned_data.get('effectiveness_score')
score = self.cleaned_data.get("effectiveness_score")
if score and (score < 1 or score > 5):
raise forms.ValidationError('Effectiveness score must be between 1 and 5')
raise forms.ValidationError("Effectiveness score must be between 1 and 5")
return score
class RCAFilterForm(forms.Form):
"""Form for filtering RCA list"""
status = forms.ChoiceField(
required=False,
choices=[('', 'All Statuses')] + list(RCAStatus.choices),
widget=forms.Select(attrs={'class': 'form-select'})
choices=[("", "All Statuses")] + list(RCAStatus.choices),
widget=forms.Select(attrs={"class": "form-select"}),
)
severity = forms.ChoiceField(
required=False,
choices=[('', 'All Severities')] + list(RCASeverity.choices),
widget=forms.Select(attrs={'class': 'form-select'})
choices=[("", "All Severities")] + list(RCASeverity.choices),
widget=forms.Select(attrs={"class": "form-select"}),
)
priority = forms.ChoiceField(
required=False,
choices=[('', 'All Priorities')] + list(PriorityChoices.choices),
widget=forms.Select(attrs={'class': 'form-select'})
choices=[("", "All Priorities")] + list(PriorityChoices.choices),
widget=forms.Select(attrs={"class": "form-select"}),
)
hospital = forms.ChoiceField(
required=False,
choices=[('', 'All Hospitals')],
widget=forms.Select(attrs={'class': 'form-select'})
required=False, choices=[("", "All Hospitals")], widget=forms.Select(attrs={"class": "form-select"})
)
department = forms.ChoiceField(
required=False,
choices=[('', 'All Departments')],
widget=forms.Select(attrs={'class': 'form-select'})
required=False, choices=[("", "All Departments")], widget=forms.Select(attrs={"class": "form-select"})
)
search = forms.CharField(
required=False,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Search RCAs...'
})
)
date_from = forms.DateField(
required=False,
widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
)
date_to = forms.DateField(
required=False,
widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
required=False, widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Search RCAs..."})
)
date_from = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}))
date_to = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Populate hospital choices
from apps.organizations.models import Hospital
hospital_choices = [('', 'All Hospitals')]
hospital_choices.extend([
(h.id, h.name)
for h in Hospital.objects.all()
])
self.fields['hospital'].choices = hospital_choices
hospital_choices = [("", "All Hospitals")]
hospital_choices.extend([(h.id, h.name) for h in Hospital.objects.all()])
self.fields["hospital"].choices = hospital_choices
class RCAStatusChangeForm(forms.Form):
"""Form for changing RCA status"""
new_status = forms.ChoiceField(
choices=RCAStatus.choices,
widget=forms.Select(attrs={'class': 'form-select'})
)
new_status = forms.ChoiceField(choices=RCAStatus.choices, widget=forms.Select(attrs={"class": "form-select"}))
notes = forms.CharField(
required=False,
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Add notes about this status change'
})
widget=forms.Textarea(
attrs={"class": "form-control", "rows": 3, "placeholder": "Add notes about this status change"}
),
)
class RCAApprovalForm(forms.Form):
"""Form for approving RCA"""
approval_notes = forms.CharField(
required=False,
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Add approval notes'
})
widget=forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Add approval notes"}),
)
class RCAClosureForm(forms.Form):
"""Form for closing RCA"""
closure_notes = forms.CharField(
required=True,
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Provide closure notes and summary'
})
widget=forms.Textarea(
attrs={"class": "form-control", "rows": 3, "placeholder": "Provide closure notes and summary"}
),
)
actual_completion_date = forms.DateField(
required=True,
widget=forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
})
required=True, widget=forms.DateInput(attrs={"class": "form-control", "type": "date"})
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set default to today
self.fields['actual_completion_date'].initial = timezone.now().date()
self.fields["actual_completion_date"].initial = timezone.now().date()
class RCAAttachmentForm(forms.ModelForm):
"""Form for uploading RCA attachments"""
class Meta:
model = RCAAttachment
fields = ['file', 'description']
fields = ["file", "description"]
widgets = {
'file': forms.FileInput(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'Describe this attachment'
}),
"file": forms.FileInput(attrs={"class": "form-control"}),
"description": forms.Textarea(
attrs={"class": "form-control", "rows": 2, "placeholder": "Describe this attachment"}
),
}
class RCANoteForm(forms.ModelForm):
"""Form for adding RCA notes"""
class Meta:
model = RCANote
fields = ['note', 'is_internal']
fields = ["note", "is_internal"]
widgets = {
'note': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Add a note'
}),
'is_internal': forms.CheckboxInput(attrs={
'class': 'form-check-input'
}),
}
"note": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Add a note"}),
"is_internal": forms.CheckboxInput(attrs={"class": "form-check-input"}),
}

View File

@ -1,6 +1,7 @@
"""
Survey forms for CRUD operations
"""
from django import forms
from django.utils.translation import gettext_lazy as _
@ -12,344 +13,319 @@ from .models import SurveyInstance, SurveyTemplate, SurveyQuestion
class SurveyTemplateForm(HospitalFieldMixin, forms.ModelForm):
"""
Form for creating/editing survey templates.
Hospital field visibility:
- PX Admins: See dropdown with all hospitals
- Others: Hidden field, auto-set to user's hospital
"""
class Meta:
model = SurveyTemplate
fields = [
'name', 'name_ar', 'hospital', 'survey_type',
'scoring_method', 'negative_threshold', 'is_active'
]
fields = ["name", "name_ar", "hospital", "survey_type", "scoring_method", "negative_threshold", "is_active"]
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'e.g., MD Consultation Feedback'
}),
'name_ar': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'الاسم بالعربية'
}),
'hospital': forms.Select(attrs={'class': 'form-select'}),
'survey_type': forms.Select(attrs={'class': 'form-select'}),
'scoring_method': forms.Select(attrs={'class': 'form-select'}),
'negative_threshold': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.1',
'min': '1',
'max': '5'
}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
"name": forms.TextInput(attrs={"class": "form-control", "placeholder": "e.g., MD Consultation Feedback"}),
"name_ar": forms.TextInput(attrs={"class": "form-control", "placeholder": "الاسم بالعربية"}),
"hospital": forms.Select(attrs={"class": "form-select"}),
"survey_type": forms.Select(attrs={"class": "form-select"}),
"scoring_method": forms.Select(attrs={"class": "form-select"}),
"negative_threshold": forms.NumberInput(
attrs={"class": "form-control", "step": "0.1", "min": "1", "max": "5"}
),
"is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}),
}
class SurveyQuestionForm(forms.ModelForm):
"""Form for creating/editing survey questions"""
class Meta:
model = SurveyQuestion
fields = [
'text', 'text_ar', 'question_type', 'order',
'is_required', 'choices_json'
]
fields = ["text", "text_ar", "question_type", "order", "is_required", "choices_json"]
widgets = {
'text': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'Enter question in English'
}),
'text_ar': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'أدخل السؤال بالعربية'
}),
'question_type': forms.Select(attrs={'class': 'form-select'}),
'order': forms.NumberInput(attrs={
'class': 'form-control',
'min': '0'
}),
'is_required': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'choices_json': forms.Textarea(attrs={
'class': 'form-control',
'rows': 5,
'placeholder': '[{"value": "1", "label": "Option 1", "label_ar": "خيار 1"}]'
}),
"text": forms.Textarea(
attrs={"class": "form-control", "rows": 2, "placeholder": "Enter question in English"}
),
"text_ar": forms.Textarea(
attrs={"class": "form-control", "rows": 2, "placeholder": "أدخل السؤال بالعربية"}
),
"question_type": forms.Select(attrs={"class": "form-select"}),
"order": forms.NumberInput(attrs={"class": "form-control", "min": "0"}),
"is_required": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"choices_json": forms.Textarea(
attrs={
"class": "form-control",
"rows": 5,
"placeholder": '[{"value": "1", "label": "Option 1", "label_ar": "خيار 1"}]',
}
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['choices_json'].required = False
self.fields['choices_json'].help_text = _(
'JSON array of choices for multiple choice questions. '
self.fields["choices_json"].required = False
self.fields["choices_json"].help_text = _(
"JSON array of choices for multiple choice questions. "
'Format: [{"value": "1", "label": "Option 1", "label_ar": "خيار 1"}]'
)
SurveyQuestionFormSet = forms.inlineformset_factory(
SurveyTemplate,
SurveyQuestion,
form=SurveyQuestionForm,
extra=1,
can_delete=True,
min_num=1,
validate_min=True
SurveyTemplate, SurveyQuestion, form=SurveyQuestionForm, extra=1, can_delete=True, min_num=1, validate_min=True
)
class ManualSurveySendForm(forms.Form):
"""Form for manually sending surveys to patients or staff"""
RECIPIENT_TYPE_CHOICES = [
('patient', _('Patient')),
('staff', _('Staff')),
("patient", _("Patient")),
("staff", _("Staff")),
]
DELIVERY_CHANNEL_CHOICES = [
('email', _('Email')),
('sms', _('SMS')),
("email", _("Email")),
("sms", _("SMS")),
]
def __init__(self, user, *args, **kwargs):
def __init__(self, request=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
# Filter survey templates by user's hospital
if user.hospital:
self.fields['survey_template'].queryset = SurveyTemplate.objects.filter(
hospital=user.hospital,
is_active=True
)
self.request = request
self.user = request.user if request else None
# Determine hospital context
hospital = None
if self.user and self.user.is_px_admin():
hospital = getattr(request, "tenant_hospital", None)
elif self.user and self.user.hospital:
hospital = self.user.hospital
# Filter survey templates by hospital
if hospital:
self.fields["survey_template"].queryset = SurveyTemplate.objects.filter(hospital=hospital, is_active=True)
survey_template = forms.ModelChoiceField(
queryset=SurveyTemplate.objects.filter(is_active=True),
label=_('Survey Template'),
widget=forms.Select(attrs={
'class': 'form-select',
'data-placeholder': _('Select a survey template')
})
label=_("Survey Template"),
widget=forms.Select(attrs={"class": "form-select", "data-placeholder": _("Select a survey template")}),
)
recipient_type = forms.ChoiceField(
choices=RECIPIENT_TYPE_CHOICES,
label=_('Recipient Type'),
widget=forms.RadioSelect(attrs={
'class': 'form-check-input'
})
label=_("Recipient Type"),
widget=forms.RadioSelect(attrs={"class": "form-check-input"}),
)
recipient = forms.CharField(
label=_('Recipient'),
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('Search by name or ID...'),
'data-search-url': '/api/recipients/search/'
}),
help_text=_('Start typing to search for patient or staff')
label=_("Recipient"),
widget=forms.TextInput(
attrs={
"class": "form-control",
"placeholder": _("Search by name or ID..."),
"data-search-url": "/api/recipients/search/",
}
),
help_text=_("Start typing to search for patient or staff"),
)
delivery_channel = forms.ChoiceField(
choices=DELIVERY_CHANNEL_CHOICES,
label=_('Delivery Channel'),
widget=forms.Select(attrs={
'class': 'form-select'
})
label=_("Delivery Channel"),
widget=forms.Select(attrs={"class": "form-select"}),
)
custom_message = forms.CharField(
label=_('Custom Message (Optional)'),
label=_("Custom Message (Optional)"),
required=False,
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': _('Add a custom message to the survey invitation...')
})
widget=forms.Textarea(
attrs={
"class": "form-control",
"rows": 3,
"placeholder": _("Add a custom message to the survey invitation..."),
}
),
)
class ManualPhoneSurveySendForm(forms.Form):
"""Form for sending surveys to a manually entered phone number"""
def __init__(self, user, *args, **kwargs):
def __init__(self, request=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
# Filter survey templates by user's hospital
if user.hospital:
self.fields['survey_template'].queryset = SurveyTemplate.objects.filter(
hospital=user.hospital,
is_active=True
)
self.request = request
self.user = request.user if request else None
# Determine hospital context
hospital = None
if self.user and self.user.is_px_admin():
hospital = getattr(request, "tenant_hospital", None)
elif self.user and self.user.hospital:
hospital = self.user.hospital
# Filter survey templates by hospital
if hospital:
self.fields["survey_template"].queryset = SurveyTemplate.objects.filter(hospital=hospital, is_active=True)
survey_template = forms.ModelChoiceField(
queryset=SurveyTemplate.objects.filter(is_active=True),
label=_('Survey Template'),
widget=forms.Select(attrs={
'class': 'form-select'
})
label=_("Survey Template"),
widget=forms.Select(attrs={"class": "form-select"}),
)
phone_number = forms.CharField(
label=_('Phone Number'),
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('+966501234567')
}),
help_text=_('Enter phone number with country code (e.g., +966...)')
label=_("Phone Number"),
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": _("+966501234567")}),
help_text=_("Enter phone number with country code (e.g., +966...)"),
)
recipient_name = forms.CharField(
label=_('Recipient Name (Optional)'),
label=_("Recipient Name (Optional)"),
required=False,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('Patient Name')
})
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": _("Patient Name")}),
)
custom_message = forms.CharField(
label=_('Custom Message (Optional)'),
label=_("Custom Message (Optional)"),
required=False,
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': _('Add a custom message to the survey invitation...')
})
widget=forms.Textarea(
attrs={
"class": "form-control",
"rows": 3,
"placeholder": _("Add a custom message to the survey invitation..."),
}
),
)
def clean_phone_number(self):
phone = self.cleaned_data['phone_number'].strip()
phone = self.cleaned_data["phone_number"].strip()
# Remove spaces, dashes, parentheses
phone = phone.replace(' ', '').replace('-', '').replace('(', '').replace(')', '')
if not phone.startswith('+'):
raise forms.ValidationError(_('Phone number must start with country code (e.g., +966)'))
phone = phone.replace(" ", "").replace("-", "").replace("(", "").replace(")", "")
if not phone.startswith("+"):
raise forms.ValidationError(_("Phone number must start with country code (e.g., +966)"))
return phone
class BulkCSVSurveySendForm(forms.Form):
"""Form for bulk sending surveys via CSV upload"""
survey_template = forms.ModelChoiceField(
queryset=SurveyTemplate.objects.filter(is_active=True),
label=_('Survey Template'),
widget=forms.Select(attrs={
'class': 'form-select'
})
label=_("Survey Template"),
widget=forms.Select(attrs={"class": "form-select"}),
)
csv_file = forms.FileField(
label=_('CSV File'),
widget=forms.FileInput(attrs={
'class': 'form-control',
'accept': '.csv'
}),
help_text=_('Upload CSV with phone numbers. Format: phone_number,name(optional)')
label=_("CSV File"),
widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv"}),
help_text=_("Upload CSV with phone numbers. Format: phone_number,name(optional)"),
)
custom_message = forms.CharField(
label=_('Custom Message (Optional)'),
label=_("Custom Message (Optional)"),
required=False,
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': _('Add a custom message to the survey invitation...')
})
widget=forms.Textarea(
attrs={
"class": "form-control",
"rows": 3,
"placeholder": _("Add a custom message to the survey invitation..."),
}
),
)
def __init__(self, user, *args, **kwargs):
def __init__(self, request=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
# Filter survey templates by user's hospital
if user.hospital:
self.fields['survey_template'].queryset = SurveyTemplate.objects.filter(
hospital=user.hospital,
is_active=True
)
self.request = request
self.user = request.user if request else None
# Determine hospital context
hospital = None
if self.user and self.user.is_px_admin():
hospital = getattr(request, "tenant_hospital", None)
elif self.user and self.user.hospital:
hospital = self.user.hospital
# Filter survey templates by hospital
if hospital:
self.fields["survey_template"].queryset = SurveyTemplate.objects.filter(hospital=hospital, is_active=True)
class HISPatientImportForm(HospitalFieldMixin, forms.Form):
"""
Form for importing patient data from HIS/MOH Statistics CSV.
Hospital field visibility:
- PX Admins: See dropdown with all hospitals
- Others: Hidden field, auto-set to user's hospital
"""
hospital = forms.ModelChoiceField(
queryset=Hospital.objects.filter(status='active'),
label=_('Hospital'),
widget=forms.Select(attrs={
'class': 'form-select'
}),
help_text=_('Select the hospital for these patient records')
queryset=Hospital.objects.filter(status="active"),
label=_("Hospital"),
widget=forms.Select(attrs={"class": "form-select"}),
help_text=_("Select the hospital for these patient records"),
)
csv_file = forms.FileField(
label=_('HIS Statistics CSV File'),
widget=forms.FileInput(attrs={
'class': 'form-control',
'accept': '.csv'
}),
help_text=_('Upload MOH Statistics CSV with patient visit data')
label=_("HIS Statistics CSV File"),
widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv"}),
help_text=_("Upload MOH Statistics CSV with patient visit data"),
)
skip_header_rows = forms.IntegerField(
label=_('Skip Header Rows'),
label=_("Skip Header Rows"),
initial=5,
min_value=0,
max_value=10,
widget=forms.NumberInput(attrs={
'class': 'form-control'
}),
help_text=_('Number of metadata/header rows to skip before data rows')
widget=forms.NumberInput(attrs={"class": "form-control"}),
help_text=_("Number of metadata/header rows to skip before data rows"),
)
class HISSurveySendForm(forms.Form):
"""Form for sending surveys to imported HIS patients"""
survey_template = forms.ModelChoiceField(
queryset=SurveyTemplate.objects.filter(is_active=True),
label=_('Survey Template'),
widget=forms.Select(attrs={
'class': 'form-select'
})
label=_("Survey Template"),
widget=forms.Select(attrs={"class": "form-select"}),
)
delivery_channel = forms.ChoiceField(
choices=[
('sms', _('SMS')),
('email', _('Email')),
('both', _('Both SMS and Email')),
("sms", _("SMS")),
("email", _("Email")),
("both", _("Both SMS and Email")),
],
label=_('Delivery Channel'),
initial='sms',
widget=forms.Select(attrs={
'class': 'form-select'
})
label=_("Delivery Channel"),
initial="sms",
widget=forms.Select(attrs={"class": "form-select"}),
)
custom_message = forms.CharField(
label=_('Custom Message (Optional)'),
label=_("Custom Message (Optional)"),
required=False,
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': _('Add a custom message to the survey invitation...')
})
widget=forms.Textarea(
attrs={
"class": "form-control",
"rows": 3,
"placeholder": _("Add a custom message to the survey invitation..."),
}
),
)
patient_ids = forms.CharField(
widget=forms.HiddenInput(),
required=True
)
def __init__(self, user, *args, **kwargs):
patient_ids = forms.CharField(widget=forms.HiddenInput(), required=True)
def __init__(self, request=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
if user.hospital:
self.fields['survey_template'].queryset = SurveyTemplate.objects.filter(
hospital=user.hospital,
is_active=True
)
self.request = request
self.user = request.user if request else None
# Determine hospital context
hospital = None
if self.user and self.user.is_px_admin():
hospital = getattr(request, "tenant_hospital", None)
elif self.user and self.user.hospital:
hospital = self.user.hospital
# Filter survey templates by hospital
if hospital:
self.fields["survey_template"].queryset = SurveyTemplate.objects.filter(hospital=hospital, is_active=True)

View File

@ -4,6 +4,7 @@ HIS Patient Import Views
Handles importing patient data from HIS/MOH Statistics CSV
and sending surveys to imported patients.
"""
import csv
import io
import logging
@ -33,263 +34,295 @@ logger = logging.getLogger(__name__)
def his_patient_import(request):
"""
Import patients from HIS/MOH Statistics CSV.
CSV Format (MOH Statistics):
SNo, Facility Name, Visit Type, Admit Date, Discharge Date,
Patient Name, File Number, SSN, Mobile No, Payment Type,
SNo, Facility Name, Visit Type, Admit Date, Discharge Date,
Patient Name, File Number, SSN, Mobile No, Payment Type,
Gender, Full Age, Nationality, Date of Birth, Diagnosis
"""
user = request.user
# Check permission
if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to import patient data.")
return redirect('surveys:instance_list')
return redirect("surveys:instance_list")
# Session storage for imported patients
session_key = f'his_import_{user.id}'
if request.method == 'POST':
form = HISPatientImportForm(request.POST, request.FILES, user=user)
session_key = f"his_import_{user.id}"
if request.method == "POST":
form = HISPatientImportForm(request.POST, request.FILES, request=request)
if form.is_valid():
try:
hospital = form.cleaned_data['hospital']
csv_file = form.cleaned_data['csv_file']
skip_rows = form.cleaned_data['skip_header_rows']
hospital = form.cleaned_data["hospital"]
csv_file = form.cleaned_data["csv_file"]
skip_rows = form.cleaned_data["skip_header_rows"]
# Parse CSV
decoded_file = csv_file.read().decode('utf-8-sig')
decoded_file = csv_file.read().decode("utf-8-sig")
io_string = io.StringIO(decoded_file)
reader = csv.reader(io_string)
# Skip header/metadata rows
for _ in range(skip_rows):
next(reader, None)
# Read header row
header = next(reader, None)
if not header:
messages.error(request, "CSV file is empty or has no data rows.")
return render(request, 'surveys/his_patient_import.html', {'form': form})
return render(request, "surveys/his_patient_import.html", {"form": form})
# Find column indices
header = [h.strip().lower() for h in header]
col_map = {
'file_number': _find_column(header, ['file number', 'file_number', 'mrn', 'file no']),
'patient_name': _find_column(header, ['patient name', 'patient_name', 'name']),
'mobile_no': _find_column(header, ['mobile no', 'mobile_no', 'mobile', 'phone']),
'ssn': _find_column(header, ['ssn', 'national id', 'national_id', 'id number']),
'gender': _find_column(header, ['gender', 'sex']),
'visit_type': _find_column(header, ['visit type', 'visit_type', 'type']),
'admit_date': _find_column(header, ['admit date', 'admit_date', 'admission date']),
'discharge_date': _find_column(header, ['discharge date', 'discharge_date']),
'facility': _find_column(header, ['facility name', 'facility', 'hospital']),
'nationality': _find_column(header, ['nationality', 'country']),
'dob': _find_column(header, ['date of birth', 'dob', 'birth date']),
"file_number": _find_column(header, ["file number", "file_number", "mrn", "file no"]),
"patient_name": _find_column(header, ["patient name", "patient_name", "name"]),
"mobile_no": _find_column(header, ["mobile no", "mobile_no", "mobile", "phone"]),
"ssn": _find_column(header, ["ssn", "national id", "national_id", "id number"]),
"gender": _find_column(header, ["gender", "sex"]),
"visit_type": _find_column(header, ["visit type", "visit_type", "type"]),
"admit_date": _find_column(header, ["admit date", "admit_date", "admission date"]),
"discharge_date": _find_column(header, ["discharge date", "discharge_date"]),
"facility": _find_column(header, ["facility name", "facility", "hospital"]),
"nationality": _find_column(header, ["nationality", "country"]),
"dob": _find_column(header, ["date of birth", "dob", "birth date"]),
}
# Check required columns
if col_map['file_number'] is None:
if col_map["file_number"] is None:
messages.error(request, "Could not find 'File Number' column in CSV.")
return render(request, 'surveys/his_patient_import.html', {'form': form})
if col_map['patient_name'] is None:
return render(request, "surveys/his_patient_import.html", {"form": form})
if col_map["patient_name"] is None:
messages.error(request, "Could not find 'Patient Name' column in CSV.")
return render(request, 'surveys/his_patient_import.html', {'form': form})
return render(request, "surveys/his_patient_import.html", {"form": form})
# Process data rows
imported_patients = []
errors = []
row_num = skip_rows + 1
for row in reader:
row_num += 1
if not row or not any(row): # Skip empty rows
continue
try:
# Extract data
file_number = _get_cell(row, col_map['file_number'], '').strip()
patient_name = _get_cell(row, col_map['patient_name'], '').strip()
mobile_no = _get_cell(row, col_map['mobile_no'], '').strip()
ssn = _get_cell(row, col_map['ssn'], '').strip()
gender = _get_cell(row, col_map['gender'], '').strip().lower()
visit_type = _get_cell(row, col_map['visit_type'], '').strip()
admit_date = _get_cell(row, col_map['admit_date'], '').strip()
discharge_date = _get_cell(row, col_map['discharge_date'], '').strip()
facility = _get_cell(row, col_map['facility'], '').strip()
nationality = _get_cell(row, col_map['nationality'], '').strip()
dob = _get_cell(row, col_map['dob'], '').strip()
file_number = _get_cell(row, col_map["file_number"], "").strip()
patient_name = _get_cell(row, col_map["patient_name"], "").strip()
mobile_no = _get_cell(row, col_map["mobile_no"], "").strip()
ssn = _get_cell(row, col_map["ssn"], "").strip()
gender = _get_cell(row, col_map["gender"], "").strip().lower()
visit_type = _get_cell(row, col_map["visit_type"], "").strip()
admit_date = _get_cell(row, col_map["admit_date"], "").strip()
discharge_date = _get_cell(row, col_map["discharge_date"], "").strip()
facility = _get_cell(row, col_map["facility"], "").strip()
nationality = _get_cell(row, col_map["nationality"], "").strip()
dob = _get_cell(row, col_map["dob"], "").strip()
# Skip if missing required fields
if not file_number or not patient_name:
continue
# Clean phone number
if mobile_no:
mobile_no = mobile_no.replace(' ', '').replace('-', '')
if not mobile_no.startswith('+'):
mobile_no = mobile_no.replace(" ", "").replace("-", "")
if not mobile_no.startswith("+"):
# Assume Saudi number if starts with 0
if mobile_no.startswith('05'):
mobile_no = '+966' + mobile_no[1:]
elif mobile_no.startswith('5'):
mobile_no = '+966' + mobile_no
if mobile_no.startswith("05"):
mobile_no = "+966" + mobile_no[1:]
elif mobile_no.startswith("5"):
mobile_no = "+966" + mobile_no
# Parse name (First Middle Last format)
name_parts = patient_name.split()
first_name = name_parts[0] if name_parts else ''
last_name = name_parts[-1] if len(name_parts) > 1 else ''
first_name = name_parts[0] if name_parts else ""
last_name = name_parts[-1] if len(name_parts) > 1 else ""
# Parse dates
parsed_admit = _parse_date(admit_date)
parsed_discharge = _parse_date(discharge_date)
parsed_dob = _parse_date(dob)
# Normalize gender
if gender in ['male', 'm']:
gender = 'male'
elif gender in ['female', 'f']:
gender = 'female'
if gender in ["male", "m"]:
gender = "male"
elif gender in ["female", "f"]:
gender = "female"
else:
gender = ''
imported_patients.append({
'row_num': row_num,
'file_number': file_number,
'patient_name': patient_name,
'first_name': first_name,
'last_name': last_name,
'mobile_no': mobile_no,
'ssn': ssn,
'gender': gender,
'visit_type': visit_type,
'admit_date': parsed_admit.isoformat() if parsed_admit else None,
'discharge_date': parsed_discharge.isoformat() if parsed_discharge else None,
'facility': facility,
'nationality': nationality,
'dob': parsed_dob.isoformat() if parsed_dob else None,
})
gender = ""
imported_patients.append(
{
"row_num": row_num,
"file_number": file_number,
"patient_name": patient_name,
"first_name": first_name,
"last_name": last_name,
"mobile_no": mobile_no,
"ssn": ssn,
"gender": gender,
"visit_type": visit_type,
"admit_date": parsed_admit.isoformat() if parsed_admit else None,
"discharge_date": parsed_discharge.isoformat() if parsed_discharge else None,
"facility": facility,
"nationality": nationality,
"dob": parsed_dob.isoformat() if parsed_dob else None,
}
)
except Exception as e:
errors.append(f"Row {row_num}: {str(e)}")
logger.error(f"Error processing row {row_num}: {e}")
# Store in session for review step
request.session[session_key] = {
'hospital_id': str(hospital.id),
'patients': imported_patients,
'errors': errors,
'total_count': len(imported_patients)
"hospital_id": str(hospital.id),
"patients": imported_patients,
"errors": errors,
"total_count": len(imported_patients),
}
# Log audit
AuditService.log_event(
event_type='his_patient_import',
event_type="his_patient_import",
description=f"Imported {len(imported_patients)} patients from HIS CSV by {user.get_full_name()}",
user=user,
metadata={
'hospital': hospital.name,
'total_count': len(imported_patients),
'error_count': len(errors)
}
"hospital": hospital.name,
"total_count": len(imported_patients),
"error_count": len(errors),
},
)
if imported_patients:
messages.success(
request,
f"Successfully parsed {len(imported_patients)} patient records. Please review before creating."
f"Successfully parsed {len(imported_patients)} patient records. Please review before creating.",
)
return redirect('surveys:his_patient_review')
return redirect("surveys:his_patient_review")
else:
messages.error(request, "No valid patient records found in CSV.")
except Exception as e:
logger.error(f"Error processing HIS CSV: {str(e)}", exc_info=True)
messages.error(request, f"Error processing CSV: {str(e)}")
else:
form = HISPatientImportForm(user=user)
form = HISPatientImportForm(request=request)
context = {
'form': form,
"form": form,
}
return render(request, 'surveys/his_patient_import.html', context)
return render(request, "surveys/his_patient_import.html", context)
@login_required
def his_patient_review(request):
"""
Review imported patients before creating records and sending surveys.
Shows summary statistics instead of full patient list for performance.
"""
user = request.user
session_key = f'his_import_{user.id}'
session_key = f"his_import_{user.id}"
import_data = request.session.get(session_key)
if not import_data:
messages.error(request, "No import data found. Please upload CSV first.")
return redirect('surveys:his_patient_import')
hospital = get_object_or_404(Hospital, id=import_data['hospital_id'])
patients = import_data['patients']
errors = import_data.get('errors', [])
# Check for existing patients
return redirect("surveys:his_patient_import")
hospital = get_object_or_404(Hospital, id=import_data["hospital_id"])
patients = import_data["patients"]
errors = import_data.get("errors", [])
# Calculate summary statistics
total_count = len(patients)
new_count = 0
existing_count = 0
visit_types = {}
has_mobile = 0
missing_mobile = 0
# Check for existing patients and calculate stats
for p in patients:
existing = Patient.objects.filter(mrn=p['file_number']).first()
p['exists'] = existing is not None
if request.method == 'POST':
action = request.POST.get('action')
selected_ids = request.POST.getlist('selected_patients')
existing = Patient.objects.filter(mrn=p["file_number"]).first()
p["exists"] = existing is not None
if action == 'create':
# Create/update patient records
if p["exists"]:
existing_count += 1
else:
new_count += 1
# Count visit types
visit_type = p.get("visit_type", "Unknown") or "Unknown"
visit_types[visit_type] = visit_types.get(visit_type, 0) + 1
# Count mobile availability
if p.get("mobile_no"):
has_mobile += 1
else:
missing_mobile += 1
if request.method == "POST":
action = request.POST.get("action")
if action == "create":
# Create/update all patient records (no individual selection needed)
created_count = 0
updated_count = 0
with transaction.atomic():
for p in patients:
if p['file_number'] in selected_ids:
patient, created = Patient.objects.update_or_create(
mrn=p['file_number'],
defaults={
'first_name': p['first_name'],
'last_name': p['last_name'],
'phone': p['mobile_no'],
'national_id': p['ssn'],
'gender': p['gender'] if p['gender'] else '',
'primary_hospital': hospital,
}
)
if created:
created_count += 1
else:
updated_count += 1
# Store only the patient ID to avoid serialization issues
p['patient_id'] = str(patient.id)
patient, created = Patient.objects.update_or_create(
mrn=p["file_number"],
defaults={
"first_name": p["first_name"],
"last_name": p["last_name"],
"phone": p["mobile_no"],
"national_id": p["ssn"],
"gender": p["gender"] if p["gender"] else "",
"primary_hospital": hospital,
},
)
if created:
created_count += 1
else:
updated_count += 1
# Store only the patient ID to avoid serialization issues
p["patient_id"] = str(patient.id)
# Update session with created patients
request.session[session_key]['patients'] = patients
request.session[session_key]["patients"] = patients
request.session.modified = True
messages.success(
request,
f"Created {created_count} new patients, updated {updated_count} existing patients."
request, f"Created {created_count} new patients, updated {updated_count} existing patients."
)
return redirect('surveys:his_patient_survey_send')
elif action == 'cancel':
return redirect("surveys:his_patient_survey_send")
elif action == "cancel":
del request.session[session_key]
messages.info(request, "Import cancelled.")
return redirect('surveys:his_patient_import')
return redirect("surveys:his_patient_import")
# Get a small sample for preview (first 5 records)
sample_patients = patients[:5]
context = {
'hospital': hospital,
'patients': patients,
'errors': errors,
'total_count': len(patients),
"hospital": hospital,
"errors": errors,
"total_count": total_count,
"new_count": new_count,
"existing_count": existing_count,
"visit_types": sorted(visit_types.items(), key=lambda x: x[1], reverse=True),
"has_mobile": has_mobile,
"missing_mobile": missing_mobile,
"sample_patients": sample_patients,
"sample_count": len(sample_patients),
}
return render(request, 'surveys/his_patient_review.html', context)
return render(request, "surveys/his_patient_review.html", context)
@login_required
@ -298,47 +331,67 @@ def his_patient_survey_send(request):
Send surveys to imported patients - Queues a background task.
"""
user = request.user
session_key = f'his_import_{user.id}'
session_key = f"his_import_{user.id}"
import_data = request.session.get(session_key)
if not import_data:
messages.error(request, "No import data found. Please upload CSV first.")
return redirect('surveys:his_patient_import')
hospital = get_object_or_404(Hospital, id=import_data['hospital_id'])
all_patients = import_data['patients']
return redirect("surveys:his_patient_import")
hospital = get_object_or_404(Hospital, id=import_data["hospital_id"])
all_patients = import_data["patients"]
# Filter only patients with records in database
# Get patient IDs from session data
patient_ids = [p["patient_id"] for p in all_patients if "patient_id" in p]
# Fetch all patients in one query for efficiency
patient_objs = {str(p.id): p for p in Patient.objects.filter(id__in=patient_ids)}
# Build render data with patient objects (don't modify session data!)
patients = []
for p in all_patients:
if 'patient_id' in p:
patients.append(p)
if "patient_id" in p and p["patient_id"] in patient_objs:
# Create a copy and add the patient object for template rendering
patient_copy = dict(p)
patient_copy["patient_obj"] = patient_objs[p["patient_id"]]
patients.append(patient_copy)
if not patients:
messages.error(request, "No patients have been created yet. Please create patients first.")
return redirect('surveys:his_patient_review')
if request.method == 'POST':
form = HISSurveySendForm(request.POST, user=user)
return redirect("surveys:his_patient_review")
if request.method == "POST":
form = HISSurveySendForm(data=request.POST, request=request)
if form.is_valid():
survey_template = form.cleaned_data['survey_template']
delivery_channel = form.cleaned_data['delivery_channel']
custom_message = form.cleaned_data.get('custom_message', '')
selected_ids = request.POST.getlist('selected_patients')
# Filter selected patients
selected_patients = [p for p in patients if p['file_number'] in selected_ids]
if not selected_patients:
survey_template = form.cleaned_data["survey_template"]
delivery_channel = form.cleaned_data["delivery_channel"]
custom_message = form.cleaned_data.get("custom_message", "")
selected_ids = request.POST.getlist("selected_patients")
# Filter selected patients from render data
selected_patients_render = [p for p in patients if p["file_number"] in selected_ids]
if not selected_patients_render:
messages.error(request, "No patients selected.")
return render(request, 'surveys/his_patient_survey_send.html', {
'form': form,
'hospital': hospital,
'patients': patients,
'total_count': len(patients),
})
return render(
request,
"surveys/his_patient_survey_send.html",
{
"form": form,
"hospital": hospital,
"patients": patients,
"total_count": len(patients),
},
)
# Prepare patient data for job (exclude patient_obj which is not serializable)
selected_patients = [
{k: v for k, v in p.items() if k != "patient_obj"}
for p in selected_patients_render
]
# Create bulk job
job = BulkSurveyJob.objects.create(
name=f"HIS Import - {hospital.name} - {timezone.now().strftime('%Y-%m-%d %H:%M')}",
@ -350,48 +403,49 @@ def his_patient_survey_send(request):
total_patients=len(selected_patients),
delivery_channel=delivery_channel,
custom_message=custom_message,
patient_data=selected_patients
patient_data=selected_patients,
)
# Queue the background task
send_bulk_surveys.delay(str(job.id))
# Log audit
AuditService.log_event(
event_type='his_survey_queued',
event_type="his_survey_queued",
description=f"Queued bulk survey job for {len(selected_patients)} patients",
user=user,
metadata={
'job_id': str(job.id),
'hospital': hospital.name,
'survey_template': survey_template.name,
'patient_count': len(selected_patients)
}
"job_id": str(job.id),
"hospital": hospital.name,
"survey_template": survey_template.name,
"patient_count": len(selected_patients),
},
)
# Clear session
del request.session[session_key]
messages.success(
request,
f"Survey sending job queued for {len(selected_patients)} patients. "
f"You can check the status in Survey Jobs."
f"You can check the status in Survey Jobs.",
)
return redirect('surveys:bulk_job_status', job_id=job.id)
return redirect("surveys:bulk_job_status", job_id=job.id)
else:
form = HISSurveySendForm(user=user)
form = HISSurveySendForm(request=request)
context = {
'form': form,
'hospital': hospital,
'patients': patients,
'total_count': len(patients),
"form": form,
"hospital": hospital,
"patients": patients,
"total_count": len(patients),
}
return render(request, 'surveys/his_patient_survey_send.html', context)
return render(request, "surveys/his_patient_survey_send.html", context)
# Helper functions
def _find_column(header, possible_names):
"""Find column index by possible names"""
for name in possible_names:
@ -401,7 +455,7 @@ def _find_column(header, possible_names):
return None
def _get_cell(row, index, default=''):
def _get_cell(row, index, default=""):
"""Safely get cell value"""
if index is None or index >= len(row):
return default
@ -412,24 +466,23 @@ def _parse_date(date_str):
"""Parse date from various formats"""
if not date_str:
return None
formats = [
'%d-%b-%Y %H:%M:%S',
'%d-%b-%Y',
'%d/%m/%Y %H:%M:%S',
'%d/%m/%Y',
'%Y-%m-%d %H:%M:%S',
'%Y-%m-%d',
"%d-%b-%Y %H:%M:%S",
"%d-%b-%Y",
"%d/%m/%Y %H:%M:%S",
"%d/%m/%Y",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d",
]
for fmt in formats:
try:
return datetime.strptime(date_str.strip(), fmt).date()
except ValueError:
continue
return None
return None
@login_required
@ -439,19 +492,19 @@ def bulk_job_status(request, job_id):
"""
user = request.user
job = get_object_or_404(BulkSurveyJob, id=job_id)
# Check permission
if not user.is_px_admin() and job.created_by != user and job.hospital != user.hospital:
messages.error(request, "You don't have permission to view this job.")
return redirect('surveys:instance_list')
return redirect("surveys:instance_list")
context = {
'job': job,
'progress': job.progress_percentage,
'is_complete': job.is_complete,
'results': job.results,
"job": job,
"progress": job.progress_percentage,
"is_complete": job.is_complete,
"results": job.results,
}
return render(request, 'surveys/bulk_job_status.html', context)
return render(request, "surveys/bulk_job_status.html", context)
@login_required
@ -460,7 +513,7 @@ def bulk_job_list(request):
List all bulk survey jobs for the user.
"""
user = request.user
# Filter jobs
if user.is_px_admin():
jobs = BulkSurveyJob.objects.all()
@ -468,10 +521,10 @@ def bulk_job_list(request):
jobs = BulkSurveyJob.objects.filter(hospital=user.hospital)
else:
jobs = BulkSurveyJob.objects.filter(created_by=user)
jobs = jobs.order_by('-created_at')[:50] # Last 50 jobs
jobs = jobs.order_by("-created_at")[:50] # Last 50 jobs
context = {
'jobs': jobs,
"jobs": jobs,
}
return render(request, 'surveys/bulk_job_list.html', context)
return render(request, "surveys/bulk_job_list.html", context)

View File

@ -237,6 +237,12 @@ class SurveyInstance(UUIDModel, TimeStampedModel, TenantModel):
# Timestamps
sent_at = models.DateTimeField(null=True, blank=True, db_index=True)
scheduled_send_at = models.DateTimeField(
null=True,
blank=True,
db_index=True,
help_text="When this survey should be sent (for delayed sending)"
)
opened_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)

View File

@ -7,6 +7,7 @@ This module contains tasks for:
- Survey-related background operations
- Bulk survey sending
"""
import logging
from celery import shared_task
@ -34,47 +35,41 @@ def analyze_survey_comment(survey_instance_id):
from apps.core.ai_service import AIService, AIServiceError
try:
survey = SurveyInstance.objects.select_related('patient', 'hospital').get(id=survey_instance_id)
survey = SurveyInstance.objects.select_related("patient", "hospital").get(id=survey_instance_id)
# Check if comment exists
if not survey.comment or not survey.comment.strip():
logger.info(f"No comment to analyze for survey {survey_instance_id}")
return {
'status': 'skipped',
'reason': 'no_comment'
}
return {"status": "skipped", "reason": "no_comment"}
# Check if already analyzed
if survey.comment_analyzed:
logger.info(f"Comment already analyzed for survey {survey_instance_id}")
return {
'status': 'skipped',
'reason': 'already_analyzed'
}
return {"status": "skipped", "reason": "already_analyzed"}
logger.info(f"Starting AI analysis for survey comment {survey_instance_id}")
# Analyze sentiment
try:
sentiment_analysis = AIService.classify_sentiment(survey.comment)
sentiment = sentiment_analysis.get('sentiment', 'neutral')
sentiment_score = sentiment_analysis.get('score', 0.0)
sentiment_confidence = sentiment_analysis.get('confidence', 0.0)
sentiment = sentiment_analysis.get("sentiment", "neutral")
sentiment_score = sentiment_analysis.get("score", 0.0)
sentiment_confidence = sentiment_analysis.get("confidence", 0.0)
except AIServiceError as e:
logger.error(f"Sentiment analysis failed for survey {survey_instance_id}: {str(e)}")
sentiment = 'neutral'
sentiment = "neutral"
sentiment_score = 0.0
sentiment_confidence = 0.0
# Analyze emotion
try:
emotion_analysis = AIService.analyze_emotion(survey.comment)
emotion = emotion_analysis.get('emotion', 'neutral')
emotion_intensity = emotion_analysis.get('intensity', 0.0)
emotion_confidence = emotion_analysis.get('confidence', 0.0)
emotion = emotion_analysis.get("emotion", "neutral")
emotion_intensity = emotion_analysis.get("intensity", 0.0)
emotion_confidence = emotion_analysis.get("confidence", 0.0)
except AIServiceError as e:
logger.error(f"Emotion analysis failed for survey {survey_instance_id}: {str(e)}")
emotion = 'neutral'
emotion = "neutral"
emotion_intensity = 0.0
emotion_confidence = 0.0
@ -101,69 +96,63 @@ def analyze_survey_comment(survey_instance_id):
- topics_ar: List of topics in Arabic
- feedback_type: "positive", "negative", or "neutral"
"""
summary_result = AIService.chat_completion(
messages=[
{
"role": "system",
"content": "You are a helpful assistant analyzing patient survey comments. Always respond with valid JSON."
"content": "You are a helpful assistant analyzing patient survey comments. Always respond with valid JSON.",
},
{
"role": "user",
"content": summary_prompt
}
{"role": "user", "content": summary_prompt},
],
response_format={"type": "json_object"}
response_format={"type": "json_object"},
)
# Parse the JSON response
import json
summary_data = json.loads(summary_result)
summary_en = summary_data.get('summary_en', '')
summary_ar = summary_data.get('summary_ar', '')
topics_en = summary_data.get('topics_en', [])
topics_ar = summary_data.get('topics_ar', [])
feedback_type = summary_data.get('feedback_type', 'neutral')
summary_en = summary_data.get("summary_en", "")
summary_ar = summary_data.get("summary_ar", "")
topics_en = summary_data.get("topics_en", [])
topics_ar = summary_data.get("topics_ar", [])
feedback_type = summary_data.get("feedback_type", "neutral")
except Exception as e:
logger.error(f"Summary generation failed for survey {survey_instance_id}: {str(e)}")
summary_en = survey.comment[:200] # Fallback to comment text
summary_ar = ''
summary_ar = ""
topics_en = []
topics_ar = []
feedback_type = sentiment # Fallback to sentiment
# Update survey with analysis results
survey.comment_analysis = {
'sentiment': sentiment,
'sentiment_score': sentiment_score,
'sentiment_confidence': sentiment_confidence,
'emotion': emotion,
'emotion_intensity': emotion_intensity,
'emotion_confidence': emotion_confidence,
'summary_en': summary_en,
'summary_ar': summary_ar,
'topics_en': topics_en,
'topics_ar': topics_ar,
'feedback_type': feedback_type,
'analyzed_at': timezone.now().isoformat()
"sentiment": sentiment,
"sentiment_score": sentiment_score,
"sentiment_confidence": sentiment_confidence,
"emotion": emotion,
"emotion_intensity": emotion_intensity,
"emotion_confidence": emotion_confidence,
"summary_en": summary_en,
"summary_ar": summary_ar,
"topics_en": topics_en,
"topics_ar": topics_ar,
"feedback_type": feedback_type,
"analyzed_at": timezone.now().isoformat(),
}
survey.comment_analyzed = True
survey.save(update_fields=['comment_analysis', 'comment_analyzed'])
survey.save(update_fields=["comment_analysis", "comment_analyzed"])
# Log audit
from apps.core.services import create_audit_log
create_audit_log(
event_type='survey_comment_analyzed',
event_type="survey_comment_analyzed",
description=f"Survey comment analyzed with AI: sentiment={sentiment}, emotion={emotion}",
content_object=survey,
metadata={
'sentiment': sentiment,
'emotion': emotion,
'feedback_type': feedback_type,
'topics': topics_en
}
metadata={"sentiment": sentiment, "emotion": emotion, "feedback_type": feedback_type, "topics": topics_en},
)
logger.info(
@ -174,25 +163,25 @@ def analyze_survey_comment(survey_instance_id):
)
return {
'status': 'success',
'survey_id': str(survey.id),
'sentiment': sentiment,
'sentiment_score': sentiment_score,
'sentiment_confidence': sentiment_confidence,
'emotion': emotion,
'emotion_intensity': emotion_intensity,
'emotion_confidence': emotion_confidence,
'summary_en': summary_en,
'summary_ar': summary_ar,
'topics_en': topics_en,
'topics_ar': topics_ar,
'feedback_type': feedback_type
"status": "success",
"survey_id": str(survey.id),
"sentiment": sentiment,
"sentiment_score": sentiment_score,
"sentiment_confidence": sentiment_confidence,
"emotion": emotion,
"emotion_intensity": emotion_intensity,
"emotion_confidence": emotion_confidence,
"summary_en": summary_en,
"summary_ar": summary_ar,
"topics_en": topics_en,
"topics_ar": topics_ar,
"feedback_type": feedback_type,
}
except SurveyInstance.DoesNotExist:
error_msg = f"SurveyInstance {survey_instance_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
return {"status": "error", "reason": error_msg}
@shared_task
@ -213,97 +202,91 @@ def send_satisfaction_feedback(survey_instance_id, user_id):
from apps.surveys.models import SurveyInstance, SurveyTemplate
try:
survey = SurveyInstance.objects.select_related(
'patient', 'hospital', 'survey_template'
).get(id=survey_instance_id)
survey = SurveyInstance.objects.select_related("patient", "hospital", "survey_template").get(
id=survey_instance_id
)
# Get feedback survey template
try:
feedback_template = SurveyTemplate.objects.get(
hospital=survey.hospital,
survey_type='complaint_resolution',
is_active=True
hospital=survey.hospital, survey_type="complaint_resolution", is_active=True
)
except SurveyTemplate.DoesNotExist:
logger.warning(
f"No feedback survey template found for hospital {survey.hospital.name}"
)
return {'status': 'skipped', 'reason': 'no_template'}
logger.warning(f"No feedback survey template found for hospital {survey.hospital.name}")
return {"status": "skipped", "reason": "no_template"}
# Check if already sent
if survey.satisfaction_feedback_sent:
logger.info(f"Satisfaction feedback already sent for survey {survey_instance_id}")
return {'status': 'skipped', 'reason': 'already_sent'}
return {"status": "skipped", "reason": "already_sent"}
# Create feedback survey instance
feedback_survey = SurveyInstance.objects.create(
survey_template=feedback_template,
patient=survey.patient,
encounter_id=survey.encounter_id,
delivery_channel='sms',
delivery_channel="sms",
recipient_phone=survey.patient.phone,
recipient_email=survey.patient.email,
metadata={
'original_survey_id': str(survey.id),
'original_survey_title': survey.survey_template.name,
'original_score': float(survey.total_score) if survey.total_score else None,
'feedback_type': 'satisfaction'
}
"original_survey_id": str(survey.id),
"original_survey_title": survey.survey_template.name,
"original_score": float(survey.total_score) if survey.total_score else None,
"feedback_type": "satisfaction",
},
)
# Mark original survey as having feedback sent
survey.satisfaction_feedback_sent = True
survey.satisfaction_feedback_sent_at = timezone.now()
survey.satisfaction_feedback = feedback_survey
survey.save(update_fields=[
'satisfaction_feedback_sent',
'satisfaction_feedback_sent_at',
'satisfaction_feedback'
])
survey.save(
update_fields=["satisfaction_feedback_sent", "satisfaction_feedback_sent_at", "satisfaction_feedback"]
)
# Send survey invitation
from apps.notifications.services import NotificationService
notification_log = NotificationService.send_survey_invitation(
survey_instance=feedback_survey,
language='en' # TODO: Get from patient preference
language="en", # TODO: Get from patient preference
)
# Update feedback survey status
feedback_survey.status = 'sent'
feedback_survey.status = "sent"
feedback_survey.sent_at = timezone.now()
feedback_survey.save(update_fields=['status', 'sent_at'])
feedback_survey.save(update_fields=["status", "sent_at"])
# Log audit
from apps.core.services import create_audit_log
create_audit_log(
event_type='satisfaction_feedback_sent',
event_type="satisfaction_feedback_sent",
description=f"Satisfaction feedback survey sent for survey: {survey.survey_template.name}",
content_object=feedback_survey,
metadata={
'original_survey_id': str(survey.id),
'feedback_template': feedback_template.name,
'sent_by_user_id': user_id
}
"original_survey_id": str(survey.id),
"feedback_template": feedback_template.name,
"sent_by_user_id": user_id,
},
)
logger.info(
f"Satisfaction feedback survey sent for survey {survey_instance_id}"
)
logger.info(f"Satisfaction feedback survey sent for survey {survey_instance_id}")
return {
'status': 'sent',
'feedback_survey_id': str(feedback_survey.id),
'notification_log_id': str(notification_log.id)
"status": "sent",
"feedback_survey_id": str(feedback_survey.id),
"notification_log_id": str(notification_log.id),
}
except SurveyInstance.DoesNotExist:
error_msg = f"SurveyInstance {survey_instance_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
return {"status": "error", "reason": error_msg}
except Exception as e:
error_msg = f"Error sending satisfaction feedback: {str(e)}"
logger.error(error_msg, exc_info=True)
return {'status': 'error', 'reason': error_msg}
return {"status": "error", "reason": error_msg}
@shared_task
@ -326,21 +309,19 @@ def create_action_from_negative_survey(survey_instance_id):
from django.contrib.contenttypes.models import ContentType
try:
survey = SurveyInstance.objects.select_related(
'survey_template',
'patient',
'hospital'
).get(id=survey_instance_id)
survey = SurveyInstance.objects.select_related("survey_template", "patient", "hospital").get(
id=survey_instance_id
)
# Verify survey is negative
if not survey.is_negative:
logger.info(f"Survey {survey_instance_id} is not negative, skipping action creation")
return {'status': 'skipped', 'reason': 'not_negative'}
return {"status": "skipped", "reason": "not_negative"}
# Check if action already created
if survey.metadata.get('px_action_created'):
if survey.metadata.get("px_action_created"):
logger.info(f"PX Action already created for survey {survey_instance_id}")
return {'status': 'skipped', 'reason': 'already_created'}
return {"status": "skipped", "reason": "already_created"}
# Calculate score for priority/severity determination
score = float(survey.total_score) if survey.total_score else 0.0
@ -360,19 +341,19 @@ def create_action_from_negative_survey(survey_instance_id):
priority = PriorityChoices.LOW
# Determine category based on survey template or journey stage
category = 'service_quality' # Default
if survey.survey_template.survey_type == 'post_discharge':
category = 'clinical_quality'
elif survey.survey_template.survey_type == 'inpatient_satisfaction':
category = 'service_quality'
category = "service_quality" # Default
if survey.survey_template.survey_type == "post_discharge":
category = "clinical_quality"
elif survey.survey_template.survey_type == "inpatient_satisfaction":
category = "service_quality"
elif survey.journey_instance and survey.journey_instance.stage:
stage = survey.journey_instance.stage.lower()
if 'admission' in stage or 'registration' in stage:
category = 'process_improvement'
elif 'treatment' in stage or 'procedure' in stage:
category = 'clinical_quality'
elif 'discharge' in stage or 'billing' in stage:
category = 'process_improvement'
if "admission" in stage or "registration" in stage:
category = "process_improvement"
elif "treatment" in stage or "procedure" in stage:
category = "clinical_quality"
elif "discharge" in stage or "billing" in stage:
category = "process_improvement"
# Build description
description_parts = [
@ -384,9 +365,7 @@ def create_action_from_negative_survey(survey_instance_id):
description_parts.append(f"Patient Comment: {survey.comment}")
if survey.journey_instance:
description_parts.append(
f"Journey Stage: {survey.journey_instance.stage}"
)
description_parts.append(f"Journey Stage: {survey.journey_instance.stage}")
if survey.encounter_id:
description_parts.append(f"Encounter ID: {survey.encounter_id}")
@ -397,7 +376,7 @@ def create_action_from_negative_survey(survey_instance_id):
survey_ct = ContentType.objects.get_for_model(SurveyInstance)
action = PXAction.objects.create(
source_type='survey',
source_type="survey",
content_type=survey_ct,
object_id=survey.id,
title=f"Negative Survey: {survey.survey_template.name} (Score: {score:.1f})",
@ -407,54 +386,55 @@ def create_action_from_negative_survey(survey_instance_id):
category=category,
priority=priority,
severity=severity,
status='open',
status="open",
metadata={
'source_survey_id': str(survey.id),
'source_survey_template': survey.survey_template.name,
'survey_score': score,
'is_negative': True,
'has_comment': bool(survey.comment),
'encounter_id': survey.encounter_id,
'auto_created': True
}
"source_survey_id": str(survey.id),
"source_survey_template": survey.survey_template.name,
"survey_score": score,
"is_negative": True,
"has_comment": bool(survey.comment),
"encounter_id": survey.encounter_id,
"auto_created": True,
},
)
# Create action log entry
PXActionLog.objects.create(
action=action,
log_type='note',
log_type="note",
message=(
f"Action automatically created from negative survey. "
f"Score: {score:.1f}, Template: {survey.survey_template.name}"
),
metadata={
'survey_id': str(survey.id),
'survey_score': score,
'auto_created': True,
'severity': severity,
'priority': priority
}
"survey_id": str(survey.id),
"survey_score": score,
"auto_created": True,
"severity": severity,
"priority": priority,
},
)
# Update survey metadata to track action creation
if not survey.metadata:
survey.metadata = {}
survey.metadata['px_action_created'] = True
survey.metadata['px_action_id'] = str(action.id)
survey.save(update_fields=['metadata'])
survey.metadata["px_action_created"] = True
survey.metadata["px_action_id"] = str(action.id)
survey.save(update_fields=["metadata"])
# Log audit
from apps.core.services import create_audit_log
create_audit_log(
event_type='px_action_created',
event_type="px_action_created",
description=f"PX Action created from negative survey: {survey.survey_template.name}",
content_object=action,
metadata={
'survey_id': str(survey.id),
'survey_template': survey.survey_template.name,
'survey_score': score,
'trigger': 'negative_survey'
}
"survey_id": str(survey.id),
"survey_template": survey.survey_template.name,
"survey_score": score,
"trigger": "negative_survey",
},
)
logger.info(
@ -463,36 +443,34 @@ def create_action_from_negative_survey(survey_instance_id):
)
return {
'status': 'action_created',
'action_id': str(action.id),
'survey_score': score,
'severity': severity,
'priority': priority
"status": "action_created",
"action_id": str(action.id),
"survey_score": score,
"severity": severity,
"priority": priority,
}
except SurveyInstance.DoesNotExist:
error_msg = f"SurveyInstance {survey_instance_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
return {"status": "error", "reason": error_msg}
except Exception as e:
error_msg = f"Error creating action from negative survey: {str(e)}"
logger.error(error_msg, exc_info=True)
return {'status': 'error', 'reason': error_msg}
return {"status": "error", "reason": error_msg}
@shared_task(bind=True, max_retries=3)
def send_bulk_surveys(self, job_id):
"""
Send surveys to multiple patients in the background.
This task processes a BulkSurveyJob and sends surveys to all patients.
It updates the job progress as it goes and handles errors gracefully.
Args:
job_id: UUID of the BulkSurveyJob
Returns:
dict: Result with counts and status
"""
@ -500,69 +478,68 @@ def send_bulk_surveys(self, job_id):
from apps.organizations.models import Patient, Hospital
from apps.surveys.services import SurveyDeliveryService
from apps.core.services import AuditService
try:
# Get the job
job = BulkSurveyJob.objects.get(id=job_id)
# Update status to processing
job.status = BulkSurveyJob.JobStatus.PROCESSING
job.started_at = timezone.now()
job.save(update_fields=['status', 'started_at'])
job.save(update_fields=["status", "started_at"])
logger.info(f"Starting bulk survey job {job_id} for {job.total_patients} patients")
# Get settings
survey_template = job.survey_template
hospital = job.hospital
delivery_channel = job.delivery_channel
custom_message = job.custom_message
patient_data_list = job.patient_data
# Results tracking
success_count = 0
failed_count = 0
failed_patients = []
created_survey_ids = []
# Process each patient
for idx, patient_info in enumerate(patient_data_list):
try:
# Update progress periodically
if idx % 5 == 0:
job.processed_count = idx
job.save(update_fields=['processed_count'])
job.save(update_fields=["processed_count"])
# Get patient
patient_id = patient_info.get('patient_id')
file_number = patient_info.get('file_number', 'unknown')
patient_id = patient_info.get("patient_id")
file_number = patient_info.get("file_number", "unknown")
try:
patient = Patient.objects.get(id=patient_id)
except Patient.DoesNotExist:
failed_count += 1
failed_patients.append({
'file_number': file_number,
'reason': 'Patient not found'
})
failed_patients.append({"file_number": file_number, "reason": "Patient not found"})
continue
# Determine delivery channels
channels = []
if delivery_channel in ['sms', 'both'] and patient.phone:
channels.append('sms')
if delivery_channel in ['email', 'both'] and patient.email:
channels.append('email')
if delivery_channel in ["sms", "both"] and patient.phone:
channels.append("sms")
if delivery_channel in ["email", "both"] and patient.email:
channels.append("email")
if not channels:
failed_count += 1
failed_patients.append({
'file_number': file_number,
'patient_name': patient.get_full_name(),
'reason': 'No contact information'
})
failed_patients.append(
{
"file_number": file_number,
"patient_name": patient.get_full_name(),
"reason": "No contact information",
}
)
continue
# Create and send survey for each channel
for channel in channels:
survey_instance = SurveyInstance.objects.create(
@ -571,52 +548,51 @@ def send_bulk_surveys(self, job_id):
hospital=hospital,
delivery_channel=channel,
status=SurveyStatus.SENT,
recipient_phone=patient.phone if channel == 'sms' else '',
recipient_email=patient.email if channel == 'email' else '',
recipient_phone=patient.phone if channel == "sms" else "",
recipient_email=patient.email if channel == "email" else "",
metadata={
'sent_manually': True,
'sent_by': str(job.created_by.id) if job.created_by else None,
'custom_message': custom_message,
'recipient_type': 'bulk_import',
'his_file_number': file_number,
'bulk_job_id': str(job.id),
}
"sent_manually": True,
"sent_by": str(job.created_by.id) if job.created_by else None,
"custom_message": custom_message,
"recipient_type": "bulk_import",
"his_file_number": file_number,
"bulk_job_id": str(job.id),
},
)
# Send survey
success = SurveyDeliveryService.deliver_survey(survey_instance)
if success:
success_count += 1
created_survey_ids.append(str(survey_instance.id))
else:
failed_count += 1
failed_patients.append({
'file_number': file_number,
'patient_name': patient.get_full_name(),
'reason': 'Delivery failed'
})
failed_patients.append(
{
"file_number": file_number,
"patient_name": patient.get_full_name(),
"reason": "Delivery failed",
}
)
survey_instance.delete()
except Exception as e:
failed_count += 1
failed_patients.append({
'file_number': patient_info.get('file_number', 'unknown'),
'reason': str(e)
})
failed_patients.append({"file_number": patient_info.get("file_number", "unknown"), "reason": str(e)})
logger.error(f"Error processing patient in bulk job {job_id}: {e}")
# Update job with final results
job.processed_count = len(patient_data_list)
job.success_count = success_count
job.failed_count = failed_count
job.results = {
'success_count': success_count,
'failed_count': failed_count,
'failed_patients': failed_patients[:50], # Limit stored failures
'survey_ids': created_survey_ids[:100], # Limit stored IDs
"success_count": success_count,
"failed_count": failed_count,
"failed_patients": failed_patients[:50], # Limit stored failures
"survey_ids": created_survey_ids[:100], # Limit stored IDs
}
# Determine final status
if failed_count == 0:
job.status = BulkSurveyJob.JobStatus.COMPLETED
@ -625,41 +601,41 @@ def send_bulk_surveys(self, job_id):
job.error_message = "All surveys failed to send"
else:
job.status = BulkSurveyJob.JobStatus.PARTIAL
job.completed_at = timezone.now()
job.save()
# Log audit
AuditService.log_event(
event_type='bulk_survey_completed',
event_type="bulk_survey_completed",
description=f"Bulk survey job completed: {success_count} sent, {failed_count} failed",
user=job.created_by,
metadata={
'job_id': str(job.id),
'hospital': hospital.name,
'survey_template': survey_template.name,
'success_count': success_count,
'failed_count': failed_count
}
"job_id": str(job.id),
"hospital": hospital.name,
"survey_template": survey_template.name,
"success_count": success_count,
"failed_count": failed_count,
},
)
logger.info(f"Bulk survey job {job_id} completed: {success_count} success, {failed_count} failed")
return {
'status': 'success',
'job_id': str(job.id),
'success_count': success_count,
'failed_count': failed_count,
'total': len(patient_data_list)
"status": "success",
"job_id": str(job.id),
"success_count": success_count,
"failed_count": failed_count,
"total": len(patient_data_list),
}
except BulkSurveyJob.DoesNotExist:
logger.error(f"BulkSurveyJob {job_id} not found")
return {'status': 'error', 'error': 'Job not found'}
return {"status": "error", "error": "Job not found"}
except Exception as e:
logger.error(f"Error in bulk survey task {job_id}: {e}", exc_info=True)
# Update job status to failed
try:
job = BulkSurveyJob.objects.get(id=job_id)
@ -669,10 +645,93 @@ def send_bulk_surveys(self, job_id):
job.save()
except:
pass
# Retry on failure
if self.request.retries < self.max_retries:
logger.info(f"Retrying bulk survey job {job_id} (attempt {self.request.retries + 1})")
raise self.retry(countdown=60 * (self.request.retries + 1))
return {'status': 'error', 'error': str(e)}
return {"status": "error", "error": str(e)}
@shared_task
def send_scheduled_survey(survey_instance_id):
"""
Send a scheduled survey.
This task is called after the delay period expires.
It sends the survey via the configured delivery channel (SMS/Email).
Args:
survey_instance_id: UUID of the SurveyInstance to send
Returns:
dict: Result with status and details
"""
from apps.surveys.models import SurveyInstance, SurveyStatus
from apps.surveys.services import SurveyDeliveryService
try:
survey = SurveyInstance.objects.get(id=survey_instance_id)
# Check if already sent
if survey.status != SurveyStatus.PENDING:
logger.warning(f"Survey {survey.id} already sent/cancelled (status: {survey.status})")
return {"status": "skipped", "reason": "already_sent", "survey_id": survey.id}
# Check if scheduled time has passed
if survey.scheduled_send_at and survey.scheduled_send_at > timezone.now():
logger.warning(f"Survey {survey.id} not due yet (scheduled: {survey.scheduled_send_at})")
return {"status": "delayed", "scheduled_at": survey.scheduled_send_at.isoformat(), "survey_id": survey.id}
# Send survey
success = SurveyDeliveryService.deliver_survey(survey)
if success:
survey.status = SurveyStatus.SENT
survey.sent_at = timezone.now()
survey.save()
logger.info(f"Scheduled survey {survey.id} sent successfully")
return {"status": "sent", "survey_id": survey.id}
else:
survey.status = SurveyStatus.FAILED
survey.save()
logger.error(f"Scheduled survey {survey.id} delivery failed")
return {"status": "failed", "survey_id": survey.id, "reason": "delivery_failed"}
except SurveyInstance.DoesNotExist:
logger.error(f"Survey {survey_instance_id} not found")
return {"status": "error", "reason": "not_found"}
except Exception as e:
logger.error(f"Error sending scheduled survey: {e}", exc_info=True)
return {"status": "error", "reason": str(e)}
@shared_task
def send_pending_scheduled_surveys():
"""
Periodic task to send any overdue scheduled surveys.
Runs every 10 minutes as a safety net to catch any surveys
that weren't sent due to task failures or delays.
Returns:
dict: Result with count of queued surveys
"""
from apps.surveys.models import SurveyInstance
# Find surveys that should have been sent but weren't
# Use sent_at__isnull=True since there's no PENDING status
overdue_surveys = SurveyInstance.objects.filter(sent_at__isnull=True, scheduled_send_at__lte=timezone.now())[
:50
] # Max 50 at a time
sent_count = 0
for survey in overdue_surveys:
send_scheduled_survey.delay(str(survey.id))
sent_count += 1
if sent_count > 0:
logger.info(f"Queued {sent_count} overdue scheduled surveys")
return {"queued": sent_count}

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,11 @@ from celery.schedules import crontab
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev')
# Apply zoneinfo compatibility patch for django-celery-beat with Python 3.12+
# This must be done before Celery app is created
from config.celery_scheduler import apply_tzcrontab_patch
apply_tzcrontab_patch()
app = Celery('px360')
# Using a string here means the worker doesn't have to serialize
@ -30,11 +35,21 @@ app.conf.beat_schedule = {
# Fetch surveys from HIS every 5 minutes
'fetch-his-surveys': {
'task': 'apps.integrations.tasks.fetch_his_surveys',
'schedule': crontab(minute='*/5'), # Every 5 minutes
'schedule': crontab(minute='*/2'), # Every 5 minutes
'options': {
'expires': 240, # Task expires after 4 minutes if not picked up
}
},
# TEST TASK - Fetch from JSON file (uncomment for testing, remove when done)
# 'test-fetch-his-surveys-from-json': {
# 'task': 'apps.integrations.tasks.test_fetch_his_surveys_from_json',
# 'schedule': crontab(minute='*/5'), # Every 5 minutes
# },
# Send pending scheduled surveys every 10 minutes
'send-pending-scheduled-surveys': {
'task': 'apps.surveys.tasks.send_pending_scheduled_surveys',
'schedule': crontab(minute='*/10'), # Every 10 minutes
},
# Check for overdue complaints every 15 minutes
'check-overdue-complaints': {
'task': 'apps.complaints.tasks.check_overdue_complaints',

View File

@ -1,36 +1,46 @@
"""
Custom Celery Beat scheduler to fix Python 3.12 zoneinfo compatibility issue.
This patches the _default_now method to work with zoneinfo.ZoneInfo instead of pytz.
This patches the TzAwareCrontab class to work with zoneinfo.ZoneInfo instead of pytz.
The error occurs because django-celery-beat assumes pytz timezone objects
which have a `normalize()` method that zoneinfo.ZoneInfo doesn't have.
"""
from django_celery_beat.schedulers import DatabaseScheduler, ModelEntry
import functools
class PatchedModelEntry(ModelEntry):
"""
Custom model entry that fixes the zoneinfo.ZoneInfo compatibility issue.
def apply_tzcrontab_patch():
"""
Apply monkey-patch to django_celery_beat.tzcrontab.TzAwareCrontab
to fix zoneinfo.ZoneInfo compatibility.
def _default_now(self):
"""
Return the current time in the configured timezone.
This fixes the AttributeError: 'zoneinfo.ZoneInfo' object has no attribute 'localize'
"""
from django.utils import timezone
return timezone.now()
class PatchedDatabaseScheduler(DatabaseScheduler):
"""
Custom scheduler that fixes the zoneinfo.ZoneInfo compatibility issue
in django-celery-beat 2.1.0 with Python 3.12.
This should be called at Celery app initialization.
"""
from django_celery_beat import tzcrontab
Entry = PatchedModelEntry
original_init = tzcrontab.TzAwareCrontab.__init__
def _default_now(self):
"""
Return the current time in the configured timezone.
This fixes the AttributeError: 'zoneinfo.ZoneInfo' object has no attribute 'localize'
"""
from django.utils import timezone
return timezone.now()
@functools.wraps(original_init)
def patched_init(self, *args, **kwargs):
# Get the tz argument, default to None
tz = kwargs.get('tz', None)
# Check if it's a zoneinfo.ZoneInfo (no 'normalize' attribute)
if tz is not None and not hasattr(tz, 'normalize'):
# Replace with a patched nowfun that works with zoneinfo
def zoneinfo_aware_nowfunc():
"""Get current time in the scheduler's timezone using Django's timezone utility."""
from django.utils import timezone as django_timezone
now = django_timezone.now()
return now.astimezone(self.tz)
# Store the zoneinfo-compatible nowfun
self._zoneinfo_nowfun = zoneinfo_aware_nowfunc
# Call original init
original_init(self, *args, **kwargs)
# If we detected zoneinfo, override the nowfun
if hasattr(self, '_zoneinfo_nowfun'):
self.nowfun = self._zoneinfo_nowfun
tzcrontab.TzAwareCrontab.__init__ = patched_init

1281
data.json

File diff suppressed because it is too large Load Diff

245
docs/SETUP_COMPLETE.md Normal file
View File

@ -0,0 +1,245 @@
# Development Environment Setup - Complete! ✅
## Setup Summary
The development environment has been successfully created with the following components:
### 📊 Database Objects Created
| Component | Count |
|-----------|-------|
| Organizations | 2 |
| Hospitals | 6 |
| Departments | 78 |
| Roles | 9 |
| PX Sources | 13 |
| Survey Templates | 22 |
| Survey Questions | 117 |
| Journey Templates | 15 |
| Journey Stages | 37 |
| Observation Categories | 15 |
| Notification Templates | 10 |
| Standard Sources | 4 |
| Standard Categories | 8 |
### 🏥 Hospitals Created
1. **NUZHA-DEV** - Al Hammadi Hospital - Nuzha (Development)
- 8 departments (ED, OPD, IP, ICU, Pharmacy, Lab, Radiology, Admin)
- 4 survey templates (Inpatient, OPD, EMS, Day Case)
- Journey templates for all patient types
- SLA configs, escalation rules, thresholds
2. **OLAYA-DEV** - Al Hammadi Hospital - Olaya (Development)
- Same configuration as NUZHA-DEV
3. **SUWAIDI-DEV** - Al Hammadi Hospital - Suwaidi (Development)
- Same configuration as NUZHA-DEV
### 📝 Survey Templates
**4 Template Types** per hospital:
1. **Inpatient Post-Discharge Survey**
- 7 questions (nursing, doctor, cleanliness, food, information, NPS, comments)
2. **OPD Patient Experience Survey**
- 6 questions (registration, waiting, consultation, pharmacy, NPS, comments)
3. **EMS Emergency Services Survey**
- 6 questions (response time, paramedic, ED care, communication, NPS, comments)
4. **Day Case Patient Survey**
- 6 questions (pre-procedure, procedure, post-procedure, discharge, NPS, comments)
### 🔄 Journey Templates
**OPD Journey Stages** (with HIS integration):
1. Registration (trigger: REGISTRATION)
2. Waiting (trigger: WAITING)
3. MD Consultation (trigger: Consultation)
4. MD Visit (trigger: Doctor Visited)
5. Clinical Assessment (trigger: Clinical Condtion)
6. Patient Assessment (trigger: ChiefComplaint)
7. Pharmacy (trigger: Prescribed Drugs)
8. Discharge (trigger: DISCHARGED)
**Other Journey Types**:
- Inpatient Journey
- EMS Journey
- Day Case Journey
### 🎭 Roles & Permissions
| Role | Level | Description |
|------|-------|-------------|
| PX Admin | 100 | Full system access |
| Hospital Admin | 80 | Hospital-level access |
| Department Manager | 60 | Department-level access |
| PX Coordinator | 50 | PX actions & complaints |
| Physician | 40 | View feedback |
| Nurse | 30 | View department feedback |
| Staff | 20 | Basic access |
| Viewer | 10 | Read-only |
| PX Source User | 5 | External sources |
### 📬 PX Sources
**Internal Sources:**
- Patient
- Family Member
- Staff
- Survey
**Government Sources:**
- Ministry of Health (MOH)
- Council of Cooperative Health Insurance (CCHI)
### ⚙️ SLA Configurations
| Source | SLA | 1st Reminder | 2nd Reminder | Escalation |
|--------|-----|--------------|--------------|------------|
| MOH | 24h | 12h | 18h | 24h |
| CCHI | 48h | 24h | 36h | 48h |
| Internal | 72h | 24h | 48h | 72h |
### 🔔 Escalation Rules
1. **Default**: Department Manager (immediate on overdue)
2. **Critical**: Hospital Admin (4h overdue)
3. **Final**: PX Admin (24h overdue)
### 👁️ Observation Categories
15 categories including:
- Patient Safety
- Clinical Quality
- Infection Control
- Medication Safety
- Equipment & Devices
- Facility & Environment
- Staff Behavior
- Communication
- Documentation
- Process & Workflow
- Security
- IT & Systems
- Housekeeping
- Food Services
- Other
### 📨 Notification Templates
10 templates for:
- Onboarding (Invitation, Reminder, Completion)
- Surveys (Invitation, Reminder)
- Complaints (Acknowledgment, Update)
- Actions (Assignment)
- SLA (Reminder, Breach)
### ✅ Standards Setup
**Standard Sources:**
- CBAHI (Saudi Central Board for Accreditation)
- JCI (Joint Commission International)
- ISO (International Organization for Standardization)
- SFDA (Saudi Food & Drug Authority)
**Standard Categories:**
- Patient Safety
- Quality Management
- Infection Control
- Medication Safety
- Environment of Care
- Leadership
- Information Management
- Facility Management
### 🔌 HIS Integration
**API Configuration:**
- URL: `https://his.alhammadi.med.sa:54380/SSRCE/API/FetchPatientVisitTimeStamps`
- Auth: Basic (username/password from .env)
- Schedule: Every 5 minutes (Celery Beat)
**Event Mappings:**
- Consultation → OPD_CONSULTATION
- Doctor Visited → OPD_DOCTOR_VISITED
- Clinical Condtion → CLINICAL_ASSESSMENT
- ChiefComplaint → PATIENT_ASSESSMENT
- Prescribed Drugs → PHARMACY
- DISCHARGED → PATIENT_DISCHARGED
## 🚀 Next Steps
### 1. Create Admin Users
```bash
python manage.py createsuperuser
```
### 2. Start Services
```bash
# Start Redis
redis-server
# Start Celery Worker
celery -A config worker -l info
# Start Celery Beat
celery -A config beat -l info
# Start Django Server
python manage.py runserver
```
### 3. Verify Setup
Visit http://localhost:8000 and login with your superuser account.
### 4. Load SHCT Taxonomy (Optional)
```bash
python manage.py load_shct_taxonomy
```
### 5. Import Staff Data (Optional)
```bash
python manage.py import_staff_csv path/to/staff.csv
```
## 📚 Documentation
See `/docs/SETUP_GUIDE.md` for complete documentation.
## 🔧 Useful Commands
```bash
# Preview changes
python manage.py setup_dev_environment --dry-run
# Setup specific hospital
python manage.py setup_dev_environment --hospital-code NUZHA-DEV
# Skip specific components
python manage.py setup_dev_environment --skip-surveys --skip-integration
# Reset and recreate (delete database first)
rm db.sqlite3
python manage.py migrate
python manage.py setup_dev_environment
```
## ✨ Features Ready to Use
1. ✅ Multi-hospital structure
2. ✅ Role-based access control
3. ✅ Survey system with 4 template types
4. ✅ HIS integration with 5-minute polling
5. ✅ Patient journey tracking
6. ✅ Complaint management with SLA
7. ✅ Observation system
8. ✅ Notification system
9. ✅ Standards compliance tracking
10. ✅ Escalation workflows
---
**Environment is ready for development!** 🎉

362
docs/SETUP_GUIDE.md Normal file
View File

@ -0,0 +1,362 @@
# PX360 Development Environment Setup Guide
## Overview
This guide explains how to set up a complete development environment for PX360 using the `setup_dev_environment` management command.
## What Gets Created
### 1. Organization Structure
- **Organization**: Al Hammadi Healthcare Group (DEV)
- **3 Hospitals**: NUZHA-DEV, OLAYA-DEV, SUWAIDI-DEV
- **Departments**: Emergency, OPD, Inpatient, ICU, Pharmacy, Laboratory, Radiology, Administration
### 2. User Roles & Permissions
- PX Admin (Full system access)
- Hospital Admin (Hospital-level access)
- Department Manager (Department-level access)
- PX Coordinator (PX actions & complaints)
- Physician (View feedback)
- Nurse (View department feedback)
- Staff (Basic access)
- Viewer (Read-only)
- PX Source User (External source users)
### 3. Survey Templates (4 types)
1. **Inpatient Post-Discharge Survey**
- Nursing care
- Doctor's care
- Room cleanliness
- Food quality
- Treatment information
- NPS question
- Comments
2. **OPD Patient Experience Survey**
- Registration process
- Waiting time
- Doctor consultation
- Pharmacy service
- NPS question
- Comments
3. **EMS Emergency Services Survey**
- Ambulance response time
- Paramedic care
- Emergency department care
- Communication
- NPS question
- Comments
4. **Day Case Patient Survey**
- Pre-procedure preparation
- Procedure quality
- Post-procedure care
- Discharge process
- NPS question
- Comments
### 4. Complaint System Configuration
- **Complaint Categories**: Clinical Care, Management, Relationships, Facility, Communication, Access, Billing, Other
- **PX Sources**: Patient, Family Member, Staff, Survey, MOH, CCHI
- **SLA Configurations** (per hospital):
- MOH: 24 hours (reminders at 12h/18h)
- CCHI: 48 hours (reminders at 24h/36h)
- Internal: 72 hours (reminders at 24h/48h)
- **Escalation Rules** (per hospital):
- Default: Department Manager (immediate)
- Critical: Hospital Admin (4h overdue)
- Final: PX Admin (24h overdue)
- **Thresholds**: Resolution survey < 50% Create PX Action
- **Explanation SLA**: 48 hours response time
### 5. Journey Templates
**OPD Journey Stages**:
1. Registration (trigger: REGISTRATION)
2. Waiting (trigger: WAITING)
3. MD Consultation (trigger: Consultation)
4. MD Visit (trigger: Doctor Visited)
5. Clinical Assessment (trigger: Clinical Condition)
6. Patient Assessment (trigger: ChiefComplaint)
7. Pharmacy (trigger: Prescribed Drugs)
8. Discharge (trigger: DISCHARGED)
**Other Journey Types**:
- Inpatient Journey
- EMS Journey
- Day Case Journey
### 6. Survey Mappings
- Patient Type 1 (Inpatient) → Inpatient Survey
- Patient Type 2 (Outpatient) → OPD Survey
- Patient Type 3 (Emergency) → EMS Survey
- Patient Type 4 (Day Case) → Day Case Survey
### 7. Observation Categories (15)
1. Patient Safety
2. Clinical Quality
3. Infection Control
4. Medication Safety
5. Equipment & Devices
6. Facility & Environment
7. Staff Behavior
8. Communication
9. Documentation
10. Process & Workflow
11. Security
12. IT & Systems
13. Housekeeping
14. Food Services
15. Other
### 8. Notification Templates
- Onboarding Invitation
- Onboarding Reminder
- Onboarding Completion
- Survey Invitation
- Survey Reminder
- Complaint Acknowledgment
- Complaint Update
- Action Assignment
- SLA Reminder
- SLA Breach
### 9. Standards Setup
**Standard Sources**:
- CBAHI (Saudi Central Board for Accreditation)
- JCI (Joint Commission International)
- ISO (International Organization for Standardization)
**Standard Categories**:
- Patient Safety
- Quality Management
- Infection Control
- Medication Safety
- Environment of Care
- Leadership
- Information Management
### 10. HIS Integration
- API URL: From `.env` (HIS_API_URL)
- Username: From `.env` (HIS_API_USERNAME)
- Password: From `.env` (HIS_API_PASSWORD)
- Event Mappings configured for OPD workflow
## Usage
### Basic Setup (All Components)
```bash
python manage.py setup_dev_environment
```
### Dry Run (Preview Only)
```bash
python manage.py setup_dev_environment --dry-run
```
### Setup Specific Hospital
```bash
python manage.py setup_dev_environment --hospital-code NUZHA-DEV
```
### Skip Specific Components
```bash
# Skip surveys
python manage.py setup_dev_environment --skip-surveys
# Skip complaints
python manage.py setup_dev_environment --skip-complaints
# Skip journeys
python manage.py setup_dev_environment --skip-journeys
# Skip HIS integration
python manage.py setup_dev_environment --skip-integration
# Combine multiple skips
python manage.py setup_dev_environment --skip-surveys --skip-integration
```
## Environment Variables Required
Add these to your `.env` file:
```env
# HIS Integration
HIS_API_URL=https://his.alhammadi.med.sa:54380/SSRCE/API/FetchPatientVisitTimeStamps
HIS_API_USERNAME=AlhhSUNZHippo
HIS_API_PASSWORD=*#$@PAlhh^2106
# Database
DATABASE_URL=sqlite:///db.sqlite3
# Redis/Celery
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
# SMS Gateway
SMS_API_URL=http://localhost:8000/api/simulator/send-sms/
SMS_API_KEY=simulator-test-key
SMS_ENABLED=True
SMS_PROVIDER=console
# Email Gateway
EMAIL_API_URL=http://localhost:8000/api/simulator/send-email/
EMAIL_API_KEY=simulator-test-key
EMAIL_ENABLED=True
EMAIL_PROVIDER=console
# AI Configuration
OPENROUTER_API_KEY=your-api-key-here
AI_MODEL=stepfun/step-3.5-flash:free
AI_TEMPERATURE=0.3
AI_MAX_TOKENS=500
```
## Post-Setup Steps
### 1. Create Admin Users
After running the setup, create admin users for each hospital:
```bash
python manage.py createsuperuser
```
Then assign them to hospitals via the admin interface or:
```python
from apps.accounts.models import HospitalUser
from apps.organizations.models import Hospital
from django.contrib.auth import get_user
User = get_user_model()
hospital = Hospital.objects.get(code='NUZHA-DEV')
user = User.objects.get(email='admin@example.com')
HospitalUser.objects.create(
user=user,
hospital=hospital,
role='hospital_admin'
)
```
### 2. Start Celery Workers
```bash
# Start Celery worker
celery -A config worker -l info
# Start Celery beat (for scheduled tasks)
celery -A config beat -l info
```
### 3. Verify Setup
```bash
# Check organization
python manage.py shell
>>> from apps.organizations.models import Organization
>>> Organization.objects.count()
1
# Check hospitals
>>> from apps.organizations.models import Hospital
>>> Hospital.objects.count()
3
# Check survey templates
>>> from apps.surveys.models import SurveyTemplate
>>> SurveyTemplate.objects.count()
12 # 4 templates × 3 hospitals
```
### 4. Test HIS Integration
```bash
python manage.py test_his_connection
```
### 5. Run Initial HIS Sync
```bash
python manage.py fetch_his_surveys
```
## Idempotent Operation
The command is **idempotent** - it can be run multiple times safely:
- Uses `get_or_create()` for all models
- Won't create duplicates
- Updates existing records if needed
- Safe to re-run after errors
## Troubleshooting
### Issue: "No module named 'django'"
**Solution**: Activate virtual environment
```bash
source .venv/bin/activate
```
### Issue: "Command not found"
**Solution**: Run from project root
```bash
cd /path/to/HH
python manage.py setup_dev_environment
```
### Issue: Database locked
**Solution**: Stop all running processes and try again
```bash
pkill -f celery
pkill -f python
python manage.py setup_dev_environment
```
### Issue: Permission denied
**Solution**: Check file permissions
```bash
chmod +x manage.py
```
## Next Steps After Setup
1. **Configure SMS Gateway** (for production)
2. **Configure Email Gateway** (for production)
3. **Load SHCT Taxonomy** (detailed complaint categories)
4. **Import Staff Data** (via CSV import commands)
5. **Set Up Department Managers** (via admin interface)
6. **Configure HIS Integration** (fine-tune event mappings)
7. **Create Additional Survey Templates** (as needed)
8. **Set Up Standards** (add CBAHI/JCI standards)
9. **Configure Notification Templates** (add SMS/email content)
10. **Test Complete Workflow** (create test complaint → resolve → survey)
## Related Management Commands
```bash
# Load SHCT complaint taxonomy
python manage.py load_shct_taxonomy
# Seed departments
python manage.py seed_departments
# Import staff from CSV
python manage.py import_staff_csv path/to/staff.csv
# Create notification templates
python manage.py init_notification_templates
# Create appreciation category
python manage.py create_patient_feedback_category
# Seed observation categories
python manage.py seed_observation_categories
# Seed acknowledgement categories
python manage.py seed_acknowledgements
```
## Support
For issues or questions:
1. Check logs: `tail -f logs/debug.log`
2. Check Celery logs
3. Review environment variables
4. Check database integrity
5. Contact: support@px360.sa

View File

@ -28,7 +28,7 @@ dependencies = [
"openpyxl>=3.1.5",
"litellm>=1.0.0",
"watchdog>=6.0.0",
"django-celery-beat>=2.1.0",
"django-celery-beat>=2.7.0",
"google-api-python-client>=2.187.0",
"tweepy>=4.16.0",
"google-auth-oauthlib>=1.2.3",

7429
rating_data.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,44 +3,124 @@
{% block title %}{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "New Category" %}{% endif %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.form-section {
background: #fff;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
}
.form-section:hover {
border-color: #005696;
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
}
.form-control, .form-select {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
outline: none;
border-color: #005696;
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #005696;
color: white;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
border: none;
cursor: pointer;
}
.btn-primary:hover {
background: #007bbd;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: white;
color: #64748b;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #005696;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<div class="p-6 md:p-8 bg-gradient-to-br from-slate-50 to-blue-50 min-h-screen">
<!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-indigo-500 rounded-2xl shadow-lg shadow-blue-200">
<i data-lucide="{% if form.instance.pk %}edit{% else %}plus{% endif %}" class="w-8 h-8 text-white"></i>
</div>
<div>
<h1 class="text-2xl font-bold text-navy">
{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "New Category" %}{% endif %}
</h1>
<p class="text-slate text-sm">
{% if form.instance.pk %}{{ form.instance.name_en }}{% else %}{% trans "Create a new acknowledgement category" %}{% endif %}
</p>
</div>
<div class="page-header-gradient flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<nav aria-label="breadcrumb" class="mb-2">
<ol class="flex items-center gap-2 text-sm text-white/80">
<li><a href="{% url 'accounts:acknowledgements:ack_category_list' %}" class="hover:text-white transition">{% trans "Categories" %}</a></li>
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li class="text-white">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Create" %}{% endif %}</li>
</ol>
</nav>
<h1 class="text-2xl font-bold flex items-center gap-2">
<i data-lucide="{% if form.instance.pk %}edit{% else %}plus{% endif %}" class="w-8 h-8"></i>
{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "New Category" %}{% endif %}
</h1>
</div>
<a href="{% url 'accounts:acknowledgements:ack_category_list' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
<a href="{% url 'accounts:acknowledgements:ack_category_list' %}" class="inline-flex items-center gap-2 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back to Categories" %}
</a>
</div>
<!-- Form Card -->
<!-- Form Section -->
<div class="max-w-3xl mx-auto">
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-blue-50 to-transparent">
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
<i data-lucide="file-text" class="w-5 h-5 text-blue-500"></i>
{% trans "Category Details" %}
</h2>
<div class="form-section">
<div class="flex items-center gap-2 mb-4 pb-3 border-b border-slate-100">
<i data-lucide="file-text" class="w-5 h-5 text-blue-600"></i>
<h2 class="text-lg font-bold text-slate-800">{% trans "Category Details" %}</h2>
</div>
<form method="post" class="p-6 space-y-6">
<form method="post" class="space-y-6">
{% csrf_token %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.name_en.id_for_label }}">
<label class="form-label" for="{{ form.name_en.id_for_label }}">
{% trans "Name (English)" %}
</label>
{{ form.name_en }}
@ -50,7 +130,7 @@
</div>
<div>
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.name_ar.id_for_label }}">
<label class="form-label" for="{{ form.name_ar.id_for_label }}">
{% trans "Name (Arabic)" %}
</label>
{{ form.name_ar }}
@ -62,7 +142,7 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.code.id_for_label }}">
<label class="form-label" for="{{ form.code.id_for_label }}">
{% trans "Code" %}
</label>
{{ form.code }}
@ -72,7 +152,7 @@
</div>
<div>
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.order.id_for_label }}">
<label class="form-label" for="{{ form.order.id_for_label }}">
{% trans "Display Order" %}
</label>
{{ form.order }}
@ -82,7 +162,7 @@
</div>
<div>
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.icon.id_for_label }}">
<label class="form-label" for="{{ form.icon.id_for_label }}">
{% trans "Icon" %}
</label>
{{ form.icon }}
@ -94,7 +174,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.color.id_for_label }}">
<label class="form-label" for="{{ form.color.id_for_label }}">
{% trans "Color" %}
</label>
<div class="flex gap-3 items-center">
@ -107,21 +187,21 @@
</div>
<div>
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.is_active.id_for_label }}">
<label class="form-label" for="{{ form.is_active.id_for_label }}">
{% trans "Status" %}
</label>
<div class="flex items-center gap-3 mt-3">
{{ form.is_active }}
<span class="text-slate">{% trans "Active" %}</span>
<span class="text-slate-600">{% trans "Active" %}</span>
</div>
</div>
</div>
<div class="flex justify-end gap-3 pt-6 border-t border-slate-100">
<a href="{% url 'accounts:acknowledgements:ack_category_list' %}" class="px-6 py-2.5 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition">
<a href="{% url 'accounts:acknowledgements:ack_category_list' %}" class="btn-secondary">
{% trans "Cancel" %}
</a>
<button type="submit" class="inline-flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
<button type="submit" class="btn-primary">
<i data-lucide="save" class="w-4 h-4"></i>
{% trans "Save Category" %}
</button>
@ -150,4 +230,4 @@ document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}
{% endblock %}

View File

@ -3,42 +3,88 @@
{% block title %}{% trans "Acknowledgement Categories" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-indigo-500 rounded-2xl shadow-lg shadow-blue-200">
<i data-lucide="folder" class="w-8 h-8 text-white"></i>
<div class="page-header-gradient">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-3">
<div class="section-icon bg-white/20">
<i data-lucide="folder" class="w-6 h-6 text-white"></i>
</div>
<div>
<h1 class="text-2xl font-bold">
{% trans "Acknowledgement Categories" %}
</h1>
<p class="text-white/80 text-sm">
{% trans "Manage categories for acknowledgement items" %}
</p>
</div>
</div>
<div>
<h1 class="text-2xl font-bold text-navy">
{% trans "Acknowledgement Categories" %}
</h1>
<p class="text-slate text-sm">
{% trans "Manage categories for acknowledgement items" %}
</p>
<div class="flex gap-3">
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white/20 border border-white/30 text-white rounded-xl font-semibold hover:bg-white/30 transition">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back" %}
</a>
<a href="{% url 'accounts:acknowledgements:ack_category_create' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white text-blue-700 rounded-xl font-semibold hover:bg-blue-50 transition shadow-lg">
<i data-lucide="plus" class="w-4 h-4"></i>
{% trans "New Category" %}
</a>
</div>
</div>
<div class="flex gap-3">
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back" %}
</a>
<a href="{% url 'accounts:acknowledgements:ack_category_create' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
<i data-lucide="plus" class="w-4 h-4"></i>
{% trans "New Category" %}
</a>
</div>
</div>
<!-- Categories List -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-blue-50 to-transparent">
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
<i data-lucide="list" class="w-5 h-5 text-blue-500"></i>
{% trans "All Categories" %}
</h2>
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-blue-100">
<i data-lucide="list" class="w-5 h-5 text-blue-600"></i>
</div>
<h2 class="text-lg font-bold text-navy">{% trans "All Categories" %}</h2>
</div>
<div class="p-6">
{% if categories %}
@ -130,4 +176,4 @@ document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}
{% endblock %}

View File

@ -3,44 +3,124 @@
{% block title %}{% if form.instance.pk %}{% trans "Edit Checklist Item" %}{% else %}{% trans "New Checklist Item" %}{% endif %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.form-section {
background: #fff;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
}
.form-section:hover {
border-color: #005696;
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
}
.form-control, .form-select {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
outline: none;
border-color: #005696;
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #005696;
color: white;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
border: none;
cursor: pointer;
}
.btn-primary:hover {
background: #007bbd;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: white;
color: #64748b;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #005696;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<div class="p-6 md:p-8 bg-gradient-to-br from-slate-50 to-blue-50 min-h-screen">
<!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-2xl shadow-lg shadow-purple-200">
<i data-lucide="{% if form.instance.pk %}edit{% else %}plus{% endif %}" class="w-8 h-8 text-white"></i>
</div>
<div>
<h1 class="text-2xl font-bold text-navy">
{% if form.instance.pk %}{% trans "Edit Checklist Item" %}{% else %}{% trans "New Checklist Item" %}{% endif %}
</h1>
<p class="text-slate text-sm">
{% if form.instance.pk %}{{ form.instance.text_en }}{% else %}{% trans "Create a new acknowledgement checklist item" %}{% endif %}
</p>
</div>
<div class="page-header-gradient flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<nav aria-label="breadcrumb" class="mb-2">
<ol class="flex items-center gap-2 text-sm text-white/80">
<li><a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="hover:text-white transition">{% trans "Checklist Items" %}</a></li>
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li class="text-white">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Create" %}{% endif %}</li>
</ol>
</nav>
<h1 class="text-2xl font-bold flex items-center gap-2">
<i data-lucide="{% if form.instance.pk %}edit{% else %}plus{% endif %}" class="w-8 h-8"></i>
{% if form.instance.pk %}{% trans "Edit Checklist Item" %}{% else %}{% trans "New Checklist Item" %}{% endif %}
</h1>
</div>
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="inline-flex items-center gap-2 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back to List" %}
</a>
</div>
<!-- Form Card -->
<!-- Form Section -->
<div class="max-w-4xl mx-auto">
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-purple-50 to-transparent">
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
<i data-lucide="file-text" class="w-5 h-5 text-purple-500"></i>
{% trans "Checklist Item Details" %}
</h2>
<div class="form-section">
<div class="flex items-center gap-2 mb-4 pb-3 border-b border-slate-100">
<i data-lucide="file-text" class="w-5 h-5 text-purple-600"></i>
<h2 class="text-lg font-bold text-slate-800">{% trans "Checklist Item Details" %}</h2>
</div>
<form method="post" class="p-6 space-y-6">
<form method="post" class="space-y-6">
{% csrf_token %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.category.id_for_label }}">
<label class="form-label" for="{{ form.category.id_for_label }}">
{% trans "Category" %} *
</label>
{{ form.category }}
@ -50,7 +130,7 @@
</div>
<div>
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.code.id_for_label }}">
<label class="form-label" for="{{ form.code.id_for_label }}">
{% trans "Code" %}
</label>
{{ form.code }}
@ -61,7 +141,7 @@
</div>
<div>
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.text_en.id_for_label }}">
<label class="form-label" for="{{ form.text_en.id_for_label }}">
{% trans "Text (English)" %} *
</label>
{{ form.text_en }}
@ -71,7 +151,7 @@
</div>
<div>
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.text_ar.id_for_label }}">
<label class="form-label" for="{{ form.text_ar.id_for_label }}">
{% trans "Text (Arabic)" %}
</label>
{{ form.text_ar }}
@ -81,7 +161,7 @@
</div>
<div>
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.description_en.id_for_label }}">
<label class="form-label" for="{{ form.description_en.id_for_label }}">
{% trans "Description (English)" %}
</label>
{{ form.description_en }}
@ -91,7 +171,7 @@
</div>
<div>
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.description_ar.id_for_label }}">
<label class="form-label" for="{{ form.description_ar.id_for_label }}">
{% trans "Description (Arabic)" %}
</label>
{{ form.description_ar }}
@ -102,7 +182,7 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label class="block text-sm font-semibold text-navy mb-2" for="{{ form.order.id_for_label }}">
<label class="form-label" for="{{ form.order.id_for_label }}">
{% trans "Display Order" %}
</label>
{{ form.order }}
@ -115,7 +195,7 @@
<div class="mt-6">
<div class="flex items-center gap-3">
{{ form.requires_signature }}
<label class="text-sm text-navy" for="{{ form.requires_signature.id_for_label }}">
<label class="text-sm text-slate-700" for="{{ form.requires_signature.id_for_label }}">
{% trans "Requires Signature" %}
</label>
</div>
@ -126,7 +206,7 @@
<div class="mt-6">
<div class="flex items-center gap-3">
{{ form.is_active }}
<label class="text-sm text-navy" for="{{ form.is_active.id_for_label }}">
<label class="text-sm text-slate-700" for="{{ form.is_active.id_for_label }}">
{% trans "Active" %}
</label>
</div>
@ -135,10 +215,10 @@
</div>
<div class="flex justify-end gap-3 pt-6 border-t border-slate-100">
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="px-6 py-2.5 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition">
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="btn-secondary">
{% trans "Cancel" %}
</a>
<button type="submit" class="inline-flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-xl font-semibold hover:from-indigo-500 hover:to-purple-500 transition shadow-lg shadow-purple-200">
<button type="submit" class="btn-primary">
<i data-lucide="save" class="w-4 h-4"></i>
{% trans "Save Item" %}
</button>
@ -153,4 +233,4 @@ document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}
{% endblock %}

View File

@ -3,73 +3,121 @@
{% block title %}{% trans "Acknowledgement Checklist Items" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-2xl shadow-lg shadow-purple-200">
<i data-lucide="check-square" class="w-8 h-8 text-white"></i>
<div class="page-header-gradient">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-3">
<div class="section-icon bg-white/20">
<i data-lucide="check-square" class="w-6 h-6 text-white"></i>
</div>
<div>
<h1 class="text-2xl font-bold">
{% trans "Checklist Items" %}
</h1>
<p class="text-white/80 text-sm">
{% trans "Manage acknowledgement checklist items" %}
</p>
</div>
</div>
<div>
<h1 class="text-2xl font-bold text-navy">
{% trans "Checklist Items" %}
</h1>
<p class="text-slate text-sm">
{% trans "Manage acknowledgement checklist items" %}
</p>
<div class="flex gap-3">
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white/20 border border-white/30 text-white rounded-xl font-semibold hover:bg-white/30 transition">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back" %}
</a>
<a href="{% url 'accounts:acknowledgements:ack_checklist_create' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white text-purple-700 rounded-xl font-semibold hover:bg-purple-50 transition shadow-lg">
<i data-lucide="plus" class="w-4 h-4"></i>
{% trans "New Item" %}
</a>
</div>
</div>
<div class="flex gap-3">
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back" %}
</a>
<a href="{% url 'accounts:acknowledgements:ack_checklist_create' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-xl font-semibold hover:from-indigo-500 hover:to-purple-500 transition shadow-lg shadow-purple-200">
<i data-lucide="plus" class="w-4 h-4"></i>
{% trans "New Item" %}
</a>
</div>
</div>
<!-- Filter Section -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-4 mb-6">
<form method="get" class="flex flex-wrap gap-4 items-end">
<div>
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Category" %}</label>
<select name="category" class="px-4 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue focus:border-blue">
<option value="">{% trans "All Categories" %}</option>
{% for cat in categories %}
<option value="{{ cat.id }}" {% if request.GET.category == cat.id|stringformat:"s" %}selected{% endif %}>{{ cat.name_en }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Status" %}</label>
<select name="is_active" class="px-4 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue focus:border-blue">
<option value="">{% trans "All" %}</option>
<option value="true" {% if request.GET.is_active == "true" %}selected{% endif %}>{% trans "Active" %}</option>
<option value="false" {% if request.GET.is_active == "false" %}selected{% endif %}>{% trans "Inactive" %}</option>
</select>
</div>
<button type="submit" class="px-4 py-2 bg-blue text-white rounded-xl text-sm font-semibold hover:bg-navy transition">
<i data-lucide="filter" class="w-4 h-4 inline mr-1"></i>
{% trans "Filter" %}
</button>
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="px-4 py-2 text-slate hover:text-navy text-sm">
{% trans "Clear" %}
</a>
</form>
<div class="section-card mb-6">
<div class="p-4">
<form method="get" class="flex flex-wrap gap-4 items-end">
<div>
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Category" %}</label>
<select name="category" class="px-4 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue focus:border-blue">
<option value="">{% trans "All Categories" %}</option>
{% for cat in categories %}
<option value="{{ cat.id }}" {% if request.GET.category == cat.id|stringformat:"s" %}selected{% endif %}>{{ cat.name_en }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Status" %}</label>
<select name="is_active" class="px-4 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue focus:border-blue">
<option value="">{% trans "All" %}</option>
<option value="true" {% if request.GET.is_active == "true" %}selected{% endif %}>{% trans "Active" %}</option>
<option value="false" {% if request.GET.is_active == "false" %}selected{% endif %}>{% trans "Inactive" %}</option>
</select>
</div>
<button type="submit" class="px-4 py-2 bg-blue text-white rounded-xl text-sm font-semibold hover:bg-navy transition">
<i data-lucide="filter" class="w-4 h-4 inline mr-1"></i>
{% trans "Filter" %}
</button>
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="px-4 py-2 text-slate hover:text-navy text-sm">
{% trans "Clear" %}
</a>
</form>
</div>
</div>
<!-- Checklist Items List -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-purple-50 to-transparent">
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
<i data-lucide="list" class="w-5 h-5 text-purple-500"></i>
{% trans "All Checklist Items" %}
<span class="ml-2 px-2 py-0.5 bg-slate-100 text-slate text-sm rounded-full">{{ items.count }}</span>
</h2>
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-purple-100">
<i data-lucide="list" class="w-5 h-5 text-purple-600"></i>
</div>
<h2 class="text-lg font-bold text-navy">{% trans "All Checklist Items" %}</h2>
<span class="ml-2 px-2 py-0.5 bg-slate-100 text-slate text-sm rounded-full">{{ items.count }}</span>
</div>
<div class="p-6">
{% if items %}
@ -157,4 +205,4 @@ document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}
{% endblock %}

View File

@ -3,36 +3,82 @@
{% block title %}{% trans "Completed Acknowledgements" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-emerald-500 to-green-500 rounded-2xl shadow-lg shadow-emerald-200">
<i data-lucide="check-circle" class="w-8 h-8 text-white"></i>
<div class="page-header-gradient">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-3">
<div class="section-icon bg-white/20">
<i data-lucide="check-circle" class="w-6 h-6 text-white"></i>
</div>
<div>
<h1 class="text-2xl font-bold">
{% trans "Completed Acknowledgements" %}
</h1>
<p class="text-white/80 text-sm">
{% trans "View your signed acknowledgements" %}
</p>
</div>
</div>
<div>
<h1 class="text-2xl font-bold text-navy">
{% trans "Completed Acknowledgements" %}
</h1>
<p class="text-slate text-sm">
{% trans "View your signed acknowledgements" %}
</p>
<div class="flex gap-3">
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white/20 border border-white/30 text-white rounded-xl font-semibold hover:bg-white/30 transition">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back to Dashboard" %}
</a>
</div>
</div>
<div class="flex gap-3">
<a href="{% url 'accounts:acknowledgements:ack_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-blue-200 text-blue-700 rounded-xl font-semibold hover:bg-blue-50 transition">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back to Dashboard" %}
</a>
</div>
</div>
<!-- Stats Card -->
<div class="bg-white rounded-2xl shadow-sm border border-emerald-100 p-6 mb-8">
<div class="section-card p-6 mb-6 border-emerald-200">
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-gradient-to-br from-emerald-500 to-green-500 rounded-xl flex items-center justify-center">
<i data-lucide="file-check" class="w-6 h-6 text-white"></i>
<div class="section-icon bg-emerald-100">
<i data-lucide="file-check" class="w-6 h-6 text-emerald-600"></i>
</div>
<div>
<p class="text-xs font-semibold text-slate uppercase">{% trans "Total Signed" %}</p>
@ -42,12 +88,12 @@
</div>
<!-- Acknowledgements List -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-emerald-50 to-transparent">
<h2 class="text-lg font-bold text-navy flex items-center gap-2">
<i data-lucide="clipboard-check" class="w-5 h-5 text-emerald-500"></i>
{% trans "Signed Documents" %}
</h2>
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-emerald-100">
<i data-lucide="clipboard-check" class="w-5 h-5 text-emerald-600"></i>
</div>
<h2 class="text-lg font-bold text-navy">{% trans "Signed Documents" %}</h2>
</div>
<div class="p-6">
{% if acknowledgements %}
@ -130,4 +176,4 @@ document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}
{% endblock %}

View File

@ -1,135 +1,73 @@
{% extends 'emails/base_email_template.html' %}
{% load i18n %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans "Password Reset - PX360" %}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f6f9fc;
margin: 0;
padding: 20px;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #0086d2 0%, #005d93 100%);
color: white;
padding: 30px 20px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
}
.header p {
margin: 10px 0 0 0;
font-size: 14px;
opacity: 0.9;
}
.content {
padding: 40px 30px;
}
.content h2 {
color: #333;
font-size: 20px;
margin-top: 0;
margin-bottom: 20px;
}
.content p {
color: #666;
line-height: 1.6;
margin-bottom: 20px;
}
.button-container {
text-align: center;
margin: 30px 0;
}
.reset-button {
display: inline-block;
background: linear-gradient(135deg, #0086d2 0%, #005d93 100%);
color: white;
padding: 15px 30px;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
font-size: 16px;
}
.reset-button:hover {
text-decoration: none;
box-shadow: 0 4px 12px rgba(0, 134, 210, 0.3);
}
.link-text {
word-break: break-all;
color: #0086d2;
font-size: 12px;
margin-top: 10px;
}
.footer {
background-color: #f8f9fa;
padding: 20px;
text-align: center;
font-size: 12px;
color: #999;
}
.footer p {
margin: 5px 0;
}
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
font-size: 14px;
color: #856404;
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="header">
<h1>{% trans "Password Reset Request" %}</h1>
<p>{% trans "Patient Experience Management System" %}</p>
</div>
<!-- Content -->
<div class="content">
<h2>{% trans "Hello, {{ user.email }}" %}</h2>
<p>{% trans "We received a request to reset your password for your PX360 account. If you made this request, click the button below to reset your password:" %}</p>
{% block title %}{% trans "Password Reset Request - PX360 Al Hammadi Hospital" %}{% endblock %}
<div class="button-container">
<a href="{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %}" class="reset-button">
{% trans "Reset My Password" %}
</a>
</div>
{% block preheader %}{% trans "We received a request to reset your password. Click to reset it now." %}{% endblock %}
<p>{% trans "Or copy and paste this link into your browser:" %}</p>
<p class="link-text">{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %}</p>
{% block hero_title %}{% trans "Password Reset Request" %}{% endblock %}
<div class="warning-box">
<strong>{% trans "Important:" %}</strong><br>
{% block hero_subtitle %}{% trans "Patient Experience Management System" %}{% endblock %}
{% block content %}
<!-- Greeting -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding-bottom: 20px;">
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
{% trans "Hello" %} <strong>{{ user.email }}</strong>,
</p>
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
{% trans "We received a request to reset your password for your PX360 account. If you made this request, click the button below to reset your password:" %}
</p>
</td>
</tr>
</table>
<!-- Reset Link Text -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding-bottom: 15px;">
<p style="margin: 0; font-size: 14px; color: #64748b;">
{% trans "Or copy and paste this link into your browser:" %}
</p>
<p style="margin: 5px 0 0 0; font-size: 12px; color: #007bbd; word-break: break-all;">
{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %}
</p>
</td>
</tr>
</table>
<!-- Warning Box -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 15px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 8px;">
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #856404;">
{% trans "Important:" %}
</p>
<p style="margin: 0; font-size: 14px; color: #856404; line-height: 1.6;">
{% trans "This link will expire in 24 hours. If you didn't request this password reset, please ignore this email and your password will remain unchanged." %}
</div>
</p>
</td>
</tr>
</table>
<p>{% trans "If you continue to have problems, please contact our support team." %}</p>
</div>
<!-- Support Message -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 20px;">
<tr>
<td>
<p style="margin: 0; font-size: 14px; color: #64748b; line-height: 1.6;">
{% trans "If you continue to have problems, please contact our support team." %}
</p>
</td>
</tr>
</table>
{% endblock %}
<!-- Footer -->
<div class="footer">
<p>{% trans "This is an automated email from PX360" %}</p>
<p>&copy; {% now "Y" %} Al Hammadi Hospital. {% trans "All rights reserved." %}</p>
</div>
</div>
</body>
</html>
{% block cta_url %}{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %}{% endblock %}
{% block cta_text %}{% trans "Reset My Password" %}{% endblock %}
{% block footer_address %}
PX360 - Patient Experience Management<br>
Al Hammadi Hospital
{% endblock %}

View File

@ -3,35 +3,83 @@
{% block title %}{% trans "Acknowledgement Checklist Items" %}{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-navy rounded-2xl shadow-lg shadow-blue-200">
<i data-lucide="list-checks" class="w-8 h-8 text-white"></i>
</div>
<div>
<h2 class="text-3xl font-bold text-navy mb-1">
{% trans "Checklist Items" %}
</h2>
<p class="text-slate">{% trans "Manage acknowledgement checklist items" %}</p>
<div class="page-header-gradient">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-3">
<div class="section-icon bg-white/20">
<i data-lucide="list-checks" class="w-6 h-6 text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold">
{% trans "Checklist Items" %}
</h2>
<p class="text-white/80">{% trans "Manage acknowledgement checklist items" %}</p>
</div>
</div>
<button type="button" onclick="document.getElementById('createChecklistItemModal').classList.remove('hidden')"
class="bg-white text-[#005696] px-6 py-3 rounded-xl font-bold hover:bg-white/90 transition shadow-lg flex items-center gap-2">
<i data-lucide="plus" class="w-5 h-5"></i>
{% trans "Add Checklist Item" %}
</button>
</div>
<button type="button" onclick="document.getElementById('createChecklistItemModal').classList.remove('hidden')"
class="bg-gradient-to-r from-blue to-navy text-white px-6 py-3 rounded-xl font-bold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200 flex items-center gap-2">
<i data-lucide="plus" class="w-5 h-5"></i>
{% trans "Add Checklist Item" %}
</button>
</div>
<!-- Checklist Items List -->
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
<div class="px-6 py-5 border-b border-blue-100 bg-gradient-to-r from-blue-50 to-transparent flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<h3 class="font-bold text-navy flex items-center gap-2 text-lg">
<i data-lucide="clipboard-list" class="w-5 h-5 text-blue"></i>
{% trans "All Items" %}
</h3>
<div class="section-card">
<div class="section-header flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-2">
<div class="section-icon bg-gradient-to-br from-blue to-navy">
<i data-lucide="clipboard-list" class="w-5 h-5 text-white"></i>
</div>
<h3 class="font-bold text-navy text-lg">{% trans "All Items" %}</h3>
</div>
<div class="flex items-center gap-2 w-full md:w-auto">
<div class="relative flex-1 md:w-64">
<input type="text" id="searchInput" placeholder="{% trans 'Search items...' %}"

View File

@ -1,163 +1,107 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Onboarding Completed</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #0d6efd;
}
h1 {
color: #198754;
font-size: 24px;
margin-bottom: 20px;
}
.content {
margin-bottom: 30px;
}
.success-icon {
font-size: 48px;
text-align: center;
margin-bottom: 20px;
}
.user-info {
background-color: #f8f9fa;
border-radius: 6px;
padding: 20px;
margin: 20px 0;
}
.user-info table {
width: 100%;
border-collapse: collapse;
}
.user-info td {
padding: 8px 0;
border-bottom: 1px solid #dee2e6;
}
.user-info td:first-child {
font-weight: bold;
color: #666;
width: 40%;
}
.user-info tr:last-child td {
border-bottom: none;
}
.button {
display: inline-block;
background-color: #0d6efd;
color: #ffffff !important;
text-decoration: none;
padding: 12px 24px;
border-radius: 6px;
font-weight: bold;
font-size: 14px;
margin: 10px 0;
}
.button:hover {
background-color: #0b5ed7;
}
.button-container {
text-align: center;
margin: 30px 0;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 12px;
color: #666;
text-align: center;
}
.timestamp {
font-size: 12px;
color: #666;
text-align: center;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">PX360</div>
<p style="color: #666; margin-top: 5px;">Patient Experience Platform</p>
</div>
<div class="success-icon"></div>
<h1>User Onboarding Completed</h1>
<div class="content">
<p>A new user has successfully completed the onboarding process and is now active in the PX360 system.</p>
{% extends 'emails/base_email_template.html' %}
{% block title %}User Onboarding Completed - Al Hammadi Hospital{% endblock %}
{% block preheader %}A new user has completed onboarding and is now active.{% endblock %}
{% block hero_title %}✅ User Onboarding Completed{% endblock %}
{% block hero_subtitle %}A new team member has joined PX360{% endblock %}
{% block content %}
<!-- Notification Message -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding-bottom: 20px;">
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
A new user has successfully completed the onboarding process and is now active in the PX360 system.
</p>
</td>
</tr>
</table>
<!-- User Information Card -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0; background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0;">
<tr>
<td style="padding: 20px;">
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696; text-align: center;">
User Information
</h3>
<div class="user-info">
<table>
<tr>
<td>Name:</td>
<td>{{ user.get_full_name|default:"Not provided" }}</td>
</tr>
<tr>
<td>Email:</td>
<td>{{ user.email }}</td>
</tr>
<tr>
<td>Username:</td>
<td>{{ user.username }}</td>
</tr>
<tr>
<td>Employee ID:</td>
<td>{{ user.employee_id|default:"Not provided" }}</td>
</tr>
<tr>
<td>Hospital:</td>
<td>{{ user.hospital.name|default:"Not assigned" }}</td>
</tr>
<tr>
<td>Department:</td>
<td>{{ user.department.name|default:"Not assigned" }}</td>
</tr>
<tr>
<td>Completed At:</td>
<td>{{ user.acknowledgement_completed_at|date:"F j, Y, g:i a" }}</td>
</tr>
</table>
</div>
<div class="button-container">
<a href="{{ user_detail_url }}" class="button">View User Details</a>
</div>
</div>
<p class="timestamp">
This notification was sent on {{ "now"|date:"F j, Y, g:i a" }}
</p>
<div class="footer">
<p>This is an automated notification from PX360.</p>
<p>&copy; {{ "now"|date:"Y" }} PX360 - Patient Experience Platform</p>
</div>
</div>
</body>
</html>
<!-- Detail Rows -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td width="120" style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
Name:
</td>
<td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ user.get_full_name|default:"Not provided" }}
</td>
</tr>
<tr>
<td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
Email:
</td>
<td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ user.email }}
</td>
</tr>
<tr>
<td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
Username:
</td>
<td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ user.username }}
</td>
</tr>
<tr>
<td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
Employee ID:
</td>
<td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ user.employee_id|default:"Not provided" }}
</td>
</tr>
<tr>
<td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
Hospital:
</td>
<td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ user.hospital.name|default:"Not assigned" }}
</td>
</tr>
<tr>
<td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
Department:
</td>
<td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ user.department.name|default:"Not assigned" }}
</td>
</tr>
<tr>
<td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
Completed At:
</td>
<td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ user.acknowledgement_completed_at|date:"F j, Y, g:i a" }}
</td>
</tr>
</table>
</td>
</tr>
</table>
{% endblock %}
{% block cta_url %}{{ user_detail_url }}{% endblock %}
{% block cta_text %}View User Details{% endblock %}
{% block info_title %}Notification Details{% endblock %}
{% block info_content %}
This notification was sent on {{ "now"|date:"F j, Y, g:i a" }}.<br>
This is an automated notification from PX360.
{% endblock %}
{% block footer_address %}
PX360 - Patient Experience Platform<br>
Al Hammadi Hospital
{% endblock %}

View File

@ -3,35 +3,85 @@
{% block title %}{% trans "Manage Onboarding Content" %}{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-indigo-500 rounded-2xl shadow-lg shadow-blue-200">
<i data-lucide="file-text" class="w-8 h-8 text-white"></i>
</div>
<div>
<a href="{% url 'accounts:acknowledgement-dashboard' %}" class="inline-flex items-center text-blue hover:text-navy mb-2 font-medium">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
{% trans "Back to Dashboard" %}
</a>
<h1 class="text-3xl font-bold text-navy">
{% trans "Onboarding Content" %}
</h1>
<p class="text-slate">
{% trans "Manage the content shown during staff onboarding" %}
</p>
<!-- Page Header -->
<div class="page-header-gradient">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-3">
<div class="section-icon bg-white/20">
<i data-lucide="file-text" class="w-6 h-6 text-white"></i>
</div>
<div>
<a href="{% url 'accounts:acknowledgement-dashboard' %}" class="inline-flex items-center text-white/80 hover:text-white mb-1 font-medium transition">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
{% trans "Back to Dashboard" %}
</a>
<h1 class="text-2xl font-bold">
{% trans "Onboarding Content" %}
</h1>
<p class="text-white/80">{% trans "Manage the content shown during staff onboarding" %}</p>
</div>
</div>
<a href="{% url 'accounts:onboarding_content_create' %}" class="bg-white text-[#005696] px-6 py-3 rounded-xl font-bold hover:bg-white/90 transition shadow-lg flex items-center gap-2">
<i data-lucide="plus" class="w-5 h-5"></i>
{% trans "Add Content" %}
</a>
</div>
<a href="{% url 'accounts:onboarding_content_create' %}" class="inline-flex items-center justify-center bg-gradient-to-r from-blue to-navy text-white px-6 py-3 rounded-xl font-bold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
<i data-lucide="plus" class="w-5 h-5 mr-2"></i>
{% trans "Add Content" %}
</a>
</div>
<!-- Content List -->
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-gradient-to-br from-blue to-indigo-500">
<i data-lucide="folder-open" class="w-5 h-5 text-white"></i>
</div>
<h3 class="font-bold text-navy text-lg">{% trans "All Content Items" %}</h3>
</div>
{% if content_items %}
<div class="divide-y divide-blue-50">
{% for item in content_items %}

View File

@ -1,138 +1,108 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to PX360</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #0d6efd;
}
h1 {
color: #0d6efd;
font-size: 24px;
margin-bottom: 20px;
}
.greeting {
font-size: 18px;
margin-bottom: 20px;
}
.content {
margin-bottom: 30px;
}
.button {
display: inline-block;
background-color: #0d6efd;
color: #ffffff !important;
text-decoration: none;
padding: 14px 30px;
border-radius: 6px;
font-weight: bold;
font-size: 16px;
margin: 20px 0;
}
.button:hover {
background-color: #0b5ed7;
}
.button-container {
text-align: center;
margin: 30px 0;
}
.expiry-notice {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
font-size: 14px;
}
.expiry-notice strong {
color: #856404;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 12px;
color: #666;
text-align: center;
}
.link-fallback {
font-size: 12px;
color: #666;
word-break: break-all;
margin-top: 15px;
}
ul {
padding-left: 20px;
}
li {
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">PX360</div>
<p style="color: #666; margin-top: 5px;">Patient Experience Platform</p>
</div>
<h1>Welcome to PX360!</h1>
<p class="greeting">Hello {{ user.first_name|default:user.email }},</p>
<div class="content">
<p>You have been invited to join PX360, our comprehensive Patient Experience management platform. To complete your account setup, please click the button below:</p>
{% extends 'emails/base_email_template.html' %}
{% block title %}Welcome to PX360 - Al Hammadi Hospital{% endblock %}
{% block preheader %}You have been invited to join PX360. Complete your account setup.{% endblock %}
{% block hero_title %}Welcome to PX360!{% endblock %}
{% block hero_subtitle %}Your comprehensive Patient Experience management platform{% endblock %}
{% block content %}
<!-- Greeting -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding-bottom: 20px;">
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
Hello <strong>{{ user.first_name|default:user.email }}</strong>,
</p>
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
You have been invited to join PX360, our comprehensive Patient Experience management platform. To complete your account setup, please click the button below.
</p>
</td>
</tr>
</table>
<!-- What You'll Do -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
<tr>
<td>
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696;">
During the onboarding process, you will:
</h3>
</td>
</tr>
<tr>
<td>
<!-- Item 1 -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
<tr>
<td width="40" valign="top" style="padding-right: 10px;">
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;"></span>
</td>
<td>
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
Learn about PX360 features and your role responsibilities
</p>
</td>
</tr>
</table>
<div class="button-container">
<a href="{{ activation_url }}" class="button">Complete Account Setup</a>
</div>
<!-- Item 2 -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
<tr>
<td width="40" valign="top" style="padding-right: 10px;">
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">📋</span>
</td>
<td>
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
Review and acknowledge important policies and guidelines
</p>
</td>
</tr>
</table>
<p>During the onboarding process, you will:</p>
<ul>
<li>Learn about PX360 features and your role responsibilities</li>
<li>Review and acknowledge important policies and guidelines</li>
<li>Set up your username and password</li>
<li>Complete your profile information</li>
</ul>
</div>
<div class="expiry-notice">
<strong>⏰ Important:</strong> This invitation link will expire on <strong>{{ expires_at|date:"F j, Y, g:i a" }}</strong>. Please complete your registration before this date.
</div>
<p class="link-fallback">
If the button above doesn't work, copy and paste this link into your browser:<br>
<a href="{{ activation_url }}">{{ activation_url }}</a>
</p>
<div class="footer">
<p>This is an automated message from PX360. Please do not reply to this email.</p>
<p>If you did not expect this invitation or have questions, please contact your system administrator.</p>
<p>&copy; {{ "now"|date:"Y" }} PX360 - Patient Experience Platform</p>
</div>
</div>
</body>
</html>
<!-- Item 3 -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
<tr>
<td width="40" valign="top" style="padding-right: 10px;">
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">🔐</span>
</td>
<td>
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
Set up your username and password
</p>
</td>
</tr>
</table>
<!-- Item 4 -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td width="40" valign="top" style="padding-right: 10px;">
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">👤</span>
</td>
<td>
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
Complete your profile information
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
{% endblock %}
{% block cta_url %}{{ activation_url }}{% endblock %}
{% block cta_text %}Complete Account Setup{% endblock %}
{% block info_title %}⏰ Important: Invitation Expiry{% endblock %}
{% block info_content %}
This invitation link will expire on <strong>{{ expires_at|date:"F j, Y, g:i a" }}</strong>. Please complete your registration before this date.
{% endblock %}
{% block footer_address %}
PX360 - Patient Experience Platform<br>
Al Hammadi Hospital
{% endblock %}

View File

@ -3,31 +3,124 @@
{% block title %}{% trans "Provisional Accounts" %}{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-purple-500 rounded-2xl shadow-lg shadow-blue-200">
<i data-lucide="user-plus" class="w-8 h-8 text-white"></i>
<!-- Page Header -->
<div class="page-header-gradient">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-3">
<div class="section-icon bg-white/20">
<i data-lucide="user-plus" class="w-6 h-6 text-white"></i>
</div>
<div>
<a href="{% url 'accounts:acknowledgement-dashboard' %}" class="inline-flex items-center text-white/80 hover:text-white mb-1 font-medium transition">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
{% trans "Back to Dashboard" %}
</a>
<h1 class="text-2xl font-bold">
{% trans "Provisional Accounts" %}
</h1>
<p class="text-white/80">{% trans "View accounts pending activation" %}</p>
</div>
</div>
<div>
<a href="{% url 'accounts:acknowledgement-dashboard' %}" class="inline-flex items-center text-blue hover:text-navy mb-2 font-medium">
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
{% trans "Back to Dashboard" %}
</a>
<h1 class="text-3xl font-bold text-navy">
{% trans "Provisional Accounts" %}
</h1>
<p class="text-slate">
{% trans "View accounts pending activation" %}
</p>
<div class="flex gap-3">
<button onclick="openCreateModal()" class="bg-white text-navy px-4 py-2 rounded-lg font-medium hover:bg-white/90 transition flex items-center gap-2">
<i data-lucide="plus" class="w-4 h-4"></i>
{% trans "Create New User" %}
</button>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-xl p-6 border-2 border-blue-100 shadow-sm">
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
<i data-lucide="users" class="w-6 h-6 text-blue-600"></i>
</div>
<div>
<div class="text-2xl font-bold text-navy">{{ total_count }}</div>
<div class="text-sm text-slate">{% trans "Total Provisional" %}</div>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-6 border-2 border-emerald-100 shadow-sm">
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-emerald-100 rounded-xl flex items-center justify-center">
<i data-lucide="user-check" class="w-6 h-6 text-emerald-600"></i>
</div>
<div>
<div class="text-2xl font-bold text-emerald-600">{{ completed_count }}</div>
<div class="text-sm text-slate">{% trans "Completed" %}</div>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-6 border-2 border-orange-100 shadow-sm">
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center">
<i data-lucide="clock" class="w-6 h-6 text-orange-600"></i>
</div>
<div>
<div class="text-2xl font-bold text-orange-600">{{ in_progress_count }}</div>
<div class="text-sm text-slate">{% trans "In Progress" %}</div>
</div>
</div>
</div>
</div>
<!-- Accounts List -->
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-gradient-to-br from-blue to-purple-500">
<i data-lucide="users" class="w-5 h-5 text-white"></i>
</div>
<h3 class="font-bold text-navy text-lg">{% trans "Pending Accounts" %}</h3>
</div>
{% if provisional_accounts %}
<div class="overflow-x-auto">
<table class="w-full">
@ -80,7 +173,7 @@
{% endif %}
</td>
<td class="px-6 py-4">
<span class="text-slate text-sm">{{ account.invited_at|date:"M d, Y" }}</span>
<span class="text-slate text-sm">{{ account.created_at|date:"M d, Y" }}</span>
</td>
<td class="px-6 py-4">
{% if account.invitation_expires_at %}
@ -99,7 +192,7 @@
<i data-lucide="check" class="w-3 h-3"></i>
{% trans "Active" %}
</span>
elif account.invitation_expires_at and account.invitation_expires_at < now %}
{% elif account.invitation_expires_at and account.invitation_expires_at < now %}
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-bold bg-red-100 text-red-700">
<i data-lucide="alert-circle" class="w-3 h-3"></i>
{% trans "Expired" %}
@ -112,12 +205,23 @@
</td>
<td class="px-6 py-4">
<div class="flex gap-2">
<button class="px-3 py-2 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition font-medium text-sm" title="{% trans 'Resend Invite' %}">
<i data-lucide="mail" class="w-4 h-4"></i>
</button>
<button class="px-3 py-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition font-medium text-sm" title="{% trans 'Deactivate' %}">
<i data-lucide="user-x" class="w-4 h-4"></i>
</button>
<a href="{% url 'accounts:provisional-user-progress' account.id %}" class="px-3 py-2 text-emerald-600 bg-emerald-50 hover:bg-emerald-100 rounded-lg transition font-medium text-sm" title="{% trans 'View Progress' %}">
<i data-lucide="eye" class="w-4 h-4"></i>
</a>
<form method="post" action="{% url 'accounts:bulk-resend-invitations' %}" class="inline" onsubmit="return confirm('{% trans "Resend invitation to this user?" %}')">
{% csrf_token %}
<input type="hidden" name="user_ids" value="{{ account.id }}">
<button type="submit" class="px-3 py-2 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition font-medium text-sm" title="{% trans 'Resend Invite' %}">
<i data-lucide="mail" class="w-4 h-4"></i>
</button>
</form>
<form method="post" action="{% url 'accounts:bulk-deactivate-users' %}" class="inline" onsubmit="return confirm('{% trans "Deactivate this user?" %}')">
{% csrf_token %}
<input type="hidden" name="user_ids" value="{{ account.id }}">
<button type="submit" class="px-3 py-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition font-medium text-sm" title="{% trans 'Deactivate' %}">
<i data-lucide="user-x" class="w-4 h-4"></i>
</button>
</form>
</div>
</td>
</tr>
@ -137,7 +241,139 @@
</div>
</div>
<!-- Create User Modal -->
<div id="createUserModal" class="fixed inset-0 bg-black/50 z-50 hidden items-center justify-center">
<div class="bg-white rounded-2xl w-full max-w-2xl m-4 shadow-2xl">
<div class="bg-gradient-to-r from-navy to-blue p-6 rounded-t-2xl">
<div class="flex justify-between items-center">
<h3 class="text-xl font-bold text-white flex items-center gap-2">
<i data-lucide="user-plus" class="w-6 h-6"></i>
{% trans "Create New User" %}
</h3>
<button onclick="closeCreateModal()" class="text-white/80 hover:text-white transition">
<i data-lucide="x" class="w-6 h-6"></i>
</button>
</div>
</div>
<form method="post" class="p-6 space-y-6">
{% csrf_token %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{% trans "Email" %} <span class="text-red-500">*</span>
</label>
<input type="email" name="email" required
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition"
placeholder="user@example.com">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{% trans "Employee ID" %}
</label>
<input type="text" name="employee_id"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition"
placeholder="EMP001">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{% trans "First Name" %} <span class="text-red-500">*</span>
</label>
<input type="text" name="first_name" required
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition"
placeholder="John">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{% trans "Last Name" %} <span class="text-red-500">*</span>
</label>
<input type="text" name="last_name" required
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition"
placeholder="Doe">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{% trans "Hospital" %}
</label>
<select name="hospital"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition bg-white">
<option value="">{% trans "Select Hospital" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}">{{ hospital.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{% trans "Department" %}
</label>
<select name="department"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-navy focus:ring-2 focus:ring-navy/20 transition bg-white">
<option value="">{% trans "Select Department" %}</option>
{% for department in departments %}
<option value="{{ department.id }}">{{ department.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{% trans "Roles" %} <span class="text-red-500">*</span>
</label>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
{% for role in roles %}
<label class="flex items-center gap-2 p-3 border-2 border-gray-200 rounded-xl cursor-pointer hover:border-navy hover:bg-blue-50 transition">
<input type="checkbox" name="roles" value="{{ role.name }}" class="w-4 h-4 text-navy rounded focus:ring-navy">
<span class="text-sm">{{ role.display_name }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="flex gap-3 pt-4 border-t">
<button type="button" onclick="closeCreateModal()"
class="flex-1 px-6 py-3 border-2 border-gray-300 text-gray-700 rounded-xl font-medium hover:bg-gray-50 transition">
{% trans "Cancel" %}
</button>
<button type="submit"
class="flex-1 px-6 py-3 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-medium hover:shadow-lg transition">
{% trans "Create User" %}
</button>
</div>
</form>
</div>
</div>
<script>
function openCreateModal() {
document.getElementById('createUserModal').classList.remove('hidden');
document.getElementById('createUserModal').classList.add('flex');
lucide.createIcons();
}
function closeCreateModal() {
document.getElementById('createUserModal').classList.add('hidden');
document.getElementById('createUserModal').classList.remove('flex');
}
// Close modal when clicking outside
document.getElementById('createUserModal').addEventListener('click', function(e) {
if (e.target === this) {
closeCreateModal();
}
});
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});

View File

@ -1,134 +1,97 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reminder: Complete Your PX360 Account Setup</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #0d6efd;
}
h1 {
color: #fd7e14;
font-size: 24px;
margin-bottom: 20px;
}
.greeting {
font-size: 18px;
margin-bottom: 20px;
}
.content {
margin-bottom: 30px;
}
.button {
display: inline-block;
background-color: #0d6efd;
color: #ffffff !important;
text-decoration: none;
padding: 14px 30px;
border-radius: 6px;
font-weight: bold;
font-size: 16px;
margin: 20px 0;
}
.button:hover {
background-color: #0b5ed7;
}
.button-container {
text-align: center;
margin: 30px 0;
}
.expiry-notice {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
font-size: 14px;
}
.expiry-notice strong {
color: #721c24;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 12px;
color: #666;
text-align: center;
}
.link-fallback {
font-size: 12px;
color: #666;
word-break: break-all;
margin-top: 15px;
}
.reminder-icon {
font-size: 48px;
text-align: center;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">PX360</div>
<p style="color: #666; margin-top: 5px;">Patient Experience Platform</p>
</div>
<div class="reminder-icon"></div>
<h1>Reminder: Complete Your Account Setup</h1>
<p class="greeting">Hello {{ user.first_name|default:user.email }},</p>
<div class="content">
<p>We noticed that you haven't completed your PX360 account setup yet. Your invitation is still active, and we'd love to have you on board!</p>
{% extends 'emails/base_email_template.html' %}
{% block title %}Reminder: Complete Your PX360 Account Setup - Al Hammadi Hospital{% endblock %}
{% block preheader %}Your PX360 account setup is pending. Please complete before expiry.{% endblock %}
{% block hero_title %}⏰ Reminder: Complete Your Setup{% endblock %}
{% block hero_subtitle %}Your PX360 account invitation is still active{% endblock %}
{% block content %}
<!-- Greeting -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding-bottom: 20px;">
<p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
Hello <strong>{{ user.first_name|default:user.email }}</strong>,
</p>
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
We noticed that you haven't completed your PX360 account setup yet. Your invitation is still active, and we'd love to have you on board!
</p>
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
Click the button below to continue where you left off:
</p>
</td>
</tr>
</table>
<!-- Quick Benefits -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
<tr>
<td>
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696;">
Why Complete Your Setup?
</h3>
</td>
</tr>
<tr>
<td>
<!-- Benefit 1 -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
<tr>
<td width="40" valign="top" style="padding-right: 10px;">
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;"></span>
</td>
<td>
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
<strong>Access Your Dashboard:</strong> Manage your tasks and responsibilities
</p>
</td>
</tr>
</table>
<p>Click the button below to continue where you left off:</p>
<!-- Benefit 2 -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
<tr>
<td width="40" valign="top" style="padding-right: 10px;">
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">📊</span>
</td>
<td>
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
<strong>View Analytics:</strong> Track patient experience metrics
</p>
</td>
</tr>
</table>
<div class="button-container">
<a href="{{ activation_url }}" class="button">Complete Account Setup</a>
</div>
</div>
<div class="expiry-notice">
<strong>⚠️ Time Sensitive:</strong> Your invitation link will expire on <strong>{{ expires_at|date:"F j, Y, g:i a" }}</strong>. Please complete your registration before this date to avoid requesting a new invitation.
</div>
<p class="link-fallback">
If the button above doesn't work, copy and paste this link into your browser:<br>
<a href="{{ activation_url }}">{{ activation_url }}</a>
</p>
<div class="footer">
<p>This is an automated reminder from PX360. Please do not reply to this email.</p>
<p>If you have already completed your registration, please disregard this message.</p>
<p>If you have questions, please contact your system administrator.</p>
<p>&copy; {{ "now"|date:"Y" }} PX360 - Patient Experience Platform</p>
</div>
</div>
</body>
</html>
<!-- Benefit 3 -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td width="40" valign="top" style="padding-right: 10px;">
<span style="display: inline-block; width: 32px; height: 32px; background-color: #eef6fb; border-radius: 50%; text-align: center; line-height: 32px; font-size: 18px;">🎯</span>
</td>
<td>
<p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
<strong>Take Action:</strong> Respond to complaints and feedback
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
{% endblock %}
{% block cta_url %}{{ activation_url }}{% endblock %}
{% block cta_text %}Complete Account Setup{% endblock %}
{% block info_title %}⚠️ Time Sensitive: Invitation Expiry{% endblock %}
{% block info_content %}
Your invitation link will expire on <strong>{{ expires_at|date:"F j, Y, g:i a" }}</strong>. Please complete your registration before this date to avoid requesting a new invitation.
{% endblock %}
{% block footer_address %}
PX360 - Patient Experience Platform<br>
Al Hammadi Hospital
{% endblock %}

View File

@ -45,6 +45,12 @@
{% trans "Hospital Notifications" %}
</button>
{% endif %}
{% if user.is_px_admin %}
<button class="settings-tab px-6 py-3 rounded-xl font-medium text-sm transition flex items-center gap-2" data-tab="users">
<i data-lucide="users" class="w-4 h-4"></i>
{% trans "Users" %}
</button>
{% endif %}
</div>
</div>
@ -715,6 +721,150 @@
</div>
</div>
{% endif %}
<!-- Users Management Tab (PX Admin only) -->
{% if user.is_px_admin %}
<div class="settings-content hidden" id="users-content">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2">
<h5 class="text-xl font-bold text-gray-800 mb-6 flex items-center gap-2">
<i data-lucide="users" class="w-5 h-5 text-navy"></i>
{% trans "User Management" %}
</h5>
<!-- Quick Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-gradient-to-br from-navy to-blue text-white p-4 rounded-xl text-center">
<i data-lucide="users" class="w-8 h-8 mx-auto mb-2"></i>
<h6 class="font-bold text-2xl mb-0">{{ total_users_count|default:0 }}</h6>
<span class="text-xs text-white/80">{% trans "Total Users" %}</span>
</div>
<div class="bg-gradient-to-br from-emerald-500 to-emerald-600 text-white p-4 rounded-xl text-center">
<i data-lucide="user-check" class="w-8 h-8 mx-auto mb-2"></i>
<h6 class="font-bold text-2xl mb-0">{{ active_users_count|default:0 }}</h6>
<span class="text-xs text-white/80">{% trans "Active Users" %}</span>
</div>
<div class="bg-gradient-to-br from-orange-500 to-orange-600 text-white p-4 rounded-xl text-center">
<i data-lucide="user-plus" class="w-8 h-8 mx-auto mb-2"></i>
<h6 class="font-bold text-2xl mb-0">{{ provisional_users_count|default:0 }}</h6>
<span class="text-xs text-white/80">{% trans "Pending" %}</span>
</div>
<div class="bg-gradient-to-br from-red-500 to-red-600 text-white p-4 rounded-xl text-center">
<i data-lucide="user-x" class="w-8 h-8 mx-auto mb-2"></i>
<h6 class="font-bold text-2xl mb-0">{{ inactive_users_count|default:0 }}</h6>
<span class="text-xs text-white/80">{% trans "Inactive" %}</span>
</div>
</div>
<!-- Action Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<a href="{% url 'accounts:provisional-user-list' %}" class="block bg-white border-2 border-blue-200 rounded-xl p-4 hover:border-blue-400 hover:shadow-md transition group">
<div class="flex items-center gap-3 mb-2">
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center group-hover:bg-blue-200 transition">
<i data-lucide="list" class="w-6 h-6 text-blue-600"></i>
</div>
<div>
<h6 class="font-bold text-gray-800">{% trans "Provisional Users" %}</h6>
<p class="text-xs text-gray-500">{% trans "Manage pending accounts" %}</p>
</div>
</div>
<p class="text-sm text-gray-600">{% trans "View and manage pending user accounts awaiting activation. Create new users and track their onboarding progress." %}</p>
</a>
<a href="{% url 'accounts:bulk-invite-users' %}" class="block bg-white border-2 border-emerald-200 rounded-xl p-4 hover:border-emerald-400 hover:shadow-md transition group">
<div class="flex items-center gap-3 mb-2">
<div class="w-12 h-12 bg-emerald-100 rounded-xl flex items-center justify-center group-hover:bg-emerald-200 transition">
<i data-lucide="upload" class="w-6 h-6 text-emerald-600"></i>
</div>
<div>
<h6 class="font-bold text-gray-800">{% trans "Bulk Invite" %}</h6>
<p class="text-xs text-gray-500">{% trans "Import via CSV" %}</p>
</div>
</div>
<p class="text-sm text-gray-600">{% trans "Import multiple users at once using a CSV file. Great for onboarding entire departments." %}</p>
</a>
<a href="{% url 'accounts:export-provisional-users' %}" class="block bg-white border-2 border-amber-200 rounded-xl p-4 hover:border-amber-400 hover:shadow-md transition group">
<div class="flex items-center gap-3 mb-2">
<div class="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center group-hover:bg-amber-200 transition">
<i data-lucide="download" class="w-6 h-6 text-amber-600"></i>
</div>
<div>
<h6 class="font-bold text-gray-800">{% trans "Export Users" %}</h6>
<p class="text-xs text-gray-500">{% trans "Download CSV report" %}</p>
</div>
</div>
<p class="text-sm text-gray-600">{% trans "Download user data as CSV for reporting and compliance purposes." %}</p>
</a>
<a href="{% url 'accounts:provisional-user-list' %}" class="block bg-white border-2 border-purple-200 rounded-xl p-4 hover:border-purple-400 hover:shadow-md transition group">
<div class="flex items-center gap-3 mb-2">
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center group-hover:bg-purple-200 transition">
<i data-lucide="bar-chart-2" class="w-6 h-6 text-purple-600"></i>
</div>
<div>
<h6 class="font-bold text-gray-800">{% trans "Onboarding Dashboard" %}</h6>
<p class="text-xs text-gray-500">{% trans "View analytics" %}</p>
</div>
</div>
<p class="text-sm text-gray-600">{% trans "Access the full onboarding dashboard with detailed statistics and user progress tracking." %}</p>
</a>
</div>
</div>
<div>
<div class="bg-light rounded-2xl p-6">
<h6 class="font-bold text-gray-800 mb-4 flex items-center gap-2">
<i data-lucide="lightbulb" class="w-5 h-5 text-navy"></i>
{% trans "User Management Tips" %}
</h6>
<div class="space-y-4 text-sm text-gray-600">
<div class="flex gap-3">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
<span class="font-bold text-blue-600">1</span>
</div>
<div>
<p class="font-medium text-gray-800">{% trans "Bulk Invite for Efficiency" %}</p>
<p class="text-xs mt-1">{% trans "Use CSV upload when adding multiple users to save time and ensure consistency." %}</p>
</div>
</div>
<div class="flex gap-3">
<div class="w-8 h-8 bg-emerald-100 rounded-lg flex items-center justify-center flex-shrink-0">
<span class="font-bold text-emerald-600">2</span>
</div>
<div>
<p class="font-medium text-gray-800">{% trans "Resend Expired Invitations" %}>
<p class="text-xs mt-1">{% trans "Monitor pending users and resend invitations to those who haven't activated their accounts." %}</p>
</div>
</div>
<div class="flex gap-3">
<div class="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center flex-shrink-0">
<span class="font-bold text-orange-600">3</span>
</div>
<div>
<p class="font-medium text-gray-800">{% trans "Review Inactive Accounts" %}</p>
<p class="text-xs mt-1">{% trans "Regularly audit and deactivate inactive accounts to maintain security and compliance." %}</p>
</div>
</div>
<div class="flex gap-3">
<div class="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center flex-shrink-0">
<span class="font-bold text-purple-600">4</span>
</div>
<div>
<p class="font-medium text-gray-800">{% trans "Export for Compliance" %}</p>
<p class="text-xs mt-1">{% trans "Export user data regularly for audit trails and compliance reporting requirements." %}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>

View File

@ -3,65 +3,158 @@
{% block title %}{% if action == 'create' %}{% trans "New Acknowledgement" %}{% else %}{% trans "Edit Acknowledgement" %}{% endif %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.form-section {
background: #fff;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
}
.form-section:hover {
border-color: #005696;
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
}
.form-control, .form-select {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
outline: none;
border-color: #005696;
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #005696;
color: white;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
border: none;
cursor: pointer;
}
.btn-primary:hover {
background: #007bbd;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: white;
color: #64748b;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #005696;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<!-- Header -->
<div class="flex items-center gap-3 mb-8">
<div class="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue to-navy rounded-2xl shadow-lg shadow-blue-200">
<i data-lucide="{% if action == 'create' %}plus{% else %}edit{% endif %}" class="w-6 h-6 text-white"></i>
</div>
<div class="p-6 md:p-8 bg-gradient-to-br from-slate-50 to-blue-50 min-h-screen">
<!-- Page Header -->
<div class="page-header-gradient flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 class="text-2xl font-bold text-navy">
<nav aria-label="breadcrumb" class="mb-2">
<ol class="flex items-center gap-2 text-sm text-white/80">
<li><a href="{% url 'accounts:simple_acknowledgements:admin_list' %}" class="hover:text-white transition">{% trans "Acknowledgements" %}</a></li>
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li class="text-white">{% if action == 'create' %}{% trans "Create" %}{% else %}{% trans "Edit" %}{% endif %}</li>
</ol>
</nav>
<h1 class="text-2xl font-bold flex items-center gap-2">
<i data-lucide="{% if action == 'create' %}plus{% else %}edit{% endif %}" class="w-8 h-8"></i>
{% if action == 'create' %}{% trans "New Acknowledgement" %}{% else %}{% trans "Edit Acknowledgement" %}{% endif %}
</h1>
<p class="text-slate text-sm">
{% if action == 'create' %}{% trans "Create a new acknowledgement for employees" %}{% else %}{% trans "Update acknowledgement details" %}{% endif %}
</p>
</div>
<a href="{% url 'accounts:simple_acknowledgements:admin_list' %}" class="inline-flex items-center gap-2 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back to List" %}
</a>
</div>
<!-- Form Section -->
<div class="max-w-3xl">
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
<form method="post" enctype="multipart/form-data" class="p-6 space-y-6">
<div class="form-section">
<div class="flex items-center gap-2 mb-4 pb-3 border-b border-slate-100">
<i data-lucide="file-text" class="w-5 h-5 text-blue-600"></i>
<h2 class="text-lg font-bold text-slate-800">{% trans "Acknowledgement Details" %}</h2>
</div>
<form method="post" enctype="multipart/form-data" class="space-y-6">
{% csrf_token %}
<!-- Title -->
<div>
<label class="block text-sm font-bold text-navy mb-2">
<label class="form-label">
{% trans "Title" %} <span class="text-red-500">*</span>
</label>
<input type="text" name="title" required
value="{{ acknowledgement.title|default:'' }}"
class="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue focus:border-transparent transition"
class="form-control"
placeholder="{% trans 'Enter acknowledgement title' %}">
</div>
<!-- Description -->
<div>
<label class="block text-sm font-bold text-navy mb-2">
<label class="form-label">
{% trans "Description" %}
</label>
<textarea name="description" rows="5"
class="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue focus:border-transparent transition"
class="form-control"
placeholder="{% trans 'Enter detailed description of the acknowledgement' %}">{{ acknowledgement.description|default:'' }}</textarea>
</div>
<!-- PDF Document -->
<div>
<label class="block text-sm font-bold text-navy mb-2">
<label class="form-label">
{% trans "PDF Document" %}
</label>
<div class="border-2 border-dashed border-slate-300 rounded-xl p-6 text-center hover:border-blue transition">
<div class="border-2 border-dashed border-slate-300 rounded-xl p-6 text-center hover:border-blue-500 transition">
<input type="file" name="pdf_document" accept=".pdf"
class="w-full"
onchange="updateFileName(this)">
{% if acknowledgement.pdf_document %}
<p class="text-sm text-slate mt-2">
{% trans "Current:" %} <a href="{{ acknowledgement.pdf_document.url }}" target="_blank" class="text-blue hover:underline">{{ acknowledgement.pdf_document.name }}</a>
<p class="text-sm text-slate-600 mt-2">
{% trans "Current:" %} <a href="{{ acknowledgement.pdf_document.url }}" target="_blank" class="text-blue-600 hover:underline">{{ acknowledgement.pdf_document.name }}</a>
</p>
{% endif %}
</div>
<p class="text-xs text-slate mt-2">{% trans "Upload a PDF document for employees to review (optional)" %}</p>
<p class="text-xs text-slate-500 mt-2">{% trans "Upload a PDF document for employees to review (optional)" %}</p>
</div>
<!-- Settings -->
@ -70,10 +163,10 @@
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="is_active"
{% if acknowledgement.is_active|default:True %}checked{% endif %}
class="w-5 h-5 text-blue rounded focus:ring-blue">
class="w-5 h-5 text-blue-600 rounded focus:ring-blue-500">
<div>
<span class="font-semibold text-navy">{% trans "Active" %}</span>
<p class="text-xs text-slate">{% trans "Show in employee checklist" %}</p>
<span class="font-semibold text-slate-800">{% trans "Active" %}</span>
<p class="text-xs text-slate-500">{% trans "Show in employee checklist" %}</p>
</div>
</label>
</div>
@ -81,10 +174,10 @@
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="is_required"
{% if acknowledgement.is_required|default:True %}checked{% endif %}
class="w-5 h-5 text-blue rounded focus:ring-blue">
class="w-5 h-5 text-blue-600 rounded focus:ring-blue-500">
<div>
<span class="font-semibold text-navy">{% trans "Required" %}</span>
<p class="text-xs text-slate">{% trans "Must be signed by all employees" %}</p>
<span class="font-semibold text-slate-800">{% trans "Required" %}</span>
<p class="text-xs text-slate-500">{% trans "Must be signed by all employees" %}</p>
</div>
</label>
</div>
@ -92,24 +185,23 @@
<!-- Order -->
<div>
<label class="block text-sm font-bold text-navy mb-2">
<label class="form-label">
{% trans "Display Order" %}
</label>
<input type="number" name="order"
value="{{ acknowledgement.order|default:0 }}"
class="w-32 px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue focus:border-transparent transition">
<p class="text-xs text-slate mt-2">{% trans "Lower numbers appear first" %}</p>
class="form-control w-32">
<p class="text-xs text-slate-500 mt-2">{% trans "Lower numbers appear first" %}</p>
</div>
<!-- Actions -->
<div class="flex gap-3 pt-4 border-t border-slate-100">
<div class="flex justify-end gap-3 pt-6 border-t border-slate-100">
<a href="{% url 'accounts:simple_acknowledgements:admin_list' %}"
class="inline-flex items-center justify-center gap-2 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition">
class="btn-secondary">
{% trans "Cancel" %}
</a>
<button type="submit"
class="inline-flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:shadow-lg transition">
<i data-lucide="save" class="w-5 h-5"></i>
<button type="submit" class="btn-primary">
<i data-lucide="save" class="w-4 h-4"></i>
{% if action == 'create' %}{% trans "Create Acknowledgement" %}{% else %}{% trans "Save Changes" %}{% endif %}
</button>
</div>

View File

@ -3,39 +3,85 @@
{% block title %}{% trans "Manage Acknowledgements" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<!-- Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue to-navy rounded-2xl shadow-lg shadow-blue-200">
<i data-lucide="clipboard-list" class="w-6 h-6 text-white"></i>
<!-- Page Header -->
<div class="page-header-gradient">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-3">
<div class="section-icon bg-white/20">
<i data-lucide="clipboard-list" class="w-6 h-6 text-white"></i>
</div>
<div>
<h1 class="text-2xl font-bold">{% trans "Acknowledgements" %}</h1>
<p class="text-white/80 text-sm">{% trans "Manage employee acknowledgement forms" %}</p>
</div>
</div>
<div>
<h1 class="text-2xl font-bold text-navy">{% trans "Acknowledgements" %}</h1>
<p class="text-slate text-sm">{% trans "Manage employee acknowledgement forms" %}</p>
<div class="flex gap-3">
<a href="{% url 'accounts:simple_acknowledgements:admin_signatures' %}"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-white/20 border border-white/30 text-white rounded-xl font-semibold hover:bg-white/30 transition">
<i data-lucide="users" class="w-4 h-4"></i>
{% trans "View Signatures" %}
</a>
<a href="{% url 'accounts:simple_acknowledgements:admin_create' %}"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-white text-emerald-700 rounded-xl font-semibold hover:bg-emerald-50 transition shadow-lg">
<i data-lucide="plus" class="w-4 h-4"></i>
{% trans "New Acknowledgement" %}
</a>
</div>
</div>
<div class="flex gap-3">
<a href="{% url 'accounts:simple_acknowledgements:admin_signatures' %}"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
<i data-lucide="users" class="w-4 h-4"></i>
{% trans "View Signatures" %}
</a>
<a href="{% url 'accounts:simple_acknowledgements:admin_create' %}"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-emerald-500 to-emerald-600 text-white rounded-xl font-semibold hover:shadow-lg transition">
<i data-lucide="plus" class="w-4 h-4"></i>
{% trans "New Acknowledgement" %}
</a>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-5">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="section-card p-5">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-blue-100 rounded-xl flex items-center justify-center">
<i data-lucide="clipboard" class="w-5 h-5 text-blue"></i>
<div class="section-icon bg-blue-100">
<i data-lucide="clipboard" class="w-5 h-5 text-blue-600"></i>
</div>
<div>
<p class="text-xs text-slate uppercase font-semibold">{% trans "Total" %}</p>
@ -43,9 +89,9 @@
</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-emerald-100 p-5">
<div class="section-card p-5 border-emerald-200">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-emerald-100 rounded-xl flex items-center justify-center">
<div class="section-icon bg-emerald-100">
<i data-lucide="check-circle" class="w-5 h-5 text-emerald-600"></i>
</div>
<div>
@ -57,9 +103,12 @@
</div>
<!-- Acknowledgements Table -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50">
<h2 class="font-bold text-navy">{% trans "All Acknowledgements" %}</h2>
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-blue-100">
<i data-lucide="list" class="w-5 h-5 text-blue-600"></i>
</div>
<h2 class="text-lg font-bold text-navy">{% trans "All Acknowledgements" %}</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">

View File

@ -3,84 +3,134 @@
{% block title %}{% trans "My Acknowledgements" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue to-indigo-500 rounded-2xl shadow-lg shadow-blue-200">
<i data-lucide="clipboard-signature" class="w-8 h-8 text-white"></i>
<div class="page-header-gradient">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-3">
<div class="section-icon bg-white/20">
<i data-lucide="clipboard-signature" class="w-6 h-6 text-white"></i>
</div>
<div>
<h1 class="text-2xl font-bold">{% trans "My Acknowledgements" %}</h1>
<p class="text-white/80 text-sm">{% trans "Review and sign required documents" %}</p>
</div>
</div>
<div>
<h1 class="text-2xl font-bold text-navy">{% trans "My Acknowledgements" %}</h1>
<p class="text-slate text-sm">{% trans "Review and sign required documents" %}</p>
<div class="flex gap-3">
{% if user.is_px_admin or user.is_staff %}
<a href="{% url 'accounts:simple_acknowledgements:admin_create' %}"
class="inline-flex items-center gap-2 px-4 py-2 bg-white text-emerald-700 rounded-xl font-semibold hover:bg-emerald-50 transition shadow-lg">
<i data-lucide="plus-circle" class="w-4 h-4"></i>
{% trans "Create New" %}
</a>
<a href="{% url 'accounts:simple_acknowledgements:admin_list' %}"
class="inline-flex items-center gap-2 px-4 py-2 bg-white/20 border border-white/30 text-white rounded-xl font-semibold hover:bg-white/30 transition">
<i data-lucide="settings" class="w-4 h-4"></i>
{% trans "Manage" %}
</a>
{% endif %}
<div class="flex items-center gap-2 px-4 py-2 bg-emerald-500 text-white rounded-xl font-semibold">
<i data-lucide="check-circle" class="w-4 h-4"></i>
{{ signed }} {% trans "Signed" %}
</div>
{% if pending > 0 %}
<div class="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-xl font-semibold animate-pulse">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ pending }} {% trans "Pending" %}
</div>
{% endif %}
</div>
</div>
<div class="flex gap-3">
{% if user.is_px_admin or user.is_staff %}
<a href="{% url 'accounts:simple_acknowledgements:admin_create' %}"
class="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-emerald-500 to-emerald-600 text-white rounded-xl font-semibold hover:from-emerald-600 hover:to-emerald-700 transition shadow-lg shadow-emerald-200">
<i data-lucide="plus-circle" class="w-4 h-4"></i>
{% trans "Create New" %}
</a>
<a href="{% url 'accounts:simple_acknowledgements:admin_list' %}"
class="inline-flex items-center gap-2 px-4 py-2 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
<i data-lucide="settings" class="w-4 h-4"></i>
{% trans "Manage" %}
</a>
{% endif %}
<div class="flex items-center gap-2 px-4 py-2 bg-emerald-100 text-emerald-700 rounded-xl font-semibold">
<i data-lucide="check-circle" class="w-4 h-4"></i>
{{ signed }} {% trans "Signed" %}
</div>
{% if pending > 0 %}
<div class="flex items-center gap-2 px-4 py-2 bg-orange-100 text-orange-700 rounded-xl font-semibold animate-pulse">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ pending }} {% trans "Pending" %}
</div>
{% endif %}
</div>
</div>
{% if pending > 0 %}
<!-- Alert Banner -->
<div class="bg-gradient-to-r from-orange-500 to-orange-600 rounded-2xl shadow-lg shadow-orange-200 p-6 mb-8 text-white">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<i data-lucide="bell" class="w-6 h-6"></i>
</div>
<div class="flex-1">
<h2 class="text-lg font-bold">{% trans "Action Required" %}</h2>
<p class="text-orange-100">
{% blocktrans %}
You have {{ pending }} acknowledgement(s) waiting for your signature. Please review and sign them below.
{% endblocktrans %}
</p>
<div class="section-card mb-6 border-orange-300 bg-gradient-to-r from-orange-500 to-orange-600">
<div class="p-6 text-white">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<i data-lucide="bell" class="w-6 h-6"></i>
</div>
<div class="flex-1">
<h2 class="text-lg font-bold">{% trans "Action Required" %}</h2>
<p class="text-orange-100">
{% blocktrans %}
You have {{ pending }} acknowledgement(s) waiting for your signature. Please review and sign them below.
{% endblocktrans %}
</p>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Progress Card -->
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 p-6 mb-6">
<div class="flex justify-between items-center mb-3">
<span class="font-semibold text-navy">{% trans "Completion Progress" %}</span>
<span class="text-2xl font-bold text-blue">{{ progress }}%</span>
</div>
<div class="w-full bg-slate-200 rounded-full h-3 overflow-hidden">
<div class="bg-gradient-to-r from-emerald-400 to-emerald-500 h-3 rounded-full transition-all duration-500" style="width: {{ progress }}%"></div>
<div class="section-card mb-6">
<div class="p-6">
<div class="flex justify-between items-center mb-3">
<span class="font-semibold text-navy">{% trans "Completion Progress" %}</span>
<span class="text-2xl font-bold text-blue">{{ progress }}%</span>
</div>
<div class="w-full bg-slate-200 rounded-full h-3 overflow-hidden">
<div class="bg-gradient-to-r from-emerald-400 to-emerald-500 h-3 rounded-full transition-all duration-500" style="width: {{ progress }}%"></div>
</div>
</div>
</div>
<!-- Pending Acknowledgements -->
{% if pending_acks %}
<div class="bg-white rounded-2xl shadow-sm border border-orange-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-orange-100 bg-gradient-to-r from-orange-50 to-transparent">
<h5 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="alert-circle" class="w-5 h-5 text-orange-500"></i>
{% trans "Pending Signatures" %}
<span class="bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full text-sm">{{ pending_acks|length }}</span>
</h5>
<div class="section-card mb-6 border-orange-200">
<div class="section-header border-orange-200 bg-gradient-to-r from-orange-50 to-transparent">
<div class="section-icon bg-orange-100">
<i data-lucide="alert-circle" class="w-5 h-5 text-orange-600"></i>
</div>
<h2 class="text-lg font-bold text-navy">{% trans "Pending Signatures" %}</h2>
<span class="bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full text-sm">{{ pending_acks|length }}</span>
</div>
<div class="divide-y divide-orange-50">
{% for item in pending_acks %}
@ -134,13 +184,13 @@
<!-- Completed Acknowledgements -->
{% if signed_acks %}
<div class="bg-white rounded-2xl shadow-sm border border-emerald-200 overflow-hidden">
<div class="px-6 py-4 border-b border-emerald-100 bg-gradient-to-r from-emerald-50 to-transparent">
<h5 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="check-circle" class="w-5 h-5 text-emerald-500"></i>
{% trans "Completed" %}
<span class="bg-emerald-100 text-emerald-700 px-2 py-0.5 rounded-full text-sm">{{ signed_acks|length }}</span>
</h5>
<div class="section-card border-emerald-200">
<div class="section-header border-emerald-200 bg-gradient-to-r from-emerald-50 to-transparent">
<div class="section-icon bg-emerald-100">
<i data-lucide="check-circle" class="w-5 h-5 text-emerald-600"></i>
</div>
<h2 class="text-lg font-bold text-navy">{% trans "Completed" %}</h2>
<span class="bg-emerald-100 text-emerald-700 px-2 py-0.5 rounded-full text-sm">{{ signed_acks|length }}</span>
</div>
<div class="divide-y divide-emerald-50">
{% for item in signed_acks %}
@ -181,12 +231,14 @@
{% endif %}
{% if not pending_acks and not signed_acks %}
<div class="text-center py-12">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="clipboard-check" class="w-8 h-8 text-slate-400"></i>
<div class="section-card">
<div class="text-center py-12">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="clipboard-check" class="w-8 h-8 text-slate-400"></i>
</div>
<p class="text-slate font-medium">{% trans "No acknowledgements available at this time." %}</p>
<p class="text-sm text-slate mt-2">{% trans "You will be notified when new acknowledgements are assigned to you." %}</p>
</div>
<p class="text-slate font-medium">{% trans "No acknowledgements available at this time." %}</p>
<p class="text-sm text-slate mt-2">{% trans "You will be notified when new acknowledgements are assigned to you." %}</p>
</div>
{% endif %}
</div>

View File

@ -3,157 +3,235 @@
{% block title %}{% trans "Action Plans" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.section-icon-blue {
background: linear-gradient(135deg, #005696, #007bbd);
color: white;
}
.section-icon-orange {
background: linear-gradient(135deg, #f97316, #fb923c);
color: white;
}
.section-icon-gray {
background: linear-gradient(135deg, #64748b, #94a3b8);
color: white;
}
</style>
{% endblock %}
{% block content %}
<div class="mb-8">
<!-- Page Header -->
<div class="page-header-gradient">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-navy mb-2 flex items-center gap-3">
<i data-lucide="check-circle-2" class="w-8 h-8 text-orange-500"></i>
<h1 class="text-3xl font-bold mb-2 flex items-center gap-3">
<i data-lucide="check-circle-2" class="w-8 h-8"></i>
{% trans "Action Plans" %}
</h1>
<p class="text-slate">{% trans "Manage improvement action plans" %}</p>
<p class="opacity-90">{% trans "Manage improvement action plans" %}</p>
</div>
<a href="{% url 'actions:action_create' %}" class="bg-navy text-white px-6 py-3 rounded-2xl font-bold flex items-center gap-2 shadow-lg shadow-blue-200 hover:bg-blue transition">
<a href="{% url 'actions:action_create' %}" class="bg-white text-[#005696] px-6 py-3 rounded-xl font-bold flex items-center gap-2 shadow-lg hover:bg-gray-50 transition">
<i data-lucide="plus" class="w-5 h-5"></i> {% trans "Create Action Plan" %}
</a>
</div>
</div>
<!-- Filters -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 mb-6">
<div class="px-6 py-4 border-b-2 border-slate-200 flex justify-between items-center bg-gradient-to-r from-slate-50 to-slate-100">
<h3 class="font-bold text-gray-800 flex items-center gap-2">
<i data-lucide="filter" class="w-5 h-5 text-navy"></i>
{% trans "Filters" %}
</h3>
<!-- Filters Section -->
<div class="section-card mb-6">
<div class="section-header">
<div class="section-icon section-icon-blue">
<i data-lucide="filter" class="w-5 h-5"></i>
</div>
<h3 class="font-bold text-gray-800">{% trans "Filters" %}</h3>
</div>
<div class="p-6">
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="md:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Search" %}</label>
<input type="text" class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition" name="search" placeholder="{% trans 'Search action plans...' %}" value="{{ filters.search }}">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Status" %}</label>
<select class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition" name="status">
<option value="">{% trans "All Status" %}</option>
<option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>{% trans "Pending" %}</option>
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
<option value="completed" {% if filters.status == 'completed' %}selected{% endif %}>{% trans "Completed" %}</option>
<option value="cancelled" {% if filters.status == 'cancelled' %}selected{% endif %}>{% trans "Cancelled" %}</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Priority" %}</label>
<select class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition" name="priority">
<option value="">{% trans "All Priorities" %}</option>
<option value="high" {% if filters.priority == 'high' %}selected{% endif %}>{% trans "High" %}</option>
<option value="medium" {% if filters.priority == 'medium' %}selected{% endif %}>{% trans "Medium" %}</option>
<option value="low" {% if filters.priority == 'low' %}selected{% endif %}>{% trans "Low" %}</option>
</select>
</div>
<div class="md:col-span-2 flex items-end">
<div class="flex gap-2 w-full">
<button type="submit" class="flex-1 bg-navy text-white px-4 py-3 rounded-xl font-semibold hover:bg-blue transition flex items-center justify-center gap-2">
<i data-lucide="search" class="w-4 h-4"></i> {% trans "Filter" %}
</button>
<a href="{% url 'actions:action_list' %}" class="bg-gray-100 text-gray-600 px-4 py-3 rounded-xl font-semibold hover:bg-gray-200 transition flex items-center justify-center gap-2">
<i data-lucide="x-circle" class="w-4 h-4"></i>
</a>
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="md:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Search" %}</label>
<input type="text" class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition" name="search" placeholder="{% trans 'Search action plans...' %}" value="{{ filters.search }}">
</div>
</div>
</form>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Status" %}</label>
<select class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition" name="status">
<option value="">{% trans "All Status" %}</option>
<option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>{% trans "Pending" %}</option>
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
<option value="completed" {% if filters.status == 'completed' %}selected{% endif %}>{% trans "Completed" %}</option>
<option value="cancelled" {% if filters.status == 'cancelled' %}selected{% endif %}>{% trans "Cancelled" %}</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Priority" %}</label>
<select class="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition" name="priority">
<option value="">{% trans "All Priorities" %}</option>
<option value="high" {% if filters.priority == 'high' %}selected{% endif %}>{% trans "High" %}</option>
<option value="medium" {% if filters.priority == 'medium' %}selected{% endif %}>{% trans "Medium" %}</option>
<option value="low" {% if filters.priority == 'low' %}selected{% endif %}>{% trans "Low" %}</option>
</select>
</div>
<div class="md:col-span-2 flex items-end">
<div class="flex gap-2 w-full">
<button type="submit" class="flex-1 bg-[#005696] text-white px-4 py-3 rounded-xl font-semibold hover:bg-[#0069a8] transition flex items-center justify-center gap-2">
<i data-lucide="search" class="w-4 h-4"></i> {% trans "Filter" %}
</button>
<a href="{% url 'actions:action_list' %}" class="bg-gray-100 text-gray-600 px-4 py-3 rounded-xl font-semibold hover:bg-gray-200 transition flex items-center justify-center gap-2">
<i data-lucide="x-circle" class="w-4 h-4"></i>
</a>
</div>
</div>
</form>
</div>
</div>
<!-- Action Plans Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for action in actions %}
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden hover:shadow-md transition">
<div class="p-6">
<div class="flex justify-between items-start mb-4">
<div class="flex gap-2">
{% if action.priority == 'high' %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-red-100 text-red-600">{% trans "High" %}</span>
{% elif action.priority == 'medium' %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-orange-100 text-orange-600">{% trans "Medium" %}</span>
{% else %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-green-100 text-green-600">{% trans "Low" %}</span>
{% endif %}
<!-- Action Plans Section -->
<div class="section-card">
<div class="section-header">
<div class="section-icon section-icon-orange">
<i data-lucide="check-square" class="w-5 h-5"></i>
</div>
<h3 class="font-bold text-gray-800">{% trans "Action Plans" %}</h3>
<span class="ml-auto bg-gray-100 text-gray-600 px-3 py-1 rounded-full text-sm font-semibold">
{{ page_obj.paginator.count|default:actions|length }} {% trans "Total" %}
</span>
</div>
<div class="p-6">
<!-- Action Plans Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for action in actions %}
<div class="bg-white rounded-2xl shadow-sm border-2 border-gray-100 overflow-hidden hover:shadow-md hover:border-[#005696]/30 transition-all duration-300">
<div class="p-6">
<div class="flex justify-between items-start mb-4">
<div class="flex gap-2">
{% if action.priority == 'high' %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-red-100 text-red-600">{% trans "High" %}</span>
{% elif action.priority == 'medium' %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-orange-100 text-orange-600">{% trans "Medium" %}</span>
{% else %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-green-100 text-green-600">{% trans "Low" %}</span>
{% endif %}
{% if action.status == 'completed' %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-green-100 text-green-600">{% trans "Completed" %}</span>
{% elif action.status == 'in_progress' %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-blue-100 text-blue-600">{% trans "In Progress" %}</span>
{% elif action.status == 'cancelled' %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-gray-100 text-gray-600">{% trans "Cancelled" %}</span>
{% else %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-yellow-100 text-yellow-600">{% trans "Pending" %}</span>
{% endif %}
</div>
<span class="text-xs text-gray-400">{{ action.created_at|date:"M d, Y" }}</span>
</div>
{% if action.status == 'completed' %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-green-100 text-green-600">{% trans "Completed" %}</span>
{% elif action.status == 'in_progress' %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-blue-100 text-blue-600">{% trans "In Progress" %}</span>
{% elif action.status == 'cancelled' %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-gray-100 text-gray-600">{% trans "Cancelled" %}</span>
{% else %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-yellow-100 text-yellow-600">{% trans "Pending" %}</span>
{% endif %}
<h3 class="font-bold text-gray-800 mb-2">{{ action.title }}</h3>
<p class="text-gray-500 text-sm mb-4 line-clamp-2">{{ action.description|truncatewords:20 }}</p>
<div class="space-y-2 text-sm">
{% if action.due_date %}
<div class="flex items-center gap-2 text-gray-600">
<i data-lucide="calendar" class="w-4 h-4 text-[#005696]"></i>
<span>{% trans "Due:" %} {{ action.due_date|date:"M d, Y" }}</span>
</div>
{% endif %}
{% if action.assigned_to %}
<div class="flex items-center gap-2 text-gray-600">
<i data-lucide="user" class="w-4 h-4 text-[#005696]"></i>
<span>{{ action.assigned_to.get_full_name|default:action.assigned_to.username }}</span>
</div>
{% endif %}
</div>
</div>
<div class="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
<a href="{% url 'actions:action_detail' action.id %}" class="text-[#005696] text-sm font-bold hover:underline flex items-center gap-1">
{% trans "View Details" %} <i data-lucide="arrow-right" class="w-4 h-4"></i>
</a>
</div>
<span class="text-xs text-gray-400">{{ action.created_at|date:"M d, Y" }}</span>
</div>
<h3 class="font-bold text-gray-800 mb-2">{{ action.title }}</h3>
<p class="text-gray-500 text-sm mb-4 line-clamp-2">{{ action.description|truncatewords:20 }}</p>
<div class="space-y-2 text-sm">
{% if action.due_date %}
<div class="flex items-center gap-2 text-gray-600">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span>{% trans "Due:" %} {{ action.due_date|date:"M d, Y" }}</span>
{% empty %}
<div class="col-span-full">
<div class="bg-gray-50 rounded-2xl border-2 border-dashed border-gray-200 p-12 text-center">
<div class="section-icon section-icon-gray mx-auto mb-4" style="width: 64px; height: 64px;">
<i data-lucide="check-square" class="w-8 h-8"></i>
</div>
<h3 class="text-xl font-bold text-gray-800 mb-2">{% trans "No Action Plans Found" %}</h3>
<p class="text-gray-500 mb-6">{% trans "Get started by creating your first action plan" %}</p>
<a href="{% url 'actions:action_create' %}" class="inline-flex items-center gap-2 bg-[#005696] text-white px-6 py-3 rounded-xl font-bold shadow-lg hover:bg-[#0069a8] transition">
<i data-lucide="plus" class="w-5 h-5"></i> {% trans "Create Action Plan" %}
</a>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<div class="mt-6 flex justify-center">
<div class="flex gap-2">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}&{{ filters|urlencode }}" class="px-4 py-2 border border-gray-200 rounded-xl text-sm font-semibold hover:bg-gray-50 transition">
<i data-lucide="chevron-left" class="w-4 h-4 inline"></i> {% trans "Previous" %}
</a>
{% endif %}
{% if action.assigned_to %}
<div class="flex items-center gap-2 text-gray-600">
<i data-lucide="user" class="w-4 h-4"></i>
<span>{{ action.assigned_to.get_full_name|default:action.assigned_to.username }}</span>
</div>
<span class="px-4 py-2 bg-[#005696] text-white rounded-xl text-sm font-bold">
{% blocktrans with current=page_obj.number total=page_obj.paginator.num_pages %}Page {{ current }} of {{ total }}{% endblocktrans %}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}&{{ filters|urlencode }}" class="px-4 py-2 border border-gray-200 rounded-xl text-sm font-semibold hover:bg-gray-50 transition">
{% trans "Next" %} <i data-lucide="chevron-right" class="w-4 h-4 inline"></i>
</a>
{% endif %}
</div>
</div>
<div class="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
<a href="{% url 'actions:action_detail' action.id %}" class="text-navy text-sm font-bold hover:underline flex items-center gap-1">
{% trans "View Details" %} <i data-lucide="arrow-right" class="w-4 h-4"></i>
</a>
</div>
</div>
{% empty %}
<div class="col-span-full">
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 p-12 text-center">
<i data-lucide="check-square" class="w-16 h-16 mx-auto mb-4 text-gray-300"></i>
<h3 class="text-xl font-bold text-gray-800 mb-2">{% trans "No Action Plans Found" %}</h3>
<p class="text-gray-500 mb-6">{% trans "Get started by creating your first action plan" %}</p>
<a href="{% url 'actions:action_create' %}" class="inline-flex items-center gap-2 bg-navy text-white px-6 py-3 rounded-2xl font-bold shadow-lg shadow-blue-200 hover:bg-blue transition">
<i data-lucide="plus" class="w-5 h-5"></i> {% trans "Create Action Plan" %}
</a>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<div class="mt-6 flex justify-center">
<div class="flex gap-2">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}&{{ filters|urlencode }}" class="px-4 py-2 border border-gray-200 rounded-xl text-sm font-semibold hover:bg-gray-50 transition">
<i data-lucide="chevron-left" class="w-4 h-4"></i> {% trans "Previous" %}
</a>
{% endif %}
<span class="px-4 py-2 bg-navy text-white rounded-xl text-sm font-bold">
{% blocktrans with current=page_obj.number total=page_obj.paginator.num_pages %}Page {{ current }} of {{ total }}{% endblocktrans %}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}&{{ filters|urlencode }}" class="px-4 py-2 border border-gray-200 rounded-xl text-sm font-semibold hover:bg-gray-50 transition">
{% trans "Next" %} <i data-lucide="chevron-right" class="w-4 h-4"></i>
</a>
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}
{% endblock %}

View File

@ -3,100 +3,174 @@
{% block title %}{% trans "Sentiment Analysis Results" %}{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.progress-bar {
height: 20px;
background: #e2e8f0;
border-radius: 10px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(135deg, #005696, #007bbd);
border-radius: 10px;
transition: width 0.3s ease;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">{% trans "Sentiment Analysis Results" %}</h1>
<p class="text-muted">{% trans "AI-powered sentiment analysis of text content" %}</p>
</div>
<div>
<a href="{% url 'ai_engine:analyze_text' %}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> {% trans "Analyze Text" %}
</a>
<a href="{% url 'ai_engine:sentiment_dashboard' %}" class="btn btn-outline-secondary">
<i class="bi bi-graph-up"></i> {% trans "Dashboard" %}
</a>
<div class="page-header-gradient">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-3">
<div class="section-icon bg-white/20">
<i data-lucide="brain" class="w-6 h-6 text-white"></i>
</div>
<div>
<h1 class="text-2xl font-bold">{% trans "Sentiment Analysis Results" %}</h1>
<p class="text-white/80 text-sm">{% trans "AI-powered sentiment analysis of text content" %}</p>
</div>
</div>
<div class="flex gap-3">
<a href="{% url 'ai_engine:analyze_text' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white text-navy rounded-xl font-bold hover:bg-gray-100 transition">
<i data-lucide="plus-circle" class="w-4 h-4"></i> {% trans "Analyze Text" %}
</a>
<a href="{% url 'ai_engine:sentiment_dashboard' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white/20 text-white rounded-xl font-bold hover:bg-white/30 transition">
<i data-lucide="bar-chart-3" class="w-4 h-4"></i> {% trans "Dashboard" %}
</a>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">{% trans "Total Results" %}</h6>
<h3 class="mb-0">{{ stats.total }}</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-blue-100">
<i data-lucide="bar-chart-2" class="w-5 h-5 text-blue-600"></i>
</div>
<div>
<p class="text-xs font-semibold text-slate uppercase">{% trans "Total Results" %}</p>
<p class="text-2xl font-bold text-navy">{{ stats.total }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-success">
<div class="card-body">
<h6 class="text-muted mb-2">{% trans "Positive" %}</h6>
<h3 class="mb-0 text-success">
{{ stats.positive }} <small>({{ stats.positive_pct }}%)</small>
</h3>
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-emerald-100">
<i data-lucide="smile" class="w-5 h-5 text-emerald-600"></i>
</div>
<div>
<p class="text-xs font-semibold text-slate uppercase">{% trans "Positive" %}</p>
<p class="text-2xl font-bold text-emerald-600">{{ stats.positive }} <small class="text-sm text-slate">({{ stats.positive_pct }}%)</small></p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-secondary">
<div class="card-body">
<h6 class="text-muted mb-2">{% trans "Neutral" %}</h6>
<h3 class="mb-0 text-secondary">
{{ stats.neutral }} <small>({{ stats.neutral_pct }}%)</small>
</h3>
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-slate-100">
<i data-lucide="meh" class="w-5 h-5 text-slate-600"></i>
</div>
<div>
<p class="text-xs font-semibold text-slate uppercase">{% trans "Neutral" %}</p>
<p class="text-2xl font-bold text-slate-600">{{ stats.neutral }} <small class="text-sm text-slate">({{ stats.neutral_pct }}%)</small></p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-danger">
<div class="card-body">
<h6 class="text-muted mb-2">{% trans "Negative" %}</h6>
<h3 class="mb-0 text-danger">
{{ stats.negative }} <small>({{ stats.negative_pct }}%)</small>
</h3>
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-red-100">
<i data-lucide="frown" class="w-5 h-5 text-red-600"></i>
</div>
<div>
<p class="text-xs font-semibold text-slate uppercase">{% trans "Negative" %}</p>
<p class="text-2xl font-bold text-red-600">{{ stats.negative }} <small class="text-sm text-slate">({{ stats.negative_pct }}%)</small></p>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">{% trans "Filters" %}</h5>
<div class="section-card mb-8">
<div class="section-header">
<div class="section-icon bg-navy/10">
<i data-lucide="filter" class="w-5 h-5 text-navy"></i>
</div>
<h3 class="font-bold text-navy">{% trans "Filters" %}</h3>
</div>
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-2">
<div class="p-6">
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-bold text-navy mb-2">{% trans "Sentiment" %}</label>
{{ filter_form.sentiment }}
</div>
<div class="col-md-2">
<div>
<label class="block text-sm font-bold text-navy mb-2">{% trans "Language" %}</label>
{{ filter_form.language }}
</div>
<div class="col-md-2">
<div>
<label class="block text-sm font-bold text-navy mb-2">{% trans "AI Service" %}</label>
{{ filter_form.ai_service }}
</div>
<div class="col-md-2">
<div>
<label class="block text-sm font-bold text-navy mb-2">{% trans "Min Confidence" %}</label>
{{ filter_form.min_confidence }}
</div>
<div class="col-md-4">
<div class="md:col-span-3">
<label class="block text-sm font-bold text-navy mb-2">{% trans "Search" %}</label>
{{ filter_form.search }}
</div>
<div class="col-md-2">
{{ filter_form.date_from }}
</div>
<div class="col-md-2">
{{ filter_form.date_to }}
</div>
<div class="col-md-8 text-end">
<button type="submit" class="btn btn-primary">
<i class="bi bi-funnel"></i> {% trans "Apply Filters" %}
<div class="flex items-end gap-2">
<button type="submit" class="inline-flex items-center gap-2 px-5 py-2.5 bg-navy text-white rounded-xl font-bold hover:bg-blue transition">
<i data-lucide="filter" class="w-4 h-4"></i> {% trans "Apply Filters" %}
</button>
<a href="{% url 'ai_engine:sentiment_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans "Clear" %}
<a href="{% url 'ai_engine:sentiment_list' %}" class="inline-flex items-center gap-2 px-5 py-2.5 border-2 border-slate-200 text-slate rounded-xl font-bold hover:bg-slate-50 transition">
<i data-lucide="x-circle" class="w-4 h-4"></i> {% trans "Clear" %}
</a>
</div>
</form>
@ -104,89 +178,97 @@
</div>
<!-- Results Table -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{% trans "Results" %} ({{ page_obj.paginator.count }})</h5>
<div class="section-card">
<div class="section-header flex justify-between items-center">
<div class="flex items-center gap-2">
<div class="section-icon bg-navy/10">
<i data-lucide="list" class="w-5 h-5 text-navy"></i>
</div>
<h3 class="font-bold text-navy">{% trans "Results" %} ({{ page_obj.paginator.count }})</h3>
</div>
<div>
<select class="form-select form-select-sm" onchange="window.location.href='?page_size=' + this.value + '{% for key, value in filters.items %}{% if key != 'page_size' %}&{{ key }}={{ value }}{% endif %}{% endfor %}'">
<select class="px-4 py-2 border-2 border-slate-200 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition" onchange="window.location.href='?page_size=' + this.value + '{% for key, value in filters.items %}{% if key != 'page_size' %}&{{ key }}={{ value }}{% endif %}{% endfor %}'">
<option value="25" {% if request.GET.page_size == '25' %}selected{% endif %}>25 per page</option>
<option value="50" {% if request.GET.page_size == '50' %}selected{% endif %}>50 per page</option>
<option value="100" {% if request.GET.page_size == '100' %}selected{% endif %}>100 per page</option>
</select>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-blue-50">
<tr>
<th>{% trans "Text" %}</th>
<th>{% trans "Sentiment" %}</th>
<th>{% trans "Score" %}</th>
<th>{% trans "Confidence" %}</th>
<th>{% trans "Language" %}</th>
<th>{% trans "Related To" %}</th>
<th>{% trans "Date" %}</th>
<th>{% trans "Actions" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Text" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Sentiment" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Score" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Confidence" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Language" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Related To" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Date" %}</th>
<th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
<tbody class="divide-y divide-blue-50">
{% for result in results %}
<tr>
<td>
<div class="text-truncate" style="max-width: 300px;" title="{{ result.text }}">
<tr class="hover:bg-blue-50/50 transition">
<td class="px-6 py-4">
<div class="truncate max-w-xs" title="{{ result.text }}">
{{ result.text }}
</div>
</td>
<td>
<td class="px-6 py-4">
{% if result.sentiment == 'positive' %}
<span class="badge bg-success">😊 {% trans "Positive" %}</span>
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-bold bg-emerald-100 text-emerald-700">
<i data-lucide="smile" class="w-3 h-3"></i> {% trans "Positive" %}
</span>
{% elif result.sentiment == 'negative' %}
<span class="badge bg-danger">😞 {% trans "Negative" %}</span>
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-bold bg-red-100 text-red-700">
<i data-lucide="frown" class="w-3 h-3"></i> {% trans "Negative" %}
</span>
{% else %}
<span class="badge bg-secondary">😐 {% trans "Neutral" %}</span>
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-bold bg-slate-100 text-slate-700">
<i data-lucide="meh" class="w-3 h-3"></i> {% trans "Neutral" %}
</span>
{% endif %}
</td>
<td>
<span class="badge bg-light text-dark">{{ result.sentiment_score|floatformat:2 }}</span>
<td class="px-6 py-4">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-blue-100 text-blue-700">{{ result.sentiment_score|floatformat:2 }}</span>
</td>
<td>
<div class="progress" style="height: 20px;">
<div class="progress-bar" role="progressbar"
style="width: {{ result.confidence|floatformat:0 }}%"
aria-valuenow="{{ result.confidence|floatformat:0 }}"
aria-valuemin="0" aria-valuemax="100">
{{ result.confidence|floatformat:0 }}%
</div>
<td class="px-6 py-4">
<div class="progress-bar">
<div class="progress-bar-fill" style="width: {{ result.confidence|floatformat:0 }}%"></div>
</div>
<span class="text-xs text-slate mt-1">{{ result.confidence|floatformat:0 }}%</span>
</td>
<td>
<td class="px-6 py-4">
{% if result.language == 'ar' %}
<span class="badge bg-info">العربية</span>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-cyan-100 text-cyan-700">العربية</span>
{% else %}
<span class="badge bg-info">English</span>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-blue-100 text-blue-700">English</span>
{% endif %}
</td>
<td>
<td class="px-6 py-4">
{% if result.content_type %}
<small class="text-muted">{{ result.content_type.model }}</small>
<small class="text-slate">{{ result.content_type.model }}</small>
{% else %}
<small class="text-muted">-</small>
<small class="text-slate">-</small>
{% endif %}
</td>
<td>
<small>{{ result.created_at|date:"Y-m-d H:i" }}</small>
<td class="px-6 py-4">
<small class="text-slate">{{ result.created_at|date:"Y-m-d H:i" }}</small>
</td>
<td>
<td class="px-6 py-4">
<a href="{% url 'ai_engine:sentiment_detail' result.id %}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
class="inline-flex items-center gap-1 px-3 py-2 text-navy bg-blue-50 rounded-lg hover:bg-blue-100 transition font-medium text-sm">
<i data-lucide="eye" class="w-4 h-4"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="8" class="text-center text-muted py-4">
{% trans "No sentiment results found." %}
<td colspan="8" class="text-center py-12">
<i data-lucide="inbox" class="w-16 h-16 text-slate-300 mx-auto mb-4"></i>
<p class="text-slate font-medium">{% trans "No sentiment results found." %}</p>
</td>
</tr>
{% endfor %}
@ -196,30 +278,38 @@
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<div class="card-footer">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0">
<div class="px-6 py-4 border-t border-slate-100">
<nav class="flex justify-center">
<ul class="flex gap-1">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">{% trans "First" %}</a>
<li>
<a class="px-3 py-2 text-slate hover:bg-slate-100 rounded-lg transition" href="?page=1{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
<i data-lucide="chevrons-left" class="w-4 h-4"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">{% trans "Previous" %}</a>
<li>
<a class="px-3 py-2 text-slate hover:bg-slate-100 rounded-lg transition" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
<i data-lucide="chevron-left" class="w-4 h-4"></i>
</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">
<li>
<span class="px-4 py-2 bg-navy text-white rounded-lg font-bold">
{% trans "Page" %} {{ page_obj.number }} {% trans "of" %} {{ page_obj.paginator.num_pages }}
</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">{% trans "Next" %}</a>
<li>
<a class="px-3 py-2 text-slate hover:bg-slate-100 rounded-lg transition" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">{% trans "Last" %}</a>
<li>
<a class="px-3 py-2 text-slate hover:bg-slate-100 rounded-lg transition" href="?page={{ page_obj.paginator.num_pages }}{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
<i data-lucide="chevrons-right" class="w-4 h-4"></i>
</a>
</li>
{% endif %}
</ul>
@ -228,4 +318,10 @@
{% endif %}
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}

View File

@ -111,20 +111,6 @@
</div>
</div>
<!-- Hospital -->
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">{% trans "Hospital" %}</label>
<select class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-navy focus:border-transparent transition"
name="hospital" id="hospitalFilter" onchange="loadDepartments()">
<option value="">{% trans "All Hospitals" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name_en|default:hospital.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Department -->
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">{% trans "Department" %}</label>
@ -463,7 +449,7 @@ function handleDateRangeChange() {
function updateFilters() {
currentFilters.date_range = document.getElementById('dateRange').value;
currentFilters.hospital = document.getElementById('hospitalFilter').value;
currentFilters.hospital = '{{ current_hospital.id|default:"" }}';
currentFilters.department = document.getElementById('departmentFilter').value;
currentFilters.kpi_category = document.getElementById('kpiCategoryFilter').value;
currentFilters.custom_start = document.getElementById('customStart').value;
@ -669,7 +655,6 @@ function refreshDashboard() {
function resetFilters() {
document.getElementById('dateRange').value = '30d';
document.getElementById('hospitalFilter').value = '';
document.getElementById('departmentFilter').value = '';
document.getElementById('kpiCategoryFilter').value = '';
document.getElementById('customStart').value = '';

View File

@ -240,14 +240,6 @@
<p class="mt-1 opacity-90">{% trans "Comprehensive overview of patient experience metrics" %}</p>
</div>
<div class="flex items-center gap-3">
<select class="form-select-px360" onchange="window.location.href='?hospital='+this.value">
<option value="">{% trans "All Hospitals" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}" {% if selected_hospital and selected_hospital.id == hospital.id %}selected{% endif %}>
{{ hospital.name_en }}
</option>
{% endfor %}
</select>
<button onclick="refreshDashboard()" class="p-2.5 bg-white/20 hover:bg-white/30 rounded-xl transition" title="{% trans 'Refresh' %}">
<i data-lucide="refresh-cw" class="w-5 h-5 text-white"></i>
</button>

View File

@ -4,20 +4,71 @@
{% block title %}KPI Definitions - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">
<i class="bi bi-speedometer text-primary me-2"></i>
KPI Definitions
</h2>
<p class="text-muted mb-0">Manage key performance indicators</p>
<!-- Page Header -->
<div class="page-header-gradient">
<div class="d-flex justify-content-between align-items-center">
<div>
<h2 class="mb-1">
<i data-lucide="gauge" class="me-2" style="width: 28px; height: 28px; vertical-align: text-bottom;"></i>
KPI Definitions
</h2>
<p class="mb-0 opacity-75">Manage key performance indicators</p>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<!-- Table Section -->
<div class="section-card">
<div class="section-header">
<div class="section-icon" style="background: linear-gradient(135deg, #005696, #007bbd);">
<i data-lucide="list" style="width: 20px; height: 20px; color: white;"></i>
</div>
<h5 class="mb-0 fw-bold">{% trans "KPI List" %}</h5>
</div>
<div class="p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
@ -73,7 +124,7 @@
{% empty %}
<tr>
<td colspan="6" class="text-center py-5">
<i class="bi bi-speedometer" style="font-size: 3rem; color: #ccc;"></i>
<i data-lucide="gauge" style="width: 48px; height: 48px; color: #ccc;"></i>
<p class="text-muted mt-3">No KPIs defined</p>
</td>
</tr>
@ -90,7 +141,7 @@
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">
<i class="bi bi-chevron-left"></i>
<i data-lucide="chevron-left" style="width: 16px; height: 16px;"></i>
</a>
</li>
{% endif %}
@ -108,7 +159,7 @@
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">
<i class="bi bi-chevron-right"></i>
<i data-lucide="chevron-right" style="width: 16px; height: 16px;"></i>
</a>
</li>
{% endif %}
@ -117,13 +168,3 @@
{% endif %}
</div>
{% endblock %}
</final_file_content>
IMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference. This content reflects the current state of the file, including any auto-formatting (e.g., if you used single quotes but the formatter converted them to double quotes). Always base your SEARCH/REPLACE operations on this final version to ensure accuracy.
</function_calls>
<function_calls>
<invoke name="read_file">
<parameter name="path">apps/analytics/urls.py

View File

@ -58,19 +58,7 @@
</p>
</div>
<!-- Hospital -->
<div>
<label for="hospital" class="block text-xs font-bold text-slate uppercase tracking-wider mb-2">
{% trans "Hospital" %} <span class="text-red-500">*</span>
</label>
<select name="hospital" id="hospital" required
class="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:bg-white focus:border-navy focus:ring-2 focus:ring-navy/10 outline-none transition">
<option value="">{% trans "Select Hospital" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}">{{ hospital.name }}</option>
{% endfor %}
</select>
</div>
<input type="hidden" name="hospital" value="{{ current_hospital.id }}">
<!-- Year and Month -->
<div class="grid grid-cols-2 gap-4">

View File

@ -5,6 +5,42 @@
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.status-completed { background-color: #dcfce7; color: #166534; }
.status-pending { background-color: #fef9c3; color: #854d0e; }
.status-generating { background-color: #e0f2fe; color: #075985; }
@ -16,29 +52,29 @@
{% endblock %}
{% block content %}
<!-- Header -->
<header class="mb-6">
<!-- Page Header -->
<div class="page-header-gradient">
<div class="flex justify-between items-start">
<div>
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<h1 class="text-2xl font-bold flex items-center gap-3">
<i data-lucide="bar-chart-3" class="w-7 h-7"></i>
{% trans "KPI Reports" %}
</h1>
<p class="text-sm text-slate mt-1">{% trans "Monthly automated reports for MOH and internal KPIs" %}</p>
<p class="text-sm mt-1 opacity-75">{% trans "Monthly automated reports for MOH and internal KPIs" %}</p>
</div>
<div class="flex items-center gap-3">
<div class="relative group">
<i data-lucide="search" class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate group-focus-within:text-navy"></i>
<i data-lucide="search" class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-white/70 group-focus-within:text-white"></i>
<input type="text" id="searchInput" placeholder="{% trans 'Search KPI ID or indicator...' %}"
class="pl-10 pr-4 py-2.5 bg-slate-100 border-transparent border focus:border-navy/30 focus:bg-white rounded-xl text-sm outline-none w-64 transition-all">
class="pl-10 pr-4 py-2.5 bg-white/20 border-transparent border focus:border-white/50 focus:bg-white/30 rounded-xl text-sm outline-none w-64 transition-all text-white placeholder-white/70">
</div>
<a href="{% url 'analytics:kpi_report_generate' %}"
class="bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-navy/20 hover:bg-blue flex items-center gap-2 transition">
class="bg-white text-[#005696] px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg hover:bg-gray-100 flex items-center gap-2 transition">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Generate Report" %}
</a>
</div>
</div>
</header>
</div>
<!-- Statistics Cards -->
<div class="grid grid-cols-4 gap-6 mb-6">
@ -80,236 +116,233 @@
</div>
</div>
<!-- Filter Tabs -->
<div class="bg-white px-6 py-4 rounded-t-2xl border-b flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="?" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if not filters.status %}active{% endif %}">
{% trans "All Reports" %}
</a>
<a href="?status=completed" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if filters.status == 'completed' %}active{% endif %}">
{% trans "Completed" %}
</a>
<a href="?status=pending" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if filters.status == 'pending' %}active{% endif %}">
{% trans "Pending" %}
</a>
<a href="?status=failed" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if filters.status == 'failed' %}active{% endif %}">
{% trans "Failed" %}
</a>
<div class="h-4 w-[1px] bg-slate-200 mx-2"></div>
<button onclick="toggleFilters()" class="flex items-center gap-2 text-xs font-bold text-blue uppercase tracking-tight hover:underline">
<i data-lucide="filter" class="w-3 h-3"></i> {% trans "Advanced Filters" %}
<!-- Filter Section -->
<div class="section-card mb-6">
<div class="section-header">
<div class="section-icon" style="background: linear-gradient(135deg, #005696, #007bbd);">
<i data-lucide="filter" style="width: 20px; height: 20px; color: white;"></i>
</div>
<h5 class="mb-0 fw-bold">{% trans "Filters" %}</h5>
<button onclick="toggleFilters()" class="ms-auto text-slate hover:text-navy transition p-2 rounded-lg hover:bg-white">
<i data-lucide="chevron-up" id="filterToggleIcon" class="w-5 h-5"></i>
</button>
</div>
<p class="text-[10px] font-bold text-slate uppercase">
{% trans "Showing:" %} <span class="text-navy">{{ page_obj.start_index|default:0 }}-{{ page_obj.end_index|default:0 }} {% trans "of" %} {{ page_obj.paginator.count|default:0 }}</span>
</p>
</div>
<!-- Filter Tabs -->
<div class="px-6 py-4 border-b flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="?" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if not filters.status %}active{% endif %}">
{% trans "All Reports" %}
</a>
<a href="?status=completed" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if filters.status == 'completed' %}active{% endif %}">
{% trans "Completed" %}
</a>
<a href="?status=pending" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if filters.status == 'pending' %}active{% endif %}">
{% trans "Pending" %}
</a>
<a href="?status=failed" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if filters.status == 'failed' %}active{% endif %}">
{% trans "Failed" %}
</a>
</div>
<p class="text-[10px] font-bold text-slate uppercase">
{% trans "Showing:" %} <span class="text-navy">{{ page_obj.start_index|default:0 }}-{{ page_obj.end_index|default:0 }} {% trans "of" %} {{ page_obj.paginator.count|default:0 }}</span>
</p>
</div>
<!-- Advanced Filters (Hidden by default) -->
<div id="advancedFilters" class="hidden bg-slate-50 px-6 py-4 border-b">
<form method="get" class="flex flex-wrap gap-4">
{% if filters.status %}
<input type="hidden" name="status" value="{{ filters.status }}">
{% endif %}
<div class="flex items-center gap-2">
<label class="text-xs font-bold text-slate uppercase">{% trans "Report Type" %}</label>
<select name="report_type" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
<option value="">{% trans "All Types" %}</option>
{% for type_value, type_label in report_types %}
<option value="{{ type_value }}" {% if filters.report_type == type_value %}selected{% endif %}>
{{ type_label }}
</option>
{% endfor %}
</select>
</div>
{% if request.user.is_px_admin %}
<div class="flex items-center gap-2">
<label class="text-xs font-bold text-slate uppercase">{% trans "Hospital" %}</label>
<select name="hospital" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
<option value="">{% trans "All Hospitals" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:'s' %}selected{% endif %}>
{{ hospital.name }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="flex items-center gap-2">
<label class="text-xs font-bold text-slate uppercase">{% trans "Year" %}</label>
<select name="year" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
<option value="">{% trans "All Years" %}</option>
{% for y in years %}
<option value="{{ y }}" {% if filters.year == y|stringformat:'s' %}selected{% endif %}>
{{ y }}
</option>
{% endfor %}
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-xs font-bold text-slate uppercase">{% trans "Month" %}</label>
<select name="month" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
<option value="">{% trans "All Months" %}</option>
{% for m, m_label in months %}
<option value="{{ m }}" {% if filters.month == m|stringformat:'s' %}selected{% endif %}>
{{ m_label }}
</option>
{% endfor %}
</select>
</div>
<button type="submit" class="px-4 py-1.5 bg-navy text-white rounded-lg text-xs font-bold">{% trans "Apply" %}</button>
<a href="?" class="px-4 py-1.5 border rounded-lg text-xs font-semibold text-slate hover:bg-white">{% trans "Clear" %}</a>
</form>
<!-- Advanced Filters (Hidden by default) -->
<div id="advancedFilters" class="hidden px-6 py-4 bg-slate-50 border-b">
<form method="get" class="flex flex-wrap gap-4">
{% if filters.status %}
<input type="hidden" name="status" value="{{ filters.status }}">
{% endif %}
<div class="flex items-center gap-2">
<label class="text-xs font-bold text-slate uppercase">{% trans "Report Type" %}</label>
<select name="report_type" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
<option value="">{% trans "All Types" %}</option>
{% for type_value, type_label in report_types %}
<option value="{{ type_value }}" {% if filters.report_type == type_value %}selected{% endif %}>
{{ type_label }}
</option>
{% endfor %}
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-xs font-bold text-slate uppercase">{% trans "Year" %}</label>
<select name="year" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
<option value="">{% trans "All Years" %}</option>
{% for y in years %}
<option value="{{ y }}" {% if filters.year == y|stringformat:'s' %}selected{% endif %}>
{{ y }}
</option>
{% endfor %}
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-xs font-bold text-slate uppercase">{% trans "Month" %}</label>
<select name="month" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
<option value="">{% trans "All Months" %}</option>
{% for m, m_label in months %}
<option value="{{ m }}" {% if filters.month == m|stringformat:'s' %}selected{% endif %}>
{{ m_label }}
</option>
{% endfor %}
</select>
</div>
<button type="submit" class="px-4 py-1.5 bg-navy text-white rounded-lg text-xs font-bold">{% trans "Apply" %}</button>
<a href="?" class="px-4 py-1.5 border rounded-lg text-xs font-semibold text-slate hover:bg-white">{% trans "Clear" %}</a>
</form>
</div>
</div>
<!-- Reports Grid -->
{% if reports %}
<div class="bg-white rounded-b-2xl shadow-sm border p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for report in reports %}
<div class="card bg-white p-5 border border-slate-200 rounded-2xl hover:shadow-lg hover:-translate-y-1 transition-all duration-200 cursor-pointer group"
onclick="window.location.href='{% url 'analytics:kpi_report_detail' report.id %}'">
<!-- Header -->
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span class="px-2 py-1 text-xs font-bold rounded bg-navy text-white">
{{ report.kpi_id }}
<div class="section-card">
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for report in reports %}
<div class="card bg-white p-5 border border-slate-200 rounded-2xl hover:shadow-lg hover:-translate-y-1 transition-all duration-200 cursor-pointer group"
onclick="window.location.href='{% url 'analytics:kpi_report_detail' report.id %}'">
<!-- Header -->
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span class="px-2 py-1 text-xs font-bold rounded bg-navy text-white">
{{ report.kpi_id }}
</span>
<span class="text-xs text-slate">{{ report.report_period_display }}</span>
</div>
<span class="px-2 py-0.5 text-xs rounded-full font-semibold uppercase
{% if report.status == 'completed' %}status-completed
{% elif report.status == 'failed' %}status-failed
{% elif report.status == 'generating' %}status-generating
{% else %}status-pending{% endif %}">
{{ report.get_status_display }}
</span>
<span class="text-xs text-slate">{{ report.report_period_display }}</span>
</div>
<span class="px-2 py-0.5 text-xs rounded-full font-semibold uppercase
{% if report.status == 'completed' %}status-completed
{% elif report.status == 'failed' %}status-failed
{% elif report.status == 'generating' %}status-generating
{% else %}status-pending{% endif %}">
{{ report.get_status_display }}
</span>
</div>
<!-- Title -->
<h3 class="font-semibold text-navy mb-2 line-clamp-2 group-hover:text-blue transition-colors">{{ report.indicator_title }}</h3>
<!-- Hospital -->
<p class="text-sm text-slate mb-4 flex items-center gap-1">
<i data-lucide="building-2" class="w-3 h-3 inline"></i>
{{ report.hospital.name }}
</p>
<!-- Results -->
<div class="grid grid-cols-3 gap-2 mb-4">
<div class="bg-light rounded-lg p-3 text-center">
<div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Target" %}</div>
<div class="text-lg font-black text-navy">{{ report.target_percentage }}%</div>
</div>
<div class="bg-light rounded-lg p-3 text-center">
<div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Result" %}</div>
<div class="text-lg font-black {% if report.overall_result >= report.target_percentage %}text-green-600{% else %}text-red-600{% endif %}">
{{ report.overall_result }}%
<!-- Title -->
<h3 class="font-semibold text-navy mb-2 line-clamp-2 group-hover:text-blue transition-colors">{{ report.indicator_title }}</h3>
<!-- Hospital -->
<p class="text-sm text-slate mb-4 flex items-center gap-1">
<i data-lucide="building-2" class="w-3 h-3 inline"></i>
{{ report.hospital.name }}
</p>
<!-- Results -->
<div class="grid grid-cols-3 gap-2 mb-4">
<div class="bg-light rounded-lg p-3 text-center">
<div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Target" %}</div>
<div class="text-lg font-black text-navy">{{ report.target_percentage }}%</div>
</div>
<div class="bg-light rounded-lg p-3 text-center">
<div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Result" %}</div>
<div class="text-lg font-black {% if report.overall_result >= report.target_percentage %}text-green-600{% else %}text-red-600{% endif %}">
{{ report.overall_result }}%
</div>
</div>
<div class="bg-light rounded-lg p-3 text-center">
<div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Cases" %}</div>
<div class="text-lg font-black text-navy">{{ report.total_denominator }}</div>
</div>
</div>
<div class="bg-light rounded-lg p-3 text-center">
<div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Cases" %}</div>
<div class="text-lg font-black text-navy">{{ report.total_denominator }}</div>
<!-- Actions -->
<div class="flex gap-2 pt-3 border-t opacity-0 group-hover:opacity-100 transition-opacity">
<button onclick="event.stopPropagation(); window.location.href='{% url 'analytics:kpi_report_detail' report.id %}'"
class="flex-1 btn-primary text-center text-sm flex items-center justify-center gap-2">
<i data-lucide="eye" class="w-4 h-4"></i> {% trans "View" %}
</button>
<button onclick="event.stopPropagation(); window.location.href='{% url 'analytics:kpi_report_pdf' report.id %}'"
class="btn-secondary px-3" title="{% trans 'Download PDF' %}">
<i data-lucide="file-down" class="w-4 h-4"></i>
</button>
{% if report.status == 'failed' or report.status == 'pending' %}
<form method="post" action="{% url 'analytics:kpi_report_regenerate' report.id %}"
class="inline" onclick="event.stopPropagation(); return confirm('{% trans "Regenerate this report?" %}')">
{% csrf_token %}
<button type="submit" class="btn-secondary px-3" title="{% trans 'Regenerate' %}">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button>
</form>
{% endif %}
</div>
</div>
<!-- Actions -->
<div class="flex gap-2 pt-3 border-t opacity-0 group-hover:opacity-100 transition-opacity">
<button onclick="event.stopPropagation(); window.location.href='{% url 'analytics:kpi_report_detail' report.id %}'"
class="flex-1 btn-primary text-center text-sm flex items-center justify-center gap-2">
<i data-lucide="eye" class="w-4 h-4"></i> {% trans "View" %}
</button>
<button onclick="event.stopPropagation(); window.location.href='{% url 'analytics:kpi_report_pdf' report.id %}'"
class="btn-secondary px-3" title="{% trans 'Download PDF' %}">
<i data-lucide="file-down" class="w-4 h-4"></i>
</button>
{% if report.status == 'failed' or report.status == 'pending' %}
<form method="post" action="{% url 'analytics:kpi_report_regenerate' report.id %}"
class="inline" onclick="event.stopPropagation(); return confirm('{% trans "Regenerate this report?" %}')">
{% csrf_token %}
<button type="submit" class="btn-secondary px-3" title="{% trans 'Regenerate' %}">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button>
{% endfor %}
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<div class="bg-slate-50 px-8 py-4 flex items-center justify-between border-t mt-6 rounded-lg">
<div class="flex items-center gap-4">
<span class="text-xs text-slate font-medium">
{% trans "Showing" %} <span class="font-bold text-navy">{{ page_obj.start_index }}-{{ page_obj.end_index }}</span> {% trans "of" %} <span class="font-bold text-navy">{{ page_obj.paginator.count }}</span> {% trans "entries" %}
</span>
<!-- Page Size Selector -->
<form method="get" class="flex items-center gap-2" id="pageSizeForm">
{% for key, value in request.GET.items %}
{% if key != 'page_size' and key != 'page' %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}
<label class="text-xs text-slate">{% trans "Show" %}</label>
<select name="page_size" onchange="document.getElementById('pageSizeForm').submit()" class="px-2 py-1 bg-white border border-slate-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-navy">
<option value="6" {% if page_obj.paginator.per_page == 6 %}selected{% endif %}>6</option>
<option value="12" {% if page_obj.paginator.per_page == 12 %}selected{% endif %}>12</option>
<option value="24" {% if page_obj.paginator.per_page == 24 %}selected{% endif %}>24</option>
<option value="48" {% if page_obj.paginator.per_page == 48 %}selected{% endif %}>48</option>
</select>
</form>
</div>
<div class="flex gap-2">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 transition">
<i data-lucide="chevron-left" class="w-4 h-4 text-slate"></i>
</a>
{% else %}
<span class="w-8 h-8 flex items-center justify-center rounded-lg border bg-slate-100 text-slate-300 cursor-not-allowed">
<i data-lucide="chevron-left" class="w-4 h-4"></i>
</span>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if num == page_obj.number %}
<span class="w-8 h-8 flex items-center justify-center rounded-lg bg-navy text-white text-xs font-bold">{{ num }}</span>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<a href="?page={{ num }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 text-xs font-bold text-slate transition">
{{ num }}
</a>
{% elif num == 1 or num == page_obj.paginator.num_pages %}
<a href="?page={{ num }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 text-xs font-bold text-slate transition">
{{ num }}
</a>
{% elif num == page_obj.number|add:'-3' or num == page_obj.number|add:'3' %}
<span class="w-8 h-8 flex items-center justify-center text-xs text-slate">...</span>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 transition">
<i data-lucide="chevron-right" class="w-4 h-4 text-slate"></i>
</a>
{% else %}
<span class="w-8 h-8 flex items-center justify-center rounded-lg border bg-slate-100 text-slate-300 cursor-not-allowed">
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</span>
{% endif %}
</div>
</div>
{% endfor %}
{% endif %}
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<div class="bg-slate-50 px-8 py-4 flex items-center justify-between border-t mt-6 rounded-lg">
<div class="flex items-center gap-4">
<span class="text-xs text-slate font-medium">
{% trans "Showing" %} <span class="font-bold text-navy">{{ page_obj.start_index }}-{{ page_obj.end_index }}</span> {% trans "of" %} <span class="font-bold text-navy">{{ page_obj.paginator.count }}</span> {% trans "entries" %}
</span>
<!-- Page Size Selector -->
<form method="get" class="flex items-center gap-2" id="pageSizeForm">
{% for key, value in request.GET.items %}
{% if key != 'page_size' and key != 'page' %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}
<label class="text-xs text-slate">{% trans "Show" %}</label>
<select name="page_size" onchange="document.getElementById('pageSizeForm').submit()" class="px-2 py-1 bg-white border border-slate-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-navy">
<option value="6" {% if page_obj.paginator.per_page == 6 %}selected{% endif %}>6</option>
<option value="12" {% if page_obj.paginator.per_page == 12 %}selected{% endif %}>12</option>
<option value="24" {% if page_obj.paginator.per_page == 24 %}selected{% endif %}>24</option>
<option value="48" {% if page_obj.paginator.per_page == 48 %}selected{% endif %}>48</option>
</select>
</form>
</div>
<div class="flex gap-2">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 transition">
<i data-lucide="chevron-left" class="w-4 h-4 text-slate"></i>
</a>
{% else %}
<span class="w-8 h-8 flex items-center justify-center rounded-lg border bg-slate-100 text-slate-300 cursor-not-allowed">
<i data-lucide="chevron-left" class="w-4 h-4"></i>
</span>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if num == page_obj.number %}
<span class="w-8 h-8 flex items-center justify-center rounded-lg bg-navy text-white text-xs font-bold">{{ num }}</span>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<a href="?page={{ num }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 text-xs font-bold text-slate transition">
{{ num }}
</a>
{% elif num == 1 or num == page_obj.paginator.num_pages %}
<a href="?page={{ num }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 text-xs font-bold text-slate transition">
{{ num }}
</a>
{% elif num == page_obj.number|add:'-3' or num == page_obj.number|add:'3' %}
<span class="w-8 h-8 flex items-center justify-center text-xs text-slate">...</span>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}"
class="w-8 h-8 flex items-center justify-center rounded-lg border bg-white hover:bg-slate-50 transition">
<i data-lucide="chevron-right" class="w-4 h-4 text-slate"></i>
</a>
{% else %}
<span class="w-8 h-8 flex items-center justify-center rounded-lg border bg-slate-100 text-slate-300 cursor-not-allowed">
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</span>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% else %}
<div class="card text-center py-16">
<div class="section-card text-center py-16">
<i data-lucide="bar-chart-3" class="w-20 h-20 mx-auto text-slate-300 mb-4"></i>
<h3 class="text-lg font-bold text-navy mb-2">{% trans "No KPI Reports Found" %}</h3>
<p class="text-slate mb-6">{% trans "Generate your first KPI report to get started." %}</p>
@ -325,7 +358,14 @@
<script>
function toggleFilters() {
const filters = document.getElementById('advancedFilters');
const icon = document.getElementById('filterToggleIcon');
filters.classList.toggle('hidden');
if (filters.classList.contains('hidden')) {
icon.setAttribute('data-lucide', 'chevron-down');
} else {
icon.setAttribute('data-lucide', 'chevron-up');
}
lucide.createIcons();
}
// Search functionality
@ -338,4 +378,4 @@ document.getElementById('searchInput')?.addEventListener('keypress', function(e)
}
});
</script>
{% endblock %}
{% endblock %}

View File

@ -3,29 +3,73 @@
{% block title %}{% trans "Appreciation" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %}
<!-- Header -->
<header class="mb-6">
<!-- Page Header Gradient -->
<div class="page-header-gradient">
<div class="flex justify-between items-start">
<div>
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<i data-lucide="heart" class="w-7 h-7 text-rose-500"></i>
<h1 class="text-2xl font-bold flex items-center gap-3">
<i data-lucide="heart" class="w-7 h-7"></i>
{% trans "Appreciation" %}
</h1>
<p class="text-sm text-slate mt-1">{% trans "Send appreciation to colleagues and celebrate achievements" %}</p>
<p class="text-sm opacity-90 mt-1">{% trans "Send appreciation to colleagues and celebrate achievements" %}</p>
</div>
<a href="{% url 'appreciation:appreciation_send' %}" class="bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-navy/20 hover:bg-blue flex items-center gap-2 transition">
<a href="{% url 'appreciation:appreciation_send' %}" class="bg-white text-navy px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg hover:bg-gray-100 flex items-center gap-2 transition">
<i data-lucide="send" class="w-4 h-4"></i> {% trans "Send Appreciation" %}
</a>
</div>
</header>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-100">
<div class="section-card p-5">
<div class="flex items-center gap-4">
<div class="p-3 bg-blue-50 rounded-xl">
<i data-lucide="inbox" class="w-6 h-6 text-blue-500"></i>
<div class="section-icon bg-blue-100">
<i data-lucide="inbox" class="w-6 h-6 text-blue-600"></i>
</div>
<div>
<p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Received" %}</p>
@ -33,10 +77,10 @@
</div>
</div>
</div>
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-100">
<div class="section-card p-5">
<div class="flex items-center gap-4">
<div class="p-3 bg-green-50 rounded-xl">
<i data-lucide="send" class="w-6 h-6 text-green-500"></i>
<div class="section-icon bg-green-100">
<i data-lucide="send" class="w-6 h-6 text-green-600"></i>
</div>
<div>
<p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Sent" %}</p>
@ -44,10 +88,10 @@
</div>
</div>
</div>
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-100">
<div class="section-card p-5">
<div class="flex items-center gap-4">
<div class="p-3 bg-amber-50 rounded-xl">
<i data-lucide="award" class="w-6 h-6 text-amber-500"></i>
<div class="section-icon bg-amber-100">
<i data-lucide="award" class="w-6 h-6 text-amber-600"></i>
</div>
<div>
<p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Badges Earned" %}</p>
@ -55,10 +99,10 @@
</div>
</div>
</div>
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-100">
<div class="section-card p-5">
<div class="flex items-center gap-4">
<div class="p-3 bg-purple-50 rounded-xl">
<i data-lucide="trophy" class="w-6 h-6 text-purple-500"></i>
<div class="section-icon bg-purple-100">
<i data-lucide="trophy" class="w-6 h-6 text-purple-600"></i>
</div>
<div>
<p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Leaderboard" %}</p>
@ -73,10 +117,10 @@
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<a href="{% url 'appreciation:leaderboard_view' %}" class="group">
<div class="bg-white p-5 rounded-2xl shadow-sm border border-amber-200 hover:border-amber-300 hover:shadow-md transition">
<div class="section-card p-5 hover:border-amber-400">
<div class="flex items-center gap-4">
<div class="p-3 bg-amber-50 rounded-xl group-hover:bg-amber-100 transition">
<i data-lucide="trophy" class="w-6 h-6 text-amber-500"></i>
<div class="section-icon bg-amber-100 group-hover:bg-amber-200 transition">
<i data-lucide="trophy" class="w-6 h-6 text-amber-600"></i>
</div>
<div>
<h3 class="font-bold text-navy">{% trans "Leaderboard" %}</h3>
@ -86,10 +130,10 @@
</div>
</a>
<a href="{% url 'appreciation:my_badges_view' %}" class="group">
<div class="bg-white p-5 rounded-2xl shadow-sm border border-blue-200 hover:border-blue-300 hover:shadow-md transition">
<div class="section-card p-5 hover:border-blue-400">
<div class="flex items-center gap-4">
<div class="p-3 bg-blue-50 rounded-xl group-hover:bg-blue-100 transition">
<i data-lucide="award" class="w-6 h-6 text-blue-500"></i>
<div class="section-icon bg-blue-100 group-hover:bg-blue-200 transition">
<i data-lucide="award" class="w-6 h-6 text-blue-600"></i>
</div>
<div>
<h3 class="font-bold text-navy">{% trans "My Badges" %}</h3>
@ -99,10 +143,10 @@
</div>
</a>
<a href="{% url 'appreciation:appreciation_send' %}" class="group">
<div class="bg-white p-5 rounded-2xl shadow-sm border border-green-200 hover:border-green-300 hover:shadow-md transition">
<div class="section-card p-5 hover:border-green-400">
<div class="flex items-center gap-4">
<div class="p-3 bg-green-50 rounded-xl group-hover:bg-green-100 transition">
<i data-lucide="send" class="w-6 h-6 text-green-500"></i>
<div class="section-icon bg-green-100 group-hover:bg-green-200 transition">
<i data-lucide="send" class="w-6 h-6 text-green-600"></i>
</div>
<div>
<h3 class="font-bold text-navy">{% trans "Send Appreciation" %}</h3>
@ -114,12 +158,12 @@
</div>
<!-- Filter Bar -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 mb-6">
<div class="px-6 py-4 border-b-2 border-slate-200 flex justify-between items-center bg-gradient-to-r from-slate-50 to-slate-100">
<h3 class="font-bold text-navy flex items-center gap-2">
<div class="section-card mb-6">
<div class="section-header">
<div class="section-icon bg-navy/10">
<i data-lucide="filter" class="w-5 h-5 text-navy"></i>
{% trans "Filters" %}
</h3>
</div>
<h3 class="font-bold text-navy">{% trans "Filters" %}</h3>
</div>
<div class="p-5">
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
@ -180,12 +224,12 @@
</div>
<!-- Appreciations List -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 overflow-hidden">
<div class="px-6 py-4 border-b-2 border-slate-200 bg-gradient-to-r from-slate-50 to-slate-100">
<h3 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="heart" class="w-5 h-5 text-rose-500"></i>
{% trans "Appreciations" %}
</h3>
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-rose-100">
<i data-lucide="heart" class="w-5 h-5 text-rose-600"></i>
</div>
<h3 class="font-bold text-navy">{% trans "Appreciations" %}</h3>
</div>
<div class="p-6">
{% if page_obj %}

View File

@ -3,233 +3,304 @@
{% block title %}{% trans "Send Appreciation" %} - {% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.form-section {
background: #fff;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
}
.form-section:hover {
border-color: #005696;
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
}
.form-control, .form-select {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
outline: none;
border-color: #005696;
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #005696;
color: white;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
border: none;
cursor: pointer;
}
.btn-primary:hover {
background: #007bbd;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: white;
color: #64748b;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #005696;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="p-4">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% trans "Send Appreciation" %}</li>
<ol class="flex flex-wrap items-center gap-2 text-sm">
<li><a href="{% url 'appreciation:appreciation_list' %}" class="text-blue-600 hover:text-blue-800">{% trans "Appreciation" %}</a></li>
<li class="text-gray-400"><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li class="text-gray-600 font-medium" aria-current="page">{% trans "Send Appreciation" %}</li>
</ol>
</nav>
<!-- Send Appreciation Form -->
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">
<i class="bi-send me-2"></i>
{% trans "Send Appreciation" %}
</h4>
</div>
<div class="card-body">
<form method="post" id="appreciationForm">
{% csrf_token %}
<!-- Recipient Type -->
<div class="row mb-3">
<div class="col-md-6">
<label for="recipient_type" class="form-label">
{% trans "Recipient Type" %} <span class="text-danger">*</span>
</label>
<select class="form-select" id="recipient_type" name="recipient_type" required>
<option value="user">{% trans "User" %}</option>
<option value="physician">{% trans "Physician" %}</option>
</select>
</div>
<div class="col-md-6">
<label for="hospital_id" class="form-label">
{% trans "Hospital" %} <span class="text-danger">*</span>
</label>
<select class="form-select" id="hospital_id" name="hospital_id" required>
<option value="">-- {% trans "Select Hospital" %} --</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}">{{ hospital.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Header -->
<div class="page-header-gradient">
<h1 class="text-2xl font-bold flex items-center gap-3">
<i data-lucide="send" class="w-6 h-6"></i>
{% trans "Send Appreciation" %}
</h1>
</div>
<!-- Recipient -->
<div class="mb-3">
<label for="recipient_id" class="form-label">
{% trans "Recipient" %} <span class="text-danger">*</span>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Send Appreciation Form -->
<div class="lg:col-span-2">
<div class="form-section">
<form method="post" id="appreciationForm">
{% csrf_token %}
<!-- Recipient Type -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="recipient_type" class="form-label">
{% trans "Recipient Type" %} <span class="text-red-500">*</span>
</label>
<select class="form-select" id="recipient_id" name="recipient_id" required disabled>
<option value="">-- {% trans "Select Recipient" %} --</option>
</select>
<div class="form-text" id="recipientHelp">{% trans "Select a hospital first" %}</div>
</div>
<!-- Department (Optional) -->
<div class="mb-3">
<label for="department_id" class="form-label">{% trans "Department" %}</label>
<select class="form-select" id="department_id" name="department_id">
<option value="">-- {% trans "Select Department" %} --</option>
</select>
<div class="form-text">{% trans "Optional: Select if related to a specific department" %}</div>
</div>
<!-- Category -->
<div class="mb-3">
<label for="category_id" class="form-label">{% trans "Category" %}</label>
<select class="form-select" id="category_id" name="category_id">
<option value="">-- {% trans "Select Category" %} --</option>
{% for category in categories %}
<option value="{{ category.id }}">
<i class="{{ category.icon }}"></i> {{ category.name_en }}
</option>
{% endfor %}
<select class="form-select" id="recipient_type" name="recipient_type" required>
<option value="user">{% trans "User" %}</option>
<option value="physician">{% trans "Physician" %}</option>
</select>
</div>
<!-- Message (English) -->
<div class="mb-3">
<label for="message_en" class="form-label">
{% trans "Message (English)" %} <span class="text-danger">*</span>
</label>
<textarea
class="form-control"
id="message_en"
name="message_en"
rows="4"
required
placeholder="{% trans 'Write your appreciation message here...' %}"
></textarea>
<div class="form-text">{% trans "Required: Appreciation message in English" %}</div>
<div>
<input type="hidden" name="hospital_id" value="{{ current_hospital.id }}">
<label class="form-label">{% trans "Hospital" %}</label>
<input type="text" class="form-control" value="{{ current_hospital.name }}" readonly>
</div>
</div>
<!-- Message (Arabic) -->
<div class="mb-3">
<label for="message_ar" class="form-label">{% trans "Message (Arabic)" %}</label>
<textarea
class="form-control"
id="message_ar"
name="message_ar"
rows="4"
dir="rtl"
placeholder="{% trans 'اكتب رسالة التقدير هنا...' %}"
></textarea>
<div class="form-text">{% trans "Optional: Appreciation message in Arabic" %}</div>
</div>
<!-- Recipient -->
<div class="mb-4">
<label for="recipient_id" class="form-label">
{% trans "Recipient" %} <span class="text-red-500">*</span>
</label>
<select class="form-select" id="recipient_id" name="recipient_id" required disabled>
<option value="">-- {% trans "Select Recipient" %} --</option>
</select>
<p class="text-sm text-gray-500 mt-1" id="recipientHelp">{% trans "Select a hospital first" %}</p>
</div>
<!-- Visibility -->
<div class="mb-3">
<label for="visibility" class="form-label">{% trans "Visibility" %}</label>
<select class="form-select" id="visibility" name="visibility">
{% for choice in visibility_choices %}
<option value="{{ choice.0 }}" {% if choice.0 == 'private' %}selected{% endif %}>
{{ choice.1 }}
</option>
{% endfor %}
</select>
</div>
<!-- Department (Optional) -->
<div class="mb-4">
<label for="department_id" class="form-label">{% trans "Department" %}</label>
<select class="form-select" id="department_id" name="department_id">
<option value="">-- {% trans "Select Department" %} --</option>
</select>
<p class="text-sm text-gray-500 mt-1">{% trans "Optional: Select if related to a specific department" %}</p>
</div>
<!-- Anonymous -->
<div class="mb-4">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="is_anonymous"
name="is_anonymous"
>
<label class="form-check-label" for="is_anonymous">
<!-- Category -->
<div class="mb-4">
<label for="category_id" class="form-label">{% trans "Category" %}</label>
<select class="form-select" id="category_id" name="category_id">
<option value="">-- {% trans "Select Category" %} --</option>
{% for category in categories %}
<option value="{{ category.id }}">
<i class="{{ category.icon }}"></i> {{ category.name_en }}
</option>
{% endfor %}
</select>
</div>
<!-- Message (English) -->
<div class="mb-4">
<label for="message_en" class="form-label">
{% trans "Message (English)" %} <span class="text-red-500">*</span>
</label>
<textarea
class="form-control"
id="message_en"
name="message_en"
rows="4"
required
placeholder="{% trans 'Write your appreciation message here...' %}"
></textarea>
<p class="text-sm text-gray-500 mt-1">{% trans "Required: Appreciation message in English" %}</p>
</div>
<!-- Message (Arabic) -->
<div class="mb-4">
<label for="message_ar" class="form-label">{% trans "Message (Arabic)" %}</label>
<textarea
class="form-control"
id="message_ar"
name="message_ar"
rows="4"
dir="rtl"
placeholder="{% trans 'اكتب رسالة التقدير هنا...' %}"
></textarea>
<p class="text-sm text-gray-500 mt-1">{% trans "Optional: Appreciation message in Arabic" %}</p>
</div>
<!-- Visibility -->
<div class="mb-4">
<label for="visibility" class="form-label">{% trans "Visibility" %}</label>
<select class="form-select" id="visibility" name="visibility">
{% for choice in visibility_choices %}
<option value="{{ choice.0 }}" {% if choice.0 == 'private' %}selected{% endif %}>
{{ choice.1 }}
</option>
{% endfor %}
</select>
</div>
<!-- Anonymous -->
<div class="mb-6">
<div class="flex items-start gap-3">
<input
class="mt-1 w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
type="checkbox"
id="is_anonymous"
name="is_anonymous"
>
<div>
<label class="text-sm font-medium text-gray-700" for="is_anonymous">
{% trans "Send anonymously" %}
</label>
<div class="form-text">{% trans "Your name will not be shown to the recipient" %}</div>
<p class="text-sm text-gray-500">{% trans "Your name will not be shown to the recipient" %}</p>
</div>
</div>
</div>
<!-- Submit Button -->
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'appreciation:appreciation_list' %}" class="btn btn-outline-secondary">
<i class="bi-x me-2"></i>
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-success">
<i class="bi-send me-2"></i>
{% trans "Send Appreciation" %}
</button>
</div>
</form>
</div>
<!-- Submit Button -->
<div class="flex flex-wrap justify-end gap-3">
<a href="{% url 'appreciation:appreciation_list' %}" class="btn-secondary">
<i data-lucide="x" class="w-4 h-4"></i>
{% trans "Cancel" %}
</a>
<button type="submit" class="btn-primary">
<i data-lucide="send" class="w-4 h-4"></i>
{% trans "Send Appreciation" %}
</button>
</div>
</form>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<div class="lg:col-span-1">
<!-- Tips -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="bi-lightbulb me-2"></i>
{% trans "Tips for Writing Appreciation" %}
</h6>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0">
<li class="mb-2">
<i class="bi-check text-success me-2"></i>
{% trans "Be specific about what you appreciate" %}
</li>
<li class="mb-2">
<i class="bi-check text-success me-2"></i>
{% trans "Use the person's name when addressing them" %}
</li>
<li class="mb-2">
<i class="bi-check text-success me-2"></i>
{% trans "Mention the impact of their actions" %}
</li>
<li class="mb-2">
<i class="bi-check text-success me-2"></i>
{% trans "Be sincere and authentic" %}
</li>
<li>
<i class="bi-check text-success me-2"></i>
{% trans "Keep it positive and uplifting" %}
</li>
</ul>
</div>
<div class="form-section">
<h6 class="font-semibold text-gray-800 mb-4 flex items-center gap-2">
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-500"></i>
{% trans "Tips for Writing Appreciation" %}
</h6>
<ul class="space-y-2 text-sm">
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
{% trans "Be specific about what you appreciate" %}
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
{% trans "Use the person's name when addressing them" %}
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
{% trans "Mention the impact of their actions" %}
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
{% trans "Be sincere and authentic" %}
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
{% trans "Keep it positive and uplifting" %}
</li>
</ul>
</div>
<!-- Visibility Guide -->
<div class="card shadow-sm">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="bi-info-circle me-2"></i>
{% trans "Visibility Levels" %}
</h6>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0">
<li class="mb-2">
<strong>{% trans "Private:" %}</strong>
<p class="small text-muted mb-0">
{% trans "Only you and the recipient can see this appreciation" %}
</p>
</li>
<li class="mb-2">
<strong>{% trans "Department:" %}</strong>
<p class="small text-muted mb-0">
{% trans "Visible to everyone in the selected department" %}
</p>
</li>
<li class="mb-2">
<strong>{% trans "Hospital:" %}</strong>
<p class="small text-muted mb-0">
{% trans "Visible to everyone in the selected hospital" %}
</p>
</li>
<li>
<strong>{% trans "Public:" %}</strong>
<p class="small text-muted mb-0">
{% trans "Visible to all PX360 users" %}
</p>
</li>
</ul>
</div>
<div class="form-section">
<h6 class="font-semibold text-gray-800 mb-4 flex items-center gap-2">
<i data-lucide="info" class="w-4 h-4 text-blue-500"></i>
{% trans "Visibility Levels" %}
</h6>
<ul class="space-y-3 text-sm">
<li>
<strong class="text-gray-800">{% trans "Private:" %}</strong>
<p class="text-gray-500 text-xs">
{% trans "Only you and the recipient can see this appreciation" %}
</p>
</li>
<li>
<strong class="text-gray-800">{% trans "Department:" %}</strong>
<p class="text-gray-500 text-xs">
{% trans "Visible to everyone in the selected department" %}
</p>
</li>
<li>
<strong class="text-gray-800">{% trans "Hospital:" %}</strong>
<p class="text-gray-500 text-xs">
{% trans "Visible to everyone in the selected hospital" %}
</p>
</li>
<li>
<strong class="text-gray-800">{% trans "Public:" %}</strong>
<p class="text-gray-500 text-xs">
{% trans "Visible to all PX360 users" %}
</p>
</li>
</ul>
</div>
</div>
</div>
@ -240,31 +311,26 @@
{{ block.super }}
<script>
document.addEventListener('DOMContentLoaded', function() {
const hospitalSelect = document.getElementById('hospital_id');
const recipientTypeSelect = document.getElementById('recipient_type');
const recipientSelect = document.getElementById('recipient_id');
const departmentSelect = document.getElementById('department_id');
const recipientHelp = document.getElementById('recipientHelp');
const currentHospitalId = '{{ current_hospital.id|default:"" }}';
let recipientData = [];
// Load recipients when hospital changes
hospitalSelect.addEventListener('change', function() {
const hospitalId = this.value;
// Load recipients and departments on page load
function loadRecipientsAndDepartments() {
const hospitalId = currentHospitalId;
const recipientType = recipientTypeSelect.value;
if (!hospitalId) return;
// Load recipients
recipientSelect.disabled = true;
recipientSelect.innerHTML = '<option value="">Loading...</option>';
recipientHelp.textContent = 'Loading recipients...';
if (!hospitalId) {
recipientSelect.innerHTML = '<option value="">-- Select Recipient --</option>';
recipientSelect.disabled = true;
recipientHelp.textContent = 'Select a hospital first';
return;
}
// Fetch recipients
const url = recipientType === 'user'
? "{% url 'appreciation:get_users_by_hospital' %}?hospital_id=" + hospitalId
: "{% url 'appreciation:get_physicians_by_hospital' %}?hospital_id=" + hospitalId;
@ -290,16 +356,10 @@ document.addEventListener('DOMContentLoaded', function() {
recipientSelect.innerHTML = '<option value="">Error loading recipients</option>';
recipientHelp.textContent = 'Error loading recipients';
});
});
// Load departments when hospital changes
hospitalSelect.addEventListener('change', function() {
const hospitalId = this.value;
// Load departments
departmentSelect.innerHTML = '<option value="">-- Select Department --</option>';
if (!hospitalId) return;
fetch("{% url 'appreciation:get_departments_by_hospital' %}?hospital_id=" + hospitalId)
.then(response => response.json())
.then(data => {
@ -313,14 +373,13 @@ document.addEventListener('DOMContentLoaded', function() {
.catch(error => {
console.error('Error:', error);
});
});
}
// Refresh recipients when recipient type changes
recipientTypeSelect.addEventListener('change', function() {
if (hospitalSelect.value) {
hospitalSelect.dispatchEvent(new Event('change'));
}
});
// Load on page load
loadRecipientsAndDepartments();
// Refresh when recipient type changes
recipientTypeSelect.addEventListener('change', loadRecipientsAndDepartments);
});
</script>
{% endblock %}

View File

@ -3,209 +3,285 @@
{% block title %}{% if form.instance.pk %}{% trans "Edit Badge" %}{% else %}{% trans "Add Badge" %}{% endif %} - {% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.form-section {
background: #fff;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
}
.form-section:hover {
border-color: #005696;
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
}
.form-control, .form-select {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
outline: none;
border-color: #005696;
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #005696;
color: white;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
border: none;
cursor: pointer;
}
.btn-primary:hover {
background: #007bbd;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: white;
color: #64748b;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #005696;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="p-4">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'appreciation:badge_list' %}">{% trans "Badges" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Add" %}{% endif %}</li>
<ol class="flex flex-wrap items-center gap-2 text-sm">
<li><a href="{% url 'appreciation:appreciation_list' %}" class="text-blue-600 hover:text-blue-800">{% trans "Appreciation" %}</a></li>
<li class="text-gray-400"><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li><a href="{% url 'appreciation:badge_list' %}" class="text-blue-600 hover:text-blue-800">{% trans "Badges" %}</a></li>
<li class="text-gray-400"><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li class="text-gray-600 font-medium" aria-current="page">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Add" %}{% endif %}</li>
</ol>
</nav>
<!-- Form -->
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header bg-warning text-dark">
<h4 class="mb-0">
<i class="bi bi-trophy me-2"></i>
{% if form.instance.pk %}{% trans "Edit Badge" %}{% else %}{% trans "Add Badge" %}{% endif %}
</h4>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<!-- Name (English) -->
<div class="mb-3">
<label for="id_name_en" class="form-label">
{% trans "Name (English)" %} <span class="text-danger">*</span>
<!-- Header -->
<div class="page-header-gradient">
<h1 class="text-2xl font-bold flex items-center gap-3">
<i data-lucide="trophy" class="w-6 h-6"></i>
{% if form.instance.pk %}{% trans "Edit Badge" %}{% else %}{% trans "Add Badge" %}{% endif %}
</h1>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Form -->
<div class="lg:col-span-2">
<div class="form-section">
<form method="post">
{% csrf_token %}
<!-- Name (English) -->
<div class="mb-4">
<label for="id_name_en" class="form-label">
{% trans "Name (English)" %} <span class="text-red-500">*</span>
</label>
<input
type="text"
class="form-control"
id="id_name_en"
name="name_en"
value="{{ form.name_en.value|default:'' }}"
required
>
</div>
<!-- Name (Arabic) -->
<div class="mb-4">
<label for="id_name_ar" class="form-label">
{% trans "Name (Arabic)" %} <span class="text-red-500">*</span>
</label>
<input
type="text"
class="form-control"
id="id_name_ar"
name="name_ar"
value="{{ form.name_ar.value|default:'' }}"
dir="rtl"
required
>
</div>
<!-- Description (English) -->
<div class="mb-4">
<label for="id_description_en" class="form-label">{% trans "Description (English)" %}</label>
<textarea
class="form-control"
id="id_description_en"
name="description_en"
rows="3"
>{{ form.description_en.value|default:'' }}</textarea>
</div>
<!-- Description (Arabic) -->
<div class="mb-4">
<label for="id_description_ar" class="form-label">{% trans "Description (Arabic)" %}</label>
<textarea
class="form-control"
id="id_description_ar"
name="description_ar"
rows="3"
dir="rtl"
>{{ form.description_ar.value|default:'' }}</textarea>
</div>
<!-- Icon -->
<div class="mb-4">
<label for="id_icon" class="form-label">{% trans "Icon" %}</label>
<input
type="text"
class="form-control"
id="id_icon"
name="icon"
value="{{ form.icon.value|default:'fa-trophy' }}"
placeholder="fa-trophy"
>
<p class="text-sm text-gray-500 mt-1">
{% trans "FontAwesome icon class (e.g., fa-trophy, fa-star, fa-medal)" %}
</p>
</div>
<!-- Criteria Type -->
<div class="mb-4">
<label for="id_criteria_type" class="form-label">{% trans "Criteria Type" %}</label>
<select class="form-select" id="id_criteria_type" name="criteria_type">
{% for choice in form.fields.criteria_type.choices %}
<option value="{{ choice.0 }}" {% if choice.0 == form.criteria_type.value|stringformat:'s' or (not form.criteria_type.value and choice.0 == 'count') %}selected{% endif %}>
{{ choice.1 }}
</option>
{% endfor %}
</select>
</div>
<!-- Criteria Value -->
<div class="mb-4">
<label for="id_criteria_value" class="form-label">{% trans "Criteria Value" %}</label>
<input
type="number"
class="form-control"
id="id_criteria_value"
name="criteria_value"
value="{{ form.criteria_value.value|default:'' }}"
min="1"
required
>
<p class="text-sm text-gray-500 mt-1">
{% trans "Number of appreciations required to earn this badge" %}
</p>
</div>
<!-- Is Active -->
<div class="mb-6">
<div class="flex items-center gap-3">
<input
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
type="checkbox"
id="id_is_active"
name="is_active"
{% if form.is_active.value %}checked{% endif %}
>
<label class="text-sm font-medium text-gray-700" for="id_is_active">
{% trans "Active" %}
</label>
<input
type="text"
class="form-control"
id="id_name_en"
name="name_en"
value="{{ form.name_en.value|default:'' }}"
required
>
</div>
</div>
<!-- Name (Arabic) -->
<div class="mb-3">
<label for="id_name_ar" class="form-label">
{% trans "Name (Arabic)" %} <span class="text-danger">*</span>
</label>
<input
type="text"
class="form-control"
id="id_name_ar"
name="name_ar"
value="{{ form.name_ar.value|default:'' }}"
dir="rtl"
required
>
</div>
<!-- Description (English) -->
<div class="mb-3">
<label for="id_description_en" class="form-label">{% trans "Description (English)" %}</label>
<textarea
class="form-control"
id="id_description_en"
name="description_en"
rows="3"
>{{ form.description_en.value|default:'' }}</textarea>
</div>
<!-- Description (Arabic) -->
<div class="mb-3">
<label for="id_description_ar" class="form-label">{% trans "Description (Arabic)" %}</label>
<textarea
class="form-control"
id="id_description_ar"
name="description_ar"
rows="3"
dir="rtl"
>{{ form.description_ar.value|default:'' }}</textarea>
</div>
<!-- Icon -->
<div class="mb-3">
<label for="id_icon" class="form-label">{% trans "Icon" %}</label>
<input
type="text"
class="form-control"
id="id_icon"
name="icon"
value="{{ form.icon.value|default:'fa-trophy' }}"
placeholder="fa-trophy"
>
<div class="form-text">
{% trans "FontAwesome icon class (e.g., fa-trophy, fa-star, fa-medal)" %}
</div>
</div>
<!-- Criteria Type -->
<div class="mb-3">
<label for="id_criteria_type" class="form-label">{% trans "Criteria Type" %}</label>
<select class="form-select" id="id_criteria_type" name="criteria_type">
{% for choice in form.fields.criteria_type.choices %}
<option value="{{ choice.0 }}" {% if choice.0 == form.criteria_type.value|stringformat:'s' or (not form.criteria_type.value and choice.0 == 'count') %}selected{% endif %}>
{{ choice.1 }}
</option>
{% endfor %}
</select>
</div>
<!-- Criteria Value -->
<div class="mb-3">
<label for="id_criteria_value" class="form-label">{% trans "Criteria Value" %}</label>
<input
type="number"
class="form-control"
id="id_criteria_value"
name="criteria_value"
value="{{ form.criteria_value.value|default:'' }}"
min="1"
required
>
<div class="form-text">
{% trans "Number of appreciations required to earn this badge" %}
</div>
</div>
<!-- Is Active -->
<div class="mb-4">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="id_is_active"
name="is_active"
{% if form.is_active.value %}checked{% endif %}
>
<label class="form-check-label" for="id_is_active">
{% trans "Active" %}
</label>
</div>
</div>
<!-- Buttons -->
<div class="d-flex gap-2">
<a href="{% url 'appreciation:badge_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-x me-2"></i>
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-warning text-dark">
<i class="bi bi-save me-2"></i>
{% if form.instance.pk %}{% trans "Update" %}{% else %}{% trans "Create" %}{% endif %}
</button>
</div>
</form>
</div>
<!-- Buttons -->
<div class="flex flex-wrap gap-3">
<a href="{% url 'appreciation:badge_list' %}" class="btn-secondary">
<i data-lucide="x" class="w-4 h-4"></i>
{% trans "Cancel" %}
</a>
<button type="submit" class="btn-primary">
<i data-lucide="save" class="w-4 h-4"></i>
{% if form.instance.pk %}{% trans "Update" %}{% else %}{% trans "Create" %}{% endif %}
</button>
</div>
</form>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<div class="lg:col-span-1">
<!-- Icon Preview -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h6 class="card-title mb-0">{% trans "Badge Preview" %}</h6>
</div>
<div class="card-body text-center">
<div class="badge-icon-wrapper mb-3">
<i id="icon-preview" class="{{ form.icon.value|default:'bi-trophy' }} fa-4x text-warning"></i>
</div>
<p id="name-preview" class="h5 mb-2">{{ form.name_en.value|default:'Badge Name' }}</p>
<p class="small text-muted mb-0">
<strong>{% trans "Requires" %}: </strong>
<span id="criteria-preview">{{ form.criteria_value.value|default:0 }}</span>
{% trans "appreciations" %}
</p>
<div class="form-section text-center">
<h6 class="font-semibold text-gray-800 mb-4">{% trans "Badge Preview" %}</h6>
<div class="mb-4">
<i id="icon-preview" class="{{ form.icon.value|default:'bi-trophy' }} fa-4x text-yellow-500"></i>
</div>
<p id="name-preview" class="text-lg font-semibold mb-2">{{ form.name_en.value|default:'Badge Name' }}</p>
<p class="text-sm text-gray-500">
<strong>{% trans "Requires" %}: </strong>
<span id="criteria-preview">{{ form.criteria_value.value|default:0 }}</span>
{% trans "appreciations" %}
</p>
</div>
<!-- Criteria Information -->
<div class="card shadow-sm">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="bi bi-info-circle me-2"></i>
{% trans "About Badge Criteria" %}
</h6>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0 small">
<li class="mb-2">
<strong>{% trans "Count:" %}</strong>
<p class="mb-0 text-muted">
{% trans "Badge is earned after receiving the specified number of appreciations" %}
</p>
</li>
<li>
<strong>{% trans "Tips:" %}</strong>
<ul class="mb-0 ps-3 text-muted">
<li>{% trans "Set achievable criteria to encourage participation" %}</li>
<li>{% trans "Use descriptive names and icons" %}</li>
<li>{% trans "Create badges for different achievement levels" %}</li>
<li>{% trans "Deactivate badges instead of deleting to preserve history" %}</li>
</ul>
</li>
</ul>
</div>
<div class="form-section">
<h6 class="font-semibold text-gray-800 mb-4 flex items-center gap-2">
<i data-lucide="info" class="w-4 h-4 text-blue-500"></i>
{% trans "About Badge Criteria" %}
</h6>
<ul class="space-y-3 text-sm">
<li>
<strong class="text-gray-800">{% trans "Count:" %}</strong>
<p class="text-gray-500 text-xs">
{% trans "Badge is earned after receiving the specified number of appreciations" %}
</p>
</li>
<li>
<strong class="text-gray-800">{% trans "Tips:" %}</strong>
<ul class="text-gray-500 text-xs mt-1 space-y-1">
<li>{% trans "Set achievable criteria to encourage participation" %}</li>
<li>{% trans "Use descriptive names and icons" %}</li>
<li>{% trans "Create badges for different achievement levels" %}</li>
<li>{% trans "Deactivate badges instead of deleting to preserve history" %}</li>
</ul>
</li>
</ul>
</div>
</div>
</div>

View File

@ -1,137 +1,202 @@
{% extends "layouts/base.html" %}
{% load i18n static %}
{% block title %}{% trans "Appreciation Badges" %} - {% endblock %}
{% block title %}{% trans "Appreciation Badges" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.badge-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
overflow: hidden;
transition: all 0.3s ease;
}
.badge-card:hover {
border-color: #f59e0b;
box-shadow: 0 10px 25px -5px rgba(245, 158, 11, 0.2);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% trans "Badges" %}</li>
</ol>
</nav>
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>
<i class="bi-award text-warning me-2"></i>
{% trans "Appreciation Badges" %}
</h2>
<a href="{% url 'appreciation:badge_create' %}" class="btn btn-primary">
<i class="bi-plus me-2"></i>
{% trans "Add Badge" %}
<!-- Page Header Gradient -->
<div class="page-header-gradient">
<div class="flex justify-between items-start">
<div>
<h1 class="text-2xl font-bold flex items-center gap-3">
<i data-lucide="award" class="w-7 h-7"></i>
{% trans "Appreciation Badges" %}
</h1>
<p class="text-sm opacity-90 mt-1">{% trans "Create and manage badges to recognize achievements" %}</p>
</div>
<a href="{% url 'appreciation:badge_create' %}" class="bg-white text-navy px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg hover:bg-gray-100 flex items-center gap-2 transition">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add Badge" %}
</a>
</div>
</div>
<!-- Badges List -->
<div class="card shadow-sm">
<div class="card-body">
{% if badges %}
<div class="row">
{% for badge in badges %}
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 border-2 border-warning">
<div class="card-body">
<div class="text-center mb-3">
<i class="{{ badge.icon }} fa-4x text-warning"></i>
</div>
<h5 class="card-title text-center">{{ badge.name_en }}</h5>
<p class="card-text small text-muted text-center">
{{ badge.description_en|truncatewords:10 }}
</p>
<hr>
<ul class="list-unstyled small mb-3">
<li class="mb-2">
<strong>{% trans "Type:" %}</strong>
{{ badge.get_criteria_type_display }}
</li>
<li class="mb-2">
<strong>{% trans "Value:" %}</strong>
{{ badge.criteria_value }}
</li>
<li class="mb-2">
<strong>{% trans "Earned:" %}</strong>
{{ badge.earned_count }} {% trans "times" %}
</li>
<li>
<strong>{% trans "Status:" %}</strong>
{% if badge.is_active %}
<span class="badge bg-success">{% trans "Active" %}</span>
{% else %}
<span class="badge bg-secondary">{% trans "Inactive" %}</span>
{% endif %}
</li>
</ul>
<div class="d-grid gap-2">
<a href="{% url 'appreciation:badge_edit' badge.id %}" class="btn btn-sm btn-outline-primary">
<i class="bi-pencil me-2"></i>{% trans "Edit" %}
</a>
<a href="{% url 'appreciation:badge_delete' badge.id %}" class="btn btn-sm btn-outline-danger">
<i class="bi-trash me-2"></i>{% trans "Delete" %}
</a>
</div>
<!-- Breadcrumb -->
<nav class="mb-6 text-sm">
<ol class="flex items-center gap-2 text-slate">
<li><a href="{% url 'appreciation:appreciation_list' %}" class="hover:text-navy transition">{% trans "Appreciation" %}</a></li>
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li class="text-navy font-medium">{% trans "Badges" %}</li>
</nav>
</nav>
<!-- Badges List -->
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-amber-100">
<i data-lucide="award" class="w-5 h-5 text-amber-600"></i>
</div>
<h3 class="font-bold text-navy">{% trans "All Badges" %}</h3>
</div>
<div class="p-6">
{% if badges %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for badge in badges %}
<div class="badge-card h-full flex flex-col">
<div class="p-6 flex-1">
<div class="text-center mb-4">
<div class="w-20 h-20 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="{{ badge.icon|default:'award' }}" class="w-10 h-10 text-amber-600"></i>
</div>
<h5 class="font-bold text-navy text-lg">{{ badge.name_en }}</h5>
<p class="text-sm text-slate mt-2">{{ badge.description_en|truncatewords:10 }}</p>
</div>
<hr class="border-slate-200 my-4">
<ul class="space-y-2 text-sm">
<li class="flex justify-between">
<span class="text-slate">{% trans "Type:" %}</span>
<span class="font-medium text-navy">{{ badge.get_criteria_type_display }}</span>
</li>
<li class="flex justify-between">
<span class="text-slate">{% trans "Value:" %}</span>
<span class="font-medium text-navy">{{ badge.criteria_value }}</span>
</li>
<li class="flex justify-between">
<span class="text-slate">{% trans "Earned:" %}</span>
<span class="font-medium text-navy">{{ badge.earned_count }} {% trans "times" %}</span>
</li>
<li class="flex justify-between items-center">
<span class="text-slate">{% trans "Status:" %}</span>
{% if badge.is_active %}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-green-100 text-green-700">
{% trans "Active" %}
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-gray-100 text-gray-700">
{% trans "Inactive" %}
</span>
{% endif %}
</li>
</ul>
</div>
{% endfor %}
</div>
<div class="p-4 border-t border-slate-200 bg-slate-50 flex gap-2">
<a href="{% url 'appreciation:badge_edit' badge.id %}" class="flex-1 bg-white border border-navy text-navy px-4 py-2 rounded-lg text-sm font-medium hover:bg-navy hover:text-white transition flex items-center justify-center gap-2">
<i data-lucide="pencil" class="w-4 h-4"></i> {% trans "Edit" %}
</a>
<a href="{% url 'appreciation:badge_delete' badge.id %}" class="flex-1 bg-white border border-red-500 text-red-500 px-4 py-2 rounded-lg text-sm font-medium hover:bg-red-500 hover:text-white transition flex items-center justify-center gap-2">
<i data-lucide="trash-2" class="w-4 h-4"></i> {% trans "Delete" %}
</a>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">
{% trans "Previous" %}
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">{% trans "Previous" %}</span>
</li>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<div class="mt-8 pt-6 border-t border-slate-100 flex justify-center">
<div class="flex items-center gap-2">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}"
class="px-3 py-2 rounded-lg border border-slate-200 text-slate hover:bg-light transition">
{% trans "Previous" %}
</a>
{% else %}
<span class="px-3 py-2 rounded-lg border border-slate-200 text-gray-300 cursor-not-allowed">
{% trans "Previous" %}
</span>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<span class="px-4 py-2 rounded-lg bg-navy text-white font-bold">{{ num }}</span>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<a href="?page={{ num }}"
class="px-4 py-2 rounded-lg border border-slate-200 text-slate hover:bg-light transition">
{{ num }}
</a>
{% endif %}
{% endfor %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">
{% trans "Next" %}
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">{% trans "Next" %}</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="bi-award fa-4x text-muted mb-3"></i>
<h4 class="text-muted">{% trans "No badges found" %}</h4>
<p class="text-muted mb-3">{% trans "Create badges to motivate and recognize achievements" %}</p>
<a href="{% url 'appreciation:badge_create' %}" class="btn btn-primary">
<i class="bi-plus me-2"></i>
{% trans "Add Badge" %}
</a>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}"
class="px-3 py-2 rounded-lg border border-slate-200 text-slate hover:bg-light transition">
{% trans "Next" %}
</a>
{% else %}
<span class="px-3 py-2 rounded-lg border border-slate-200 text-gray-300 cursor-not-allowed">
{% trans "Next" %}
</span>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% else %}
<div class="text-center py-12">
<div class="w-16 h-16 bg-amber-50 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="award" class="w-8 h-8 text-amber-400"></i>
</div>
<h4 class="text-lg font-bold text-navy mb-2">{% trans "No badges found" %}</h4>
<p class="text-slate mb-4">{% trans "Create badges to motivate and recognize achievements" %}</p>
<a href="{% url 'appreciation:badge_create' %}" class="bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-blue transition inline-flex items-center gap-2">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add Badge" %}
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -3,186 +3,262 @@
{% block title %}{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "Add Category" %}{% endif %} - {% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.form-section {
background: #fff;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
}
.form-section:hover {
border-color: #005696;
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
}
.form-control, .form-select {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
outline: none;
border-color: #005696;
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #005696;
color: white;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
border: none;
cursor: pointer;
}
.btn-primary:hover {
background: #007bbd;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: white;
color: #64748b;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #005696;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="p-4">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'appreciation:category_list' %}">{% trans "Categories" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Add" %}{% endif %}</li>
<ol class="flex flex-wrap items-center gap-2 text-sm">
<li><a href="{% url 'appreciation:appreciation_list' %}" class="text-blue-600 hover:text-blue-800">{% trans "Appreciation" %}</a></li>
<li class="text-gray-400"><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li><a href="{% url 'appreciation:category_list' %}" class="text-blue-600 hover:text-blue-800">{% trans "Categories" %}</a></li>
<li class="text-gray-400"><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li class="text-gray-600 font-medium" aria-current="page">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Add" %}{% endif %}</li>
</ol>
</nav>
<!-- Form -->
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">
<i class="bi bi-tag me-2"></i>
{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "Add Category" %}{% endif %}
</h4>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<!-- Name (English) -->
<div class="mb-3">
<label for="id_name_en" class="form-label">
{% trans "Name (English)" %} <span class="text-danger">*</span>
<!-- Header -->
<div class="page-header-gradient">
<h1 class="text-2xl font-bold flex items-center gap-3">
<i data-lucide="tag" class="w-6 h-6"></i>
{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "Add Category" %}{% endif %}
</h1>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Form -->
<div class="lg:col-span-2">
<div class="form-section">
<form method="post">
{% csrf_token %}
<!-- Name (English) -->
<div class="mb-4">
<label for="id_name_en" class="form-label">
{% trans "Name (English)" %} <span class="text-red-500">*</span>
</label>
<input
type="text"
class="form-control"
id="id_name_en"
name="name_en"
value="{{ form.name_en.value|default:'' }}"
required
>
</div>
<!-- Name (Arabic) -->
<div class="mb-4">
<label for="id_name_ar" class="form-label">
{% trans "Name (Arabic)" %} <span class="text-red-500">*</span>
</label>
<input
type="text"
class="form-control"
id="id_name_ar"
name="name_ar"
value="{{ form.name_ar.value|default:'' }}"
dir="rtl"
required
>
</div>
<!-- Description (English) -->
<div class="mb-4">
<label for="id_description_en" class="form-label">{% trans "Description (English)" %}</label>
<textarea
class="form-control"
id="id_description_en"
name="description_en"
rows="3"
>{{ form.description_en.value|default:'' }}</textarea>
</div>
<!-- Description (Arabic) -->
<div class="mb-4">
<label for="id_description_ar" class="form-label">{% trans "Description (Arabic)" %}</label>
<textarea
class="form-control"
id="id_description_ar"
name="description_ar"
rows="3"
dir="rtl"
>{{ form.description_ar.value|default:'' }}</textarea>
</div>
<!-- Icon -->
<div class="mb-4">
<label for="id_icon" class="form-label">{% trans "Icon" %}</label>
<input
type="text"
class="form-control"
id="id_icon"
name="icon"
value="{{ form.icon.value|default:'fa-heart' }}"
placeholder="fa-heart"
>
<p class="text-sm text-gray-500 mt-1">
{% trans "FontAwesome icon class (e.g., fa-heart, fa-star, fa-thumbs-up)" %}
</p>
</div>
<!-- Color -->
<div class="mb-4">
<label for="id_color" class="form-label">{% trans "Color" %}</label>
<select class="form-select" id="id_color" name="color">
{% for choice in form.fields.color.choices %}
<option value="{{ choice.0 }}" {% if choice.0 == form.color.value|stringformat:'s' or (not form.color.value and choice.0 == 'primary') %}selected{% endif %}>
{{ choice.1 }}
</option>
{% endfor %}
</select>
</div>
<!-- Is Active -->
<div class="mb-6">
<div class="flex items-center gap-3">
<input
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
type="checkbox"
id="id_is_active"
name="is_active"
{% if form.is_active.value %}checked{% endif %}
>
<label class="text-sm font-medium text-gray-700" for="id_is_active">
{% trans "Active" %}
</label>
<input
type="text"
class="form-control"
id="id_name_en"
name="name_en"
value="{{ form.name_en.value|default:'' }}"
required
>
</div>
</div>
<!-- Name (Arabic) -->
<div class="mb-3">
<label for="id_name_ar" class="form-label">
{% trans "Name (Arabic)" %} <span class="text-danger">*</span>
</label>
<input
type="text"
class="form-control"
id="id_name_ar"
name="name_ar"
value="{{ form.name_ar.value|default:'' }}"
dir="rtl"
required
>
</div>
<!-- Description (English) -->
<div class="mb-3">
<label for="id_description_en" class="form-label">{% trans "Description (English)" %}</label>
<textarea
class="form-control"
id="id_description_en"
name="description_en"
rows="3"
>{{ form.description_en.value|default:'' }}</textarea>
</div>
<!-- Description (Arabic) -->
<div class="mb-3">
<label for="id_description_ar" class="form-label">{% trans "Description (Arabic)" %}</label>
<textarea
class="form-control"
id="id_description_ar"
name="description_ar"
rows="3"
dir="rtl"
>{{ form.description_ar.value|default:'' }}</textarea>
</div>
<!-- Icon -->
<div class="mb-3">
<label for="id_icon" class="form-label">{% trans "Icon" %}</label>
<input
type="text"
class="form-control"
id="id_icon"
name="icon"
value="{{ form.icon.value|default:'fa-heart' }}"
placeholder="fa-heart"
>
<div class="form-text">
{% trans "FontAwesome icon class (e.g., fa-heart, fa-star, fa-thumbs-up)" %}
</div>
</div>
<!-- Color -->
<div class="mb-3">
<label for="id_color" class="form-label">{% trans "Color" %}</label>
<select class="form-select" id="id_color" name="color">
{% for choice in form.fields.color.choices %}
<option value="{{ choice.0 }}" {% if choice.0 == form.color.value|stringformat:'s' or (not form.color.value and choice.0 == 'primary') %}selected{% endif %}>
{{ choice.1 }}
</option>
{% endfor %}
</select>
</div>
<!-- Is Active -->
<div class="mb-4">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="id_is_active"
name="is_active"
{% if form.is_active.value %}checked{% endif %}
>
<label class="form-check-label" for="id_is_active">
{% trans "Active" %}
</label>
</div>
</div>
<!-- Buttons -->
<div class="d-flex gap-2">
<a href="{% url 'appreciation:category_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-x me-2"></i>
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-2"></i>
{% if form.instance.pk %}{% trans "Update" %}{% else %}{% trans "Create" %}{% endif %}
</button>
</div>
</form>
</div>
<!-- Buttons -->
<div class="flex flex-wrap gap-3">
<a href="{% url 'appreciation:category_list' %}" class="btn-secondary">
<i data-lucide="x" class="w-4 h-4"></i>
{% trans "Cancel" %}
</a>
<button type="submit" class="btn-primary">
<i data-lucide="save" class="w-4 h-4"></i>
{% if form.instance.pk %}{% trans "Update" %}{% else %}{% trans "Create" %}{% endif %}
</button>
</div>
</form>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<div class="lg:col-span-1">
<!-- Icon Preview -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h6 class="card-title mb-0">{% trans "Icon Preview" %}</h6>
</div>
<div class="card-body text-center">
<i id="icon-preview" class="{{ form.icon.value|default:'bi-heart' }} fa-4x text-primary mb-3"></i>
<p id="name-preview" class="h5 mb-0">{{ form.name_en.value|default:'Category Name' }}</p>
</div>
<div class="form-section text-center">
<h6 class="font-semibold text-gray-800 mb-4">{% trans "Icon Preview" %}</h6>
<i id="icon-preview" class="{{ form.icon.value|default:'bi-heart' }} fa-4x text-blue-600 mb-4"></i>
<p id="name-preview" class="text-lg font-semibold">{{ form.name_en.value|default:'Category Name' }}</p>
</div>
<!-- Tips -->
<div class="card shadow-sm">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="bi bi-lightbulb me-2"></i>
{% trans "Tips" %}
</h6>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0 small">
<li class="mb-2">
<i class="bi bi-check text-success me-2"></i>
{% trans "Use descriptive names for categories" %}
</li>
<li class="mb-2">
<i class="bi bi-check text-success me-2"></i>
{% trans "Choose appropriate icons for each category" %}
</li>
<li class="mb-2">
<i class="bi bi-check text-success me-2"></i>
{% trans "Colors help users quickly identify categories" %}
</li>
<li>
<i class="bi bi-check text-success me-2"></i>
{% trans "Deactivate unused categories instead of deleting" %}
</li>
</ul>
</div>
<div class="form-section">
<h6 class="font-semibold text-gray-800 mb-4 flex items-center gap-2">
<i data-lucide="lightbulb" class="w-4 h-4 text-yellow-500"></i>
{% trans "Tips" %}
</h6>
<ul class="space-y-2 text-sm">
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
{% trans "Use descriptive names for categories" %}
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
{% trans "Choose appropriate icons for each category" %}
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
{% trans "Colors help users quickly identify categories" %}
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
{% trans "Deactivate unused categories instead of deleting" %}
</li>
</ul>
</div>
</div>
</div>

View File

@ -1,85 +1,179 @@
{% extends "layouts/base.html" %}
{% load i18n static %}
{% block title %}{% trans "Appreciation Categories" %} - {% endblock %}
{% block title %}{% trans "Appreciation Categories" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.category-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.category-table th {
padding: 0.875rem 1.5rem;
text-align: left;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
background: #f8fafc;
border-bottom: 2px solid #e2e8f0;
}
.category-table td {
padding: 1rem 1.5rem;
border-bottom: 1px solid #e2e8f0;
vertical-align: middle;
}
.category-table tr:hover td {
background: #f8fafc;
}
.category-table tr:last-child td {
border-bottom: none;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% trans "Categories" %}</li>
</ol>
</nav>
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>
<i class="bi bi-tags text-primary me-2"></i>
{% trans "Appreciation Categories" %}
</h2>
<a href="{% url 'appreciation:category_create' %}" class="btn btn-primary">
<i class="bi-plus me-2"></i>
{% trans "Add Category" %}
<!-- Page Header Gradient -->
<div class="page-header-gradient">
<div class="flex justify-between items-start">
<div>
<h1 class="text-2xl font-bold flex items-center gap-3">
<i data-lucide="tags" class="w-7 h-7"></i>
{% trans "Appreciation Categories" %}
</h1>
<p class="text-sm opacity-90 mt-1">{% trans "Manage categories to organize appreciations" %}</p>
</div>
<a href="{% url 'appreciation:category_create' %}" class="bg-white text-navy px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg hover:bg-gray-100 flex items-center gap-2 transition">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add Category" %}
</a>
</div>
</div>
<!-- Categories List -->
<div class="card shadow-sm">
<div class="card-body">
{% if categories %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>{% trans "Icon" %}</th>
<th>{% trans "Name (English)" %}</th>
<th>{% trans "Name (Arabic)" %}</th>
<th>{% trans "Color" %}</th>
<th>{% trans "Count" %}</th>
<th class="text-center">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for category in categories %}
<tr>
<td>
<i class="{{ category.icon }} fa-2x text-primary"></i>
</td>
<td>{{ category.name_en }}</td>
<td dir="rtl">{{ category.name_ar }}</td>
<td>
<span class="badge bg-{{ category.color }}">
{{ category.get_color_display }}
</span>
</td>
<td>{{ category.appreciation_count }}</td>
<td class="text-center">
<a href="{% url 'appreciation:category_edit' category.id %}" class="btn btn-sm btn-outline-primary">
<i class="bi-pencil"></i>
</a>
<a href="{% url 'appreciation:category_delete' category.id %}" class="btn btn-sm btn-outline-danger">
<i class="bi-trash"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-tags fa-4x text-muted mb-3"></i>
<h4 class="text-muted">{% trans "No categories found" %}</h4>
<p class="text-muted mb-3">{% trans "Create categories to organize appreciations" %}</p>
<a href="{% url 'appreciation:category_create' %}" class="btn btn-primary">
<i class="bi-plus me-2"></i>
{% trans "Add Category" %}
</a>
</div>
{% endif %}
<!-- Breadcrumb -->
<nav class="mb-6 text-sm">
<ol class="flex items-center gap-2 text-slate">
<li><a href="{% url 'appreciation:appreciation_list' %}" class="hover:text-navy transition">{% trans "Appreciation" %}</a></li>
<li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li class="text-navy font-medium">{% trans "Categories" %}</li>
</nav>
</nav>
<!-- Categories List -->
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-blue-100">
<i data-lucide="tags" class="w-5 h-5 text-blue-600"></i>
</div>
<h3 class="font-bold text-navy">{% trans "All Categories" %}</h3>
</div>
<div class="p-0">
{% if categories %}
<div class="overflow-x-auto">
<table class="category-table">
<thead>
<tr>
<th>{% trans "Icon" %}</th>
<th>{% trans "Name (English)" %}</th>
<th>{% trans "Name (Arabic)" %}</th>
<th>{% trans "Color" %}</th>
<th>{% trans "Count" %}</th>
<th class="text-center">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for category in categories %}
<tr>
<td>
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i data-lucide="{{ category.icon|default:'tag' }}" class="w-5 h-5 text-blue-600"></i>
</div>
</td>
<td>
<span class="font-medium text-navy">{{ category.name_en }}</span>
</td>
<td dir="rtl">
<span class="font-medium text-navy">{{ category.name_ar }}</span>
</td>
<td>
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold bg-{{ category.color }}-100 text-{{ category.color }}-700">
{{ category.get_color_display }}
</span>
</td>
<td>
<span class="inline-flex items-center justify-center w-8 h-8 bg-slate-100 rounded-full text-sm font-bold text-slate">
{{ category.appreciation_count }}
</span>
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-2">
<a href="{% url 'appreciation:category_edit' category.id %}" class="inline-flex items-center justify-center w-9 h-9 border border-navy text-navy rounded-lg hover:bg-navy hover:text-white transition">
<i data-lucide="pencil" class="w-4 h-4"></i>
</a>
<a href="{% url 'appreciation:category_delete' category.id %}" class="inline-flex items-center justify-center w-9 h-9 border border-red-500 text-red-500 rounded-lg hover:bg-red-500 hover:text-white transition">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-12">
<div class="w-16 h-16 bg-blue-50 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="tags" class="w-8 h-8 text-blue-400"></i>
</div>
<h4 class="text-lg font-bold text-navy mb-2">{% trans "No categories found" %}</h4>
<p class="text-slate mb-4">{% trans "Create categories to organize appreciations" %}</p>
<a href="{% url 'appreciation:category_create' %}" class="bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-blue transition inline-flex items-center gap-2">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add Category" %}
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -47,17 +47,6 @@
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label for="hospital" class="form-label">{% trans "Hospital" %}</label>
<select class="form-select" id="hospital" name="hospital">
<option value="">All Hospitals</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label for="department" class="form-label">{% trans "Department" %}</label>
<select class="form-select" id="department" name="department">

View File

@ -4,29 +4,80 @@
{% block title %}{% trans "Call Records" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %}
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-navy">{% trans "Call Records" %}</h1>
<p class="text-slate text-sm mt-1">{% trans "Manage and analyze imported call center recordings" %}</p>
</div>
<div class="flex items-center gap-3">
<a href="{% url 'callcenter:export_call_records_template' %}"
class="px-4 py-2 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-light transition flex items-center gap-2">
<i data-lucide="download" class="w-4 h-4"></i>
{% trans "Download Template" %}
</a>
<a href="{% url 'callcenter:import_call_records' %}"
class="px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2">
<i data-lucide="upload" class="w-4 h-4"></i>
{% trans "Import CSV" %}
</a>
<!-- Page Header -->
<div class="page-header-gradient">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="section-icon bg-white/20">
<i data-lucide="phone-call" class="w-6 h-6"></i>
</div>
<div>
<h1 class="text-2xl font-bold mb-1">{% trans "Call Records" %}</h1>
<p class="opacity-90 mb-0">{% trans "Manage and analyze imported call center recordings" %}</p>
</div>
</div>
<div class="flex items-center gap-3">
<a href="{% url 'callcenter:export_call_records_template' %}"
class="px-4 py-2 border border-white/30 text-white rounded-xl font-semibold hover:bg-white/20 transition flex items-center gap-2">
<i data-lucide="download" class="w-4 h-4"></i>
{% trans "Download Template" %}
</a>
<a href="{% url 'callcenter:import_call_records' %}"
class="px-4 py-2 bg-white text-blue-600 rounded-xl font-semibold hover:bg-blue-50 transition flex items-center gap-2">
<i data-lucide="upload" class="w-4 h-4"></i>
{% trans "Import CSV" %}
</a>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-6">
<!-- Total Calls -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<div class="flex items-center justify-between mb-4">
@ -89,72 +140,74 @@
</div>
<!-- Filters -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 mb-6">
<form method="get" class="flex flex-wrap gap-4">
<div class="flex-1 min-w-48">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Search" %}</label>
<input type="text" name="search" value="{{ filters.search }}"
placeholder="{% trans 'Name, department, extension...' %}"
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm">
<div class="section-card mb-6">
<div class="section-header">
<div class="section-icon bg-blue-100">
<i data-lucide="filter" class="w-5 h-5 text-blue-600"></i>
</div>
<h3 class="font-bold text-navy">{% trans "Filters" %}</h3>
</div>
<div class="p-5">
<form method="get" class="flex flex-wrap gap-4">
<div class="flex-1 min-w-48">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Search" %}</label>
<input type="text" name="search" value="{{ filters.search }}"
placeholder="{% trans 'Name, department, extension...' %}"
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm">
</div>
<div class="w-48">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Call Type" %}</label>
<select name="call_type" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm bg-white">
<option value="">{% trans "All Calls" %}</option>
<option value="inbound" {% if filters.call_type == 'inbound' %}selected{% endif %}>{% trans "Inbound" %}</option>
<option value="outbound" {% if filters.call_type == 'outbound' %}selected{% endif %}>{% trans "Outbound" %}</option>
</select>
</div>
<div class="w-48">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Call Type" %}</label>
<select name="call_type" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm bg-white">
<option value="">{% trans "All Calls" %}</option>
<option value="inbound" {% if filters.call_type == 'inbound' %}selected{% endif %}>{% trans "Inbound" %}</option>
<option value="outbound" {% if filters.call_type == 'outbound' %}selected{% endif %}>{% trans "Outbound" %}</option>
</select>
</div>
<div class="w-48">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Evaluated" %}</label>
<select name="evaluated" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm bg-white">
<option value="">{% trans "All" %}</option>
<option value="true" {% if filters.evaluated == 'true' %}selected{% endif %}>{% trans "Yes" %}</option>
<option value="false" {% if filters.evaluated == 'false' %}selected{% endif %}>{% trans "No" %}</option>
</select>
</div>
<div class="w-48">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Evaluated" %}</label>
<select name="evaluated" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm bg-white">
<option value="">{% trans "All" %}</option>
<option value="true" {% if filters.evaluated == 'true' %}selected{% endif %}>{% trans "Yes" %}</option>
<option value="false" {% if filters.evaluated == 'false' %}selected{% endif %}>{% trans "No" %}</option>
</select>
</div>
<div class="w-48">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Hospital" %}</label>
<select name="hospital" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm bg-white">
<option value="">{% trans "All Hospitals" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name }}
</option>
{% endfor %}
</select>
</div>
<div class="w-40">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "From Date" %}</label>
<input type="date" name="date_from" value="{{ filters.date_from }}"
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm">
</div>
<div class="w-40">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "From Date" %}</label>
<input type="date" name="date_from" value="{{ filters.date_from }}"
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm">
</div>
<div class="w-40">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "To Date" %}</label>
<input type="date" name="date_to" value="{{ filters.date_to }}"
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm">
</div>
<div class="w-40">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "To Date" %}</label>
<input type="date" name="date_to" value="{{ filters.date_to }}"
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy/20 text-sm">
</div>
<div class="flex items-end">
<button type="submit" class="px-6 py-2.5 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2">
<i data-lucide="search" class="w-4 h-4"></i>
{% trans "Filter" %}
</button>
<a href="{% url 'callcenter:call_records_list' %}"
class="px-4 py-2.5 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-light transition ml-2">
<i data-lucide="x" class="w-4 h-4"></i>
</a>
</div>
</form>
<div class="flex items-end">
<button type="submit" class="px-6 py-2.5 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2">
<i data-lucide="search" class="w-4 h-4"></i>
{% trans "Filter" %}
</button>
<a href="{% url 'callcenter:call_records_list' %}"
class="px-4 py-2.5 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-light transition ml-2">
<i data-lucide="x" class="w-4 h-4"></i>
</a>
</div>
</form>
</div>
</div>
<!-- Call Records Table -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-blue-100">
<i data-lucide="list" class="w-5 h-5 text-blue-600"></i>
</div>
<h3 class="font-bold text-navy">{% trans "Call Records" %}</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-slate-50 border-b border-slate-200">
@ -268,9 +321,3 @@
{% endif %}
</div>
{% endblock %}
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>

View File

@ -130,13 +130,9 @@
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label required-field">{% trans "Hospital" %}</label>
<select name="hospital_id" class="form-select" id="hospitalSelect" required>
<option value="">{% trans "Select hospital..." %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}">{{ hospital.name_en }}</option>
{% endfor %}
</select>
<label class="form-label">{% trans "Hospital" %}</label>
<input type="text" class="form-control" value="{{ current_hospital.name }}" readonly>
<input type="hidden" name="hospital_id" value="{{ current_hospital.id }}">
</div>
<div class="col-md-6 mb-3">
@ -283,7 +279,6 @@
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const hospitalSelect = document.getElementById('hospitalSelect');
const departmentSelect = document.getElementById('departmentSelect');
const physicianSelect = document.getElementById('physicianSelect');
const patientSearch = document.getElementById('patientSearch');
@ -293,47 +288,43 @@ document.addEventListener('DOMContentLoaded', function() {
const callerNameInput = document.getElementById('callerName');
const callerPhoneInput = document.getElementById('callerPhone');
// Hospital change handler - load departments and physicians
hospitalSelect.addEventListener('change', function() {
const hospitalId = this.value;
const currentHospitalId = '{{ current_hospital.id|default:"" }}';
// Load departments and physicians on page load
if (currentHospitalId) {
// Load departments
fetch(`/callcenter/ajax/departments/?hospital_id=${currentHospitalId}`)
.then(response => response.json())
.then(data => {
departmentSelect.innerHTML = '<option value="">{% trans "Select department..." %}</option>';
data.departments.forEach(dept => {
const option = document.createElement('option');
option.value = dept.id;
option.textContent = dept.name_en;
departmentSelect.appendChild(option);
});
})
.catch(error => console.error('Error loading departments:', error));
// Clear department and physician
departmentSelect.innerHTML = '<option value="">{% trans "Select department..." %}</option>';
physicianSelect.innerHTML = '<option value="">{% trans "Select physician..." %}</option>';
if (hospitalId) {
// Load departments
fetch(`/callcenter/ajax/departments/?hospital_id=${hospitalId}`)
.then(response => response.json())
.then(data => {
data.departments.forEach(dept => {
const option = document.createElement('option');
option.value = dept.id;
option.textContent = dept.name_en;
departmentSelect.appendChild(option);
});
})
.catch(error => console.error('Error loading departments:', error));
// Load physicians
fetch(`/callcenter/ajax/physicians/?hospital_id=${hospitalId}`)
.then(response => response.json())
.then(data => {
data.physicians.forEach(physician => {
const option = document.createElement('option');
option.value = physician.id;
option.textContent = `${physician.name} (${physician.specialty})`;
physicianSelect.appendChild(option);
});
})
.catch(error => console.error('Error loading physicians:', error));
}
});
// Load physicians
fetch(`/callcenter/ajax/physicians/?hospital_id=${currentHospitalId}`)
.then(response => response.json())
.then(data => {
physicianSelect.innerHTML = '<option value="">{% trans "Select physician..." %}</option>';
data.physicians.forEach(physician => {
const option = document.createElement('option');
option.value = physician.id;
option.textContent = `${physician.name} (${physician.specialty})`;
physicianSelect.appendChild(option);
});
})
.catch(error => console.error('Error loading physicians:', error));
}
// Patient search
function searchPatients() {
const query = patientSearch.value.trim();
const hospitalId = hospitalSelect.value;
const hospitalId = currentHospitalId;
if (query.length < 2) {
patientResults.innerHTML = '<div class="alert alert-warning small">{% trans "Please enter at least 2 characters to search" %}</div>';

View File

@ -6,6 +6,46 @@
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.badge-status-open { background: #e0f2fe; color: #075985; }
.badge-status-in_progress { background: #fef9c3; color: #854d0e; }
.badge-status-resolved { background: #dcfce7; color: #166534; }
@ -18,21 +58,23 @@
{% endblock %}
{% block content %}
<!-- Header -->
<header class="mb-6">
<div class="flex justify-between items-start">
<div>
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<i data-lucide="phone" class="w-7 h-7 text-blue"></i>
{% trans "Call Center Complaints" %}
</h1>
<p class="text-sm text-slate mt-1">{% trans "Complaints created via call center" %}</p>
<!-- Page Header -->
<div class="page-header-gradient">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="section-icon bg-white/20">
<i data-lucide="phone" class="w-6 h-6"></i>
</div>
<div>
<h1 class="text-2xl font-bold mb-1">{% trans "Call Center Complaints" %}</h1>
<p class="opacity-90 mb-0">{% trans "Complaints created via call center" %}</p>
</div>
</div>
<a href="{% url 'callcenter:create_complaint' %}" class="bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-navy/20 hover:bg-blue flex items-center gap-2 transition">
<a href="{% url 'callcenter:create_complaint' %}" class="bg-white text-blue-600 px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-blue-50 flex items-center gap-2 transition">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Create Complaint" %}
</a>
</div>
</header>
</div>
<!-- Statistics -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
@ -75,68 +117,57 @@
</div>
<!-- Filters -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 mb-6">
<div class="px-6 py-4 border-b-2 border-slate-200 flex justify-between items-center bg-gradient-to-r from-slate-50 to-slate-100">
<h3 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="filter" class="w-5 h-5 text-navy"></i>
{% trans "Filters" %}
</h3>
<div class="section-card mb-6">
<div class="section-header">
<div class="section-icon bg-blue-100">
<i data-lucide="filter" class="w-5 h-5 text-blue-600"></i>
</div>
<h3 class="font-bold text-navy">{% trans "Filters" %}</h3>
</div>
<div class="p-5">
<form method="get" class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div class="md:col-span-1">
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Search" %}</label>
<input type="text" name="search"
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition"
placeholder="{% trans 'Search...' %}" value="{{ filters.search }}">
</div>
<div>
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Status" %}</label>
<select name="status" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
<option value="">{% trans "All" %}</option>
<option value="open" {% if filters.status == 'open' %}selected{% endif %}>{% trans "Open" %}</option>
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
<option value="resolved" {% if filters.status == 'resolved' %}selected{% endif %}>{% trans "Resolved" %}</option>
<option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>{% trans "Closed" %}</option>
</select>
</div>
<div>
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Severity" %}</label>
<select name="severity" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
<option value="">{% trans "All" %}</option>
<option value="critical" {% if filters.severity == 'critical' %}selected{% endif %}>{% trans "Critical" %}</option>
<option value="high" {% if filters.severity == 'high' %}selected{% endif %}>{% trans "High" %}</option>
<option value="medium" {% if filters.severity == 'medium' %}selected{% endif %}>{% trans "Medium" %}</option>
<option value="low" {% if filters.severity == 'low' %}selected{% endif %}>{% trans "Low" %}</option>
</select>
</div>
<div>
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Hospital" %}</label>
<select name="hospital" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
<option value="">{% trans "All" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name_en }}
</option>
{% endfor %}
</select>
</div>
<div class="flex items-end">
<button type="submit" class="w-full bg-navy text-white px-4 py-2.5 rounded-xl text-sm font-bold hover:bg-blue transition flex items-center justify-center gap-2">
<i data-lucide="filter" class="w-4 h-4"></i> {% trans "Filter" %}
</button>
</div>
</form>
<form method="get" class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div class="md:col-span-1">
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Search" %}</label>
<input type="text" name="search"
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition"
placeholder="{% trans 'Search...' %}" value="{{ filters.search }}">
</div>
<div>
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Status" %}</label>
<select name="status" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
<option value="">{% trans "All" %}</option>
<option value="open" {% if filters.status == 'open' %}selected{% endif %}>{% trans "Open" %}</option>
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
<option value="resolved" {% if filters.status == 'resolved' %}selected{% endif %}>{% trans "Resolved" %}</option>
<option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>{% trans "Closed" %}</option>
</select>
</div>
<div>
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Severity" %}</label>
<select name="severity" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
<option value="">{% trans "All" %}</option>
<option value="critical" {% if filters.severity == 'critical' %}selected{% endif %}>{% trans "Critical" %}</option>
<option value="high" {% if filters.severity == 'high' %}selected{% endif %}>{% trans "High" %}</option>
<option value="medium" {% if filters.severity == 'medium' %}selected{% endif %}>{% trans "Medium" %}</option>
<option value="low" {% if filters.severity == 'low' %}selected{% endif %}>{% trans "Low" %}</option>
</select>
</div>
<div class="flex items-end">
<button type="submit" class="w-full bg-navy text-white px-4 py-2.5 rounded-xl text-sm font-bold hover:bg-blue transition flex items-center justify-center gap-2">
<i data-lucide="filter" class="w-4 h-4"></i> {% trans "Filter" %}
</button>
</div>
</form>
</div>
</div>
<!-- Complaints Table -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 overflow-hidden">
<div class="px-6 py-4 border-b-2 border-slate-200 bg-gradient-to-r from-slate-50 to-slate-100 flex justify-between items-center">
<h3 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="message-square-warning" class="w-5 h-5 text-red-500"></i>
{% trans "Complaints" %}
</h3>
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-red-100">
<i data-lucide="message-square-warning" class="w-5 h-5 text-red-600"></i>
</div>
<h3 class="font-bold text-navy">{% trans "Complaints" %}</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full">

View File

@ -134,13 +134,9 @@
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label required-field">{% trans "Hospital" %}</label>
<select name="hospital_id" class="form-select" id="hospitalSelect" required>
<option value="">{% trans "Select hospital..." %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}">{{ hospital.name_en }}</option>
{% endfor %}
</select>
<label class="form-label">{% trans "Hospital" %}</label>
<input type="text" class="form-control" value="{{ current_hospital.name }}" readonly>
<input type="hidden" name="hospital_id" value="{{ current_hospital.id }}">
</div>
<div class="col-md-6 mb-3">
@ -242,7 +238,6 @@
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const hospitalSelect = document.getElementById('hospitalSelect');
const departmentSelect = document.getElementById('departmentSelect');
const patientSearch = document.getElementById('patientSearch');
const searchBtn = document.getElementById('searchBtn');
@ -252,33 +247,28 @@ document.addEventListener('DOMContentLoaded', function() {
const contactPhoneInput = document.getElementById('contactPhone');
const contactEmailInput = document.getElementById('contactEmail');
// Hospital change handler - load departments
hospitalSelect.addEventListener('change', function() {
const hospitalId = this.value;
// Clear department
departmentSelect.innerHTML = '<option value="">{% trans "Select department..." %}</option>';
if (hospitalId) {
// Load departments
fetch(`/callcenter/ajax/departments/?hospital_id=${hospitalId}`)
.then(response => response.json())
.then(data => {
data.departments.forEach(dept => {
const option = document.createElement('option');
option.value = dept.id;
option.textContent = dept.name_en;
departmentSelect.appendChild(option);
});
})
.catch(error => console.error('Error loading departments:', error));
}
});
const currentHospitalId = '{{ current_hospital.id|default:"" }}';
// Load departments on page load
if (currentHospitalId) {
fetch(`/callcenter/ajax/departments/?hospital_id=${currentHospitalId}`)
.then(response => response.json())
.then(data => {
departmentSelect.innerHTML = '<option value="">{% trans "Select department..." %}</option>';
data.departments.forEach(dept => {
const option = document.createElement('option');
option.value = dept.id;
option.textContent = dept.name_en;
departmentSelect.appendChild(option);
});
})
.catch(error => console.error('Error loading departments:', error));
}
// Patient search
function searchPatients() {
const query = patientSearch.value.trim();
const hospitalId = hospitalSelect.value;
const hospitalId = currentHospitalId;
if (query.length < 2) {
patientResults.innerHTML = '<div class="alert alert-warning small">{% trans "Please enter at least 2 characters to search" %}</div>';

View File

@ -6,6 +6,46 @@
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.badge-status-open { background: #e0f2fe; color: #075985; }
.badge-status-in_progress { background: #fef9c3; color: #854d0e; }
.badge-status-resolved { background: #dcfce7; color: #166534; }
@ -14,21 +54,23 @@
{% endblock %}
{% block content %}
<!-- Header -->
<header class="mb-6">
<div class="flex justify-between items-start">
<div>
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<i data-lucide="phone" class="w-7 h-7 text-cyan-500"></i>
{% trans "Call Center Inquiries" %}
</h1>
<p class="text-sm text-slate mt-1">{% trans "Inquiries created via call center" %}</p>
<!-- Page Header -->
<div class="page-header-gradient">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="section-icon bg-white/20">
<i data-lucide="phone" class="w-6 h-6"></i>
</div>
<div>
<h1 class="text-2xl font-bold mb-1">{% trans "Call Center Inquiries" %}</h1>
<p class="opacity-90 mb-0">{% trans "Inquiries created via call center" %}</p>
</div>
</div>
<a href="{% url 'callcenter:create_inquiry' %}" class="bg-cyan-500 text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-cyan-200 hover:bg-cyan-600 flex items-center gap-2 transition">
<a href="{% url 'callcenter:create_inquiry' %}" class="bg-white text-blue-600 px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-blue-50 flex items-center gap-2 transition">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "Create Inquiry" %}
</a>
</div>
</header>
</div>
<!-- Statistics -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
@ -71,69 +113,58 @@
</div>
<!-- Filters -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 mb-6">
<div class="px-6 py-4 border-b-2 border-slate-200 flex justify-between items-center bg-gradient-to-r from-slate-50 to-slate-100">
<h3 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="filter" class="w-5 h-5 text-navy"></i>
{% trans "Filters" %}
</h3>
<div class="section-card mb-6">
<div class="section-header">
<div class="section-icon bg-blue-100">
<i data-lucide="filter" class="w-5 h-5 text-blue-600"></i>
</div>
<h3 class="font-bold text-navy">{% trans "Filters" %}</h3>
</div>
<div class="p-5">
<form method="get" class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div class="md:col-span-2">
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Search" %}</label>
<input type="text" name="search"
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition"
placeholder="{% trans 'Search...' %}" value="{{ filters.search }}">
</div>
<div>
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Status" %}</label>
<select name="status" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
<option value="">{% trans "All" %}</option>
<option value="open" {% if filters.status == 'open' %}selected{% endif %}>{% trans "Open" %}</option>
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
<option value="resolved" {% if filters.status == 'resolved' %}selected{% endif %}>{% trans "Resolved" %}</option>
<option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>{% trans "Closed" %}</option>
</select>
</div>
<div>
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Category" %}</label>
<select name="category" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
<option value="">{% trans "All" %}</option>
<option value="appointment" {% if filters.category == 'appointment' %}selected{% endif %}>{% trans "Appointment" %}</option>
<option value="billing" {% if filters.category == 'billing' %}selected{% endif %}>{% trans "Billing" %}</option>
<option value="medical_records" {% if filters.category == 'medical_records' %}selected{% endif %}>{% trans "Medical Records" %}</option>
<option value="general" {% if filters.category == 'general' %}selected{% endif %}>{% trans "General" %}</option>
<option value="other" {% if filters.category == 'other' %}selected{% endif %}>{% trans "Other" %}</option>
</select>
</div>
<div>
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Hospital" %}</label>
<select name="hospital" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
<option value="">{% trans "All" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name_en }}
</option>
{% endfor %}
</select>
</div>
<div class="md:col-span-5 flex justify-end">
<button type="submit" class="bg-cyan-500 text-white px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-cyan-600 transition flex items-center gap-2">
<i data-lucide="filter" class="w-4 h-4"></i> {% trans "Filter" %}
</button>
</div>
</form>
<form method="get" class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div class="md:col-span-2">
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Search" %}</label>
<input type="text" name="search"
class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition"
placeholder="{% trans 'Search...' %}" value="{{ filters.search }}">
</div>
<div>
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Status" %}</label>
<select name="status" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
<option value="">{% trans "All" %}</option>
<option value="open" {% if filters.status == 'open' %}selected{% endif %}>{% trans "Open" %}</option>
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>{% trans "In Progress" %}</option>
<option value="resolved" {% if filters.status == 'resolved' %}selected{% endif %}>{% trans "Resolved" %}</option>
<option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>{% trans "Closed" %}</option>
</select>
</div>
<div>
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Category" %}</label>
<select name="category" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-navy focus:border-transparent transition">
<option value="">{% trans "All" %}</option>
<option value="appointment" {% if filters.category == 'appointment' %}selected{% endif %}>{% trans "Appointment" %}</option>
<option value="billing" {% if filters.category == 'billing' %}selected{% endif %}>{% trans "Billing" %}</option>
<option value="medical_records" {% if filters.category == 'medical_records' %}selected{% endif %}>{% trans "Medical Records" %}</option>
<option value="general" {% if filters.category == 'general' %}selected{% endif %}>{% trans "General" %}</option>
<option value="other" {% if filters.category == 'other' %}selected{% endif %}>{% trans "Other" %}</option>
</select>
</div>
<div class="flex items-end">
<button type="submit" class="w-full bg-navy text-white px-4 py-2.5 rounded-xl text-sm font-bold hover:bg-blue transition flex items-center justify-center gap-2">
<i data-lucide="filter" class="w-4 h-4"></i> {% trans "Filter" %}
</button>
</div>
</form>
</div>
</div>
<!-- Inquiries Table -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 overflow-hidden">
<div class="px-6 py-4 border-b-2 border-slate-200 bg-gradient-to-r from-slate-50 to-slate-100 flex justify-between items-center">
<h3 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="help-circle" class="w-5 h-5 text-cyan-500"></i>
{% trans "Inquiries" %}
</h3>
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-cyan-100">
<i data-lucide="help-circle" class="w-5 h-5 text-cyan-600"></i>
</div>
<h3 class="font-bold text-navy">{% trans "Inquiries" %}</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full">
@ -211,7 +242,7 @@
</a>
{% endif %}
<span class="px-4 py-2 rounded-lg bg-cyan-500 text-white text-sm font-bold">
<span class="px-4 py-2 rounded-lg bg-navy text-white text-sm font-bold">
{% trans "Page" %} {{ page_obj.number }} {% trans "of" %} {{ page_obj.paginator.num_pages }}
</span>

View File

@ -4,15 +4,62 @@
{% block title %}Call Center - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">
<i class="bi bi-telephone text-success me-2"></i>
Call Center
</h2>
<p class="text-muted mb-0">Monitor call center interactions and satisfaction</p>
<!-- Page Header -->
<div class="page-header-gradient">
<div class="flex items-center gap-3">
<div class="section-icon bg-white/20">
<i data-lucide="phone" class="w-6 h-6"></i>
</div>
<div>
<h2 class="text-2xl font-bold mb-1">{% trans "Call Center" %}</h2>
<p class="opacity-90 mb-0">{% trans "Monitor call center interactions and satisfaction" %}</p>
</div>
</div>
</div>
@ -45,8 +92,14 @@
</div>
<!-- Interactions Table -->
<div class="card">
<div class="card-body p-0">
<div class="section-card">
<div class="section-header">
<div class="section-icon bg-blue-100">
<i data-lucide="list" class="w-5 h-5 text-blue-600"></i>
</div>
<h3 class="font-bold text-navy">{% trans "Interactions" %}</h3>
</div>
<div class="p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
@ -102,15 +155,15 @@
<td onclick="event.stopPropagation();">
<a href="{% url 'callcenter:interaction_detail' interaction.id %}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
<i data-lucide="eye" class="w-4 h-4"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="8" class="text-center py-5">
<i class="bi bi-telephone" style="font-size: 3rem; color: #ccc;"></i>
<p class="text-muted mt-3">No interactions found</p>
<i data-lucide="phone" class="w-12 h-12 mx-auto mb-3 text-gray-300"></i>
<p class="text-muted mt-3">{% trans "No interactions found" %}</p>
</td>
</tr>
{% endfor %}
@ -127,7 +180,7 @@
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="bi bi-chevron-left"></i>
<i data-lucide="chevron-left" class="w-4 h-4"></i>
</a>
</li>
{% endif %}
@ -147,7 +200,7 @@
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="bi bi-chevron-right"></i>
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</a>
</li>
{% endif %}

View File

@ -3,19 +3,101 @@
{% block title %}{% if adverse_action %}{% trans "Edit Adverse Action" %}{% else %}{% trans "Report Adverse Action" %}{% endif %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.form-section {
background: #fff;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
}
.form-section:hover {
border-color: #005696;
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
}
.form-control, .form-select {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
outline: none;
border-color: #005696;
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #005696;
color: white;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
border: none;
cursor: pointer;
}
.btn-primary:hover {
background: #007bbd;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: white;
color: #64748b;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #005696;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6 max-w-4xl mx-auto">
<!-- Header -->
<div class="mb-6">
<!-- Gradient Header -->
<div class="page-header-gradient">
<div class="flex items-center gap-4">
<a href="{% url 'complaints:complaint_detail' complaint.id %}" class="text-[#64748b] hover:text-[#005696]">
<a href="{% url 'complaints:complaint_detail' complaint.id %}" class="text-white/80 hover:text-white transition">
<i data-lucide="arrow-left" class="w-6 h-6"></i>
</a>
<div>
<h1 class="text-2xl font-bold text-[#005696]">
<h1 class="text-2xl font-bold">
{% if adverse_action %}{% trans "Edit Adverse Action" %}{% else %}{% trans "Report Adverse Action" %}{% endif %}
</h1>
<p class="text-[#64748b] mt-1">
<p class="text-white/80 mt-1">
{% trans "Complaint" %}: {{ complaint.reference_number }}
</p>
</div>
@ -23,32 +105,31 @@
</div>
<!-- Form -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<form method="post" class="p-6">
<div class="form-section">
<form method="post">
{% csrf_token %}
<!-- Action Type -->
<div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2">
<label class="form-label">
{% trans "Action Type" %}
<span class="text-red-500">*</span>
</label>
<select name="action_type" required
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition">
<select name="action_type" required class="form-select">
{% for value, label in action_type_choices %}
<option value="{{ value }}" {% if adverse_action and adverse_action.action_type == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
<p class="mt-1 text-sm text-[#64748b]">
<p class="mt-2 text-sm text-[#64748b]">
{% trans "Select the type of adverse action that occurred." %}
</p>
</div>
<!-- Severity -->
<div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2">
<label class="form-label">
{% trans "Severity Level" %}
<span class="text-red-500">*</span>
</label>
@ -77,54 +158,54 @@
<!-- Incident Date -->
<div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2">
<label class="form-label">
{% trans "Incident Date & Time" %}
<span class="text-red-500">*</span>
</label>
<input type="datetime-local" name="incident_date" required
value="{% if adverse_action %}{{ adverse_action.incident_date|date:'Y-m-d\\TH:i' }}{% else %}{% now 'Y-m-d\\TH:i' %}{% endif %}"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition">
class="form-control">
</div>
<!-- Location -->
<div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2">
<label class="form-label">
{% trans "Location" %}
</label>
<input type="text" name="location"
value="{{ adverse_action.location|default:'' }}"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition"
class="form-control"
placeholder="{% trans 'e.g., Emergency Room, Clinic B, Main Reception' %}">
</div>
<!-- Description -->
<div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2">
<label class="form-label">
{% trans "Description" %}
<span class="text-red-500">*</span>
</label>
<textarea name="description" rows="4" required
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition"
class="form-control resize-none"
placeholder="{% trans 'Describe what happened to the patient...' %}">{{ adverse_action.description|default:'' }}</textarea>
</div>
<!-- Patient Impact -->
<div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2">
<label class="form-label">
{% trans "Impact on Patient" %}
</label>
<textarea name="patient_impact" rows="3"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition"
class="form-control resize-none"
placeholder="{% trans 'Describe the physical, emotional, or financial impact on the patient...' %}">{{ adverse_action.patient_impact|default:'' }}</textarea>
</div>
<!-- Involved Staff -->
<div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2">
<label class="form-label">
{% trans "Involved Staff" %}
</label>
<select name="involved_staff" multiple
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl text-gray-800 focus:ring-2 focus:ring-[#005696] focus:border-transparent transition"
class="form-control"
size="5">
{% for staff in staff_list %}
<option value="{{ staff.id }}" {% if staff.id in selected_staff %}selected{% endif %}>
@ -132,7 +213,7 @@
</option>
{% endfor %}
</select>
<p class="mt-1 text-sm text-[#64748b]">
<p class="mt-2 text-sm text-[#64748b]">
{% trans "Hold Ctrl/Cmd to select multiple staff members." %}
</p>
</div>
@ -152,11 +233,12 @@
<!-- Actions -->
<div class="flex items-center justify-end gap-3 pt-6 border-t border-gray-100">
<a href="{% url 'complaints:complaint_detail' complaint.id %}" class="px-6 py-3 border border-gray-200 text-gray-700 rounded-xl font-semibold hover:bg-gray-50 transition">
<a href="{% url 'complaints:complaint_detail' complaint.id %}" class="btn-secondary">
<i data-lucide="x" class="w-4 h-4"></i>
{% trans "Cancel" %}
</a>
<button type="submit" class="px-8 py-3 bg-[#005696] text-white rounded-xl font-semibold hover:bg-[#007bbd] transition flex items-center gap-2">
<i data-lucide="{% if adverse_action %}save{% else %}plus{% endif %}" class="w-5 h-5"></i>
<button type="submit" class="btn-primary">
<i data-lucide="{% if adverse_action %}save{% else %}plus{% endif %}" class="w-4 h-4"></i>
{% if adverse_action %}{% trans "Save Changes" %}{% else %}{% trans "Report Adverse Action" %}{% endif %}
</button>
</div>

View File

@ -16,27 +16,55 @@
--hh-purple: #8b5cf6;
}
.page-header {
background: linear-gradient(135deg, var(--hh-navy) 0%, #0069a8 50%, var(--hh-blue) 100%);
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 2rem 2.5rem;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 2rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.filter-card, .data-card {
.section-card {
background: white;
border-radius: 1rem;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.card-header {
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
padding: 1.25rem 1.75rem;
border-bottom: 1px solid #bae6fd;
border-radius: 1rem 1rem 0 0;
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.section-icon.primary {
background: linear-gradient(135deg, #005696, #007bbd);
color: white;
}
.section-icon.secondary {
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
color: #005696;
}
.data-table th {
@ -154,7 +182,7 @@
{% block content %}
<div class="px-4 py-6">
<!-- Page Header -->
<div class="page-header animate-in">
<div class="page-header-gradient animate-in">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold mb-2">
@ -171,12 +199,12 @@
</div>
<!-- Filters -->
<div class="filter-card mb-6 animate-in">
<div class="card-header">
<h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<div class="section-card mb-6 animate-in">
<div class="section-header">
<div class="section-icon secondary">
<i data-lucide="filter" class="w-5 h-5"></i>
{% trans "Filters" %}
</h2>
</div>
<h2 class="text-lg font-bold text-navy m-0">{% trans "Filters" %}</h2>
</div>
<div class="p-6">
<form method="get" class="flex flex-wrap gap-4">
@ -224,12 +252,14 @@
</div>
<!-- Adverse Actions Table -->
<div class="data-card animate-in">
<div class="card-header flex items-center justify-between">
<h2 class="text-lg font-bold text-navy flex items-center gap-2 m-0">
<i data-lucide="shield-alert" class="w-5 h-5"></i>
{% trans "All Adverse Actions" %} ({{ page_obj.paginator.count }})
</h2>
<div class="section-card animate-in">
<div class="section-header flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="section-icon primary">
<i data-lucide="shield-alert" class="w-5 h-5"></i>
</div>
<h2 class="text-lg font-bold text-navy m-0">{% trans "All Adverse Actions" %} ({{ page_obj.paginator.count }})</h2>
</div>
<a href="#" class="btn-primary">
<i data-lucide="plus" class="w-4 h-4"></i>
{% trans "New Adverse Action" %}

View File

@ -506,6 +506,7 @@
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
{% if not form.hospital.is_hidden %}
<div>
<label for="{{ form.hospital.id_for_label }}" class="form-label">
{{ form.hospital.label }} <span class="required-mark">*</span>
@ -518,6 +519,9 @@
</p>
{% endif %}
</div>
{% else %}
{{ form.hospital }}
{% endif %}
<div>
<label for="{{ form.department.id_for_label }}" class="form-label">
@ -743,7 +747,10 @@
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
// Hospital field - could be select or hidden input depending on user role
const hospitalSelect = document.getElementById('hospitalSelect');
const hospitalField = hospitalSelect || document.querySelector('input[name="hospital"]');
const departmentSelect = document.getElementById('departmentSelect');
const staffSelect = document.getElementById('staffSelect');
const locationSelect = document.getElementById('locationSelect');
@ -752,6 +759,36 @@ document.addEventListener('DOMContentLoaded', function() {
const complaintTypeCards = document.querySelectorAll('.type-card');
const complaintTypeInput = document.getElementById('complaintTypeInput');
// Load departments on page load if hospital is already selected
function loadDepartments(hospitalId) {
if (!hospitalId || !departmentSelect) {
if (departmentSelect) {
departmentSelect.innerHTML = '<option value="">{% trans "Select Department" %}</option>';
}
return;
}
departmentSelect.innerHTML = '<option value="">{% trans "Loading..." %}</option>';
fetch('/organizations/api/departments/?hospital=' + hospitalId)
.then(response => response.json())
.then(data => {
const results = data.results || data;
departmentSelect.innerHTML = '<option value="">{% trans "Select Department" %}</option>';
results.forEach(dept => {
const option = document.createElement('option');
option.value = dept.id;
option.textContent = dept.name;
departmentSelect.appendChild(option);
});
})
.catch(error => {
console.error('Error loading departments:', error);
departmentSelect.innerHTML = '<option value="">{% trans "Error loading departments" %}</option>';
});
}
// Complaint type selection
complaintTypeCards.forEach(card => {
card.addEventListener('click', function() {
@ -763,7 +800,7 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
// Hospital change handler
// Hospital change handler (only for select, not hidden fields)
if (hospitalSelect) {
hospitalSelect.addEventListener('change', function() {
const hospitalId = this.value;
@ -775,6 +812,12 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// Load departments on page load if hospital is pre-selected
const initialHospitalId = hospitalField ? hospitalField.value : null;
if (initialHospitalId) {
loadDepartments(initialHospitalId);
}
// Location change handler
if (locationSelect) {
locationSelect.addEventListener('change', function() {

View File

@ -5,6 +5,63 @@
{% block extra_css %}
<style>
/* Page Header Gradient - Matching References Page */
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
/* Stat Cards */
.stat-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
border-color: #005696;
}
/* Section Cards */
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
/* Status styles */
.status-resolved { background-color: #dcfce7; color: #166534; }
.status-pending { background-color: #fef9c3; color: #854d0e; }
.status-investigation { background-color: #e0f2fe; color: #075985; }
@ -16,81 +73,89 @@
{% endblock %}
{% block content %}
<!-- Header -->
<header class="mb-6">
<div class="flex justify-between items-start">
<!-- Page Header with Gradient -->
<div class="page-header-gradient">
<div class="flex justify-between items-center">
<div>
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<i data-lucide="message-square-warning" class="w-7 h-7 text-red-500"></i>
{% trans "Complaints Registry" %}
</h1>
<p class="text-sm text-slate mt-1">{% trans "Manage and monitor patient feedback in real-time" %}</p>
<h1 class="text-2xl font-bold mb-1">{% trans "Complaints Registry" %}</h1>
<p class="text-blue-100 text-sm">{% trans "Manage and monitor patient feedback in real-time" %}</p>
</div>
<div class="flex items-center gap-3">
<div class="relative group">
<i data-lucide="search" class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate group-focus-within:text-navy"></i>
<i data-lucide="search" class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-white/70 group-focus-within:text-navy"></i>
<input type="text" id="searchInput" placeholder="{% trans 'Search ID, Name or Dept...' %}"
class="pl-10 pr-4 py-2.5 bg-slate-100 border-transparent border focus:border-navy/30 focus:bg-white rounded-xl text-sm outline-none w-64 transition-all">
class="pl-10 pr-4 py-2.5 bg-white/10 border-2 border-white/30 text-white placeholder-white/70 rounded-xl text-sm outline-none w-64 transition-all focus:bg-white focus:text-navy focus:border-white">
</div>
{% if user.is_px_admin or user.is_hospital_admin %}
<a href="{% url 'complaints:complaint_create' %}" class="bg-navy text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-navy/20 hover:bg-blue flex items-center gap-2 transition">
<i data-lucide="plus" class="w-4 h-4"></i> {% trans "New Case" %}
<a href="{% url 'core:public_submit_landing' %}" class="inline-flex items-center px-4 py-2.5 bg-white/10 border-2 border-white/30 text-white font-medium rounded-xl hover:bg-white hover:text-navy transition" target="_blank">
<i data-lucide="external-link" class="w-4 h-4 me-2"></i>{% trans "Public Form" %}
</a>
<a href="{% url 'core:public_submit_landing' %}" class="bg-white text-navy border-2 border-navy/30 px-5 py-2.5 rounded-xl text-sm font-bold hover:bg-navy hover:text-white flex items-center gap-2 transition" target="_blank">
<i data-lucide="external-link" class="w-4 h-4"></i> {% trans "Public Form" %}
<a href="{% url 'complaints:complaint_create' %}" class="inline-flex items-center px-4 py-2.5 bg-white text-navy font-medium rounded-xl hover:bg-blue-50 transition">
<i data-lucide="plus" class="w-4 h-4 me-2"></i>{% trans "New Case" %}
</a>
{% endif %}
</div>
</div>
</header>
</div>
<!-- Statistics Cards -->
<div class="grid grid-cols-4 gap-6 mb-6">
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4">
<div class="p-3 bg-blue/10 rounded-xl">
<i data-lucide="layers" class="text-blue w-5 h-5"></i>
<div class="stat-card bg-white rounded-2xl border-2 border-slate-200 p-6 flex items-center gap-4">
<div class="flex-shrink-0">
<div class="w-14 h-14 bg-navy/10 rounded-xl flex items-center justify-center">
<i data-lucide="layers" class="w-7 h-7 text-navy"></i>
</div>
</div>
<div>
<p class="text-[10px] font-bold text-slate uppercase tracking-wider">{% trans "Total Received" %}</p>
<p class="text-xl font-black text-navy leading-tight">{{ stats.total }}</p>
<h6 class="text-slate-500 text-sm font-medium mb-1">{% trans "Total Received" %}</h6>
<h2 class="text-3xl font-bold text-gray-800">{{ stats.total }}</h2>
</div>
</div>
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4">
<div class="p-3 bg-green-50 rounded-xl">
<i data-lucide="check-circle" class="text-green-600 w-5 h-5"></i>
<div class="stat-card bg-white rounded-2xl border-2 border-slate-200 p-6 flex items-center gap-4">
<div class="flex-shrink-0">
<div class="w-14 h-14 bg-green-500/10 rounded-xl flex items-center justify-center">
<i data-lucide="check-circle" class="w-7 h-7 text-green-600"></i>
</div>
</div>
<div>
<p class="text-[10px] font-bold text-slate uppercase tracking-wider">{% trans "Resolved" %}</p>
<p class="text-xl font-black text-navy leading-tight">{{ stats.resolved }} <span class="text-xs text-green-600">({{ stats.resolved_percentage|floatformat:1 }}%)</span></p>
<h6 class="text-slate-500 text-sm font-medium mb-1">{% trans "Resolved" %}</h6>
<h2 class="text-3xl font-bold text-gray-800">{{ stats.resolved }} <span class="text-sm text-green-600">({{ stats.resolved_percentage|floatformat:1 }}%)</span></h2>
</div>
</div>
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4">
<div class="p-3 bg-yellow-50 rounded-xl">
<i data-lucide="clock" class="text-yellow-600 w-5 h-5"></i>
<div class="stat-card bg-white rounded-2xl border-2 border-slate-200 p-6 flex items-center gap-4">
<div class="flex-shrink-0">
<div class="w-14 h-14 bg-yellow-500/10 rounded-xl flex items-center justify-center">
<i data-lucide="clock" class="w-7 h-7 text-yellow-600"></i>
</div>
</div>
<div>
<p class="text-[10px] font-bold text-slate uppercase tracking-wider">{% trans "Pending" %}</p>
<p class="text-xl font-black text-navy leading-tight">{{ stats.pending }}</p>
<h6 class="text-slate-500 text-sm font-medium mb-1">{% trans "Pending" %}</h6>
<h2 class="text-3xl font-bold text-gray-800">{{ stats.pending }}</h2>
</div>
</div>
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4">
<div class="p-3 bg-red-50 rounded-xl">
<i data-lucide="alert-triangle" class="text-red-500 w-5 h-5"></i>
<div class="stat-card bg-white rounded-2xl border-2 border-slate-200 p-6 flex items-center gap-4">
<div class="flex-shrink-0">
<div class="w-14 h-14 bg-red-500/10 rounded-xl flex items-center justify-center">
<i data-lucide="alert-triangle" class="w-7 h-7 text-red-500"></i>
</div>
</div>
<div>
<p class="text-[10px] font-bold text-slate uppercase tracking-wider">{% trans "TAT Alert" %}</p>
<p class="text-xl font-black text-navy leading-tight">{{ stats.overdue }} <span class="text-[10px] font-normal text-slate">{% trans "Over 72h" %}</span></p>
<h6 class="text-slate-500 text-sm font-medium mb-1">{% trans "TAT Alert" %}</h6>
<h2 class="text-3xl font-bold text-gray-800">{{ stats.overdue }} <span class="text-sm text-slate">{% trans "Over 72h" %}</span></h2>
</div>
</div>
</div>
<!-- Filter Tabs -->
<div class="bg-white rounded-t-2xl shadow-sm border-2 border-slate-200">
<div class="px-6 py-4 border-b-2 border-slate-200 flex items-center justify-between bg-gradient-to-r from-slate-50 to-slate-100">
<h3 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="filter" class="w-5 h-5 text-navy"></i>
{% trans "Filters" %}
</h3>
<!-- Complaints Section Card -->
<div class="section-card">
<!-- Section Header with Filters -->
<div class="section-header flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="section-icon bg-red-500/10">
<i data-lucide="message-square-warning" class="w-5 h-5 text-red-500"></i>
</div>
<h5 class="text-lg font-semibold text-gray-800">{% trans "Complaints" %}</h5>
</div>
<div class="flex items-center gap-3">
<a href="?" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if not status_filter %}active{% endif %}">
{% trans "All Cases" %}
@ -110,7 +175,7 @@
</button>
</div>
</div>
<p class="px-6 py-2 text-[10px] font-bold text-slate uppercase bg-slate-50 border-b-2 border-slate-200">
<p class="px-6 py-2 text-xs font-bold text-slate uppercase bg-slate-50 border-b-2 border-slate-200">
{% trans "Showing:" %} <span class="text-navy">{{ complaints.start_index|default:0 }}-{{ complaints.end_index|default:0 }} {% trans "of" %} {{ complaints.paginator.count|default:0 }}</span>
</p>
@ -145,13 +210,7 @@
</div>
<!-- Complaints Table -->
<div class="bg-white rounded-b-2xl shadow-sm border-2 border-slate-200 overflow-hidden border-t-0">
<div class="px-6 py-4 border-b-2 border-slate-200 bg-gradient-to-r from-slate-50 to-slate-100">
<h3 class="font-bold text-navy flex items-center gap-2">
<i data-lucide="message-square-warning" class="w-5 h-5 text-red-500"></i>
{% trans "Complaints" %}
</h3>
</div>
<div class="p-0">
<table class="w-full text-left border-collapse">
<thead class="bg-slate-50 border-b uppercase text-[10px] font-bold text-slate tracking-wider">
<tr>
@ -307,6 +366,7 @@
</div>
{% endif %}
</div>
</div>
<!-- Description Modal -->
<div id="descriptionModal" class="fixed inset-0 z-50 hidden items-center justify-center p-4" onclick="closeDescriptionModal(event)">

View File

@ -3,324 +3,467 @@
{% block title %}{{ title }} - {% translate "Complaint Thresholds" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.form-section {
background: #fff;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
}
.form-section:hover {
border-color: #005696;
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
}
.form-control, .form-select {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
outline: none;
border-color: #005696;
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #005696;
color: white;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
border: none;
cursor: pointer;
}
.btn-primary:hover {
background: #007bbd;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: white;
color: #64748b;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #005696;
}
.help-card {
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
}
.form-switch {
display: flex;
align-items: center;
gap: 0.75rem;
}
.form-switch-input {
width: 3rem;
height: 1.5rem;
appearance: none;
background: #cbd5e1;
border-radius: 1rem;
position: relative;
cursor: pointer;
transition: all 0.2s ease;
}
.form-switch-input:checked {
background: #005696;
}
.form-switch-input::after {
content: '';
position: absolute;
width: 1.25rem;
height: 1.25rem;
background: white;
border-radius: 50%;
top: 0.125rem;
left: 0.125rem;
transition: all 0.2s ease;
}
.form-switch-input:checked::after {
left: 1.625rem;
}
.invalid-feedback {
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.form-text {
color: #64748b;
font-size: 0.875rem;
margin-top: 0.25rem;
}
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<div class="page-header-content">
<div>
<h1 class="page-title">
<i class="fas fa-chart-line"></i>
{{ title }}
</h1>
<p class="page-description">
{% if threshold %}
{% translate "Edit complaint threshold" %}
{% else %}
{% translate "Create new complaint threshold" %}
{% endif %}
</p>
<!-- Gradient Header -->
<div class="page-header-gradient">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<i data-lucide="gauge" class="w-6 h-6"></i>
</div>
<div>
<h1 class="text-2xl font-bold">{{ title }}</h1>
<p class="text-white/80">
{% if threshold %}{% translate "Edit complaint threshold" %}{% else %}{% translate "Create new complaint threshold" %}{% endif %}
</p>
</div>
</div>
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i>
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% translate "Back to List" %}
</a>
</div>
</div>
<div class="page-content">
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<form method="post" class="row g-3">
{% csrf_token %}
{% if request.user.is_px_admin %}
<div class="col-md-12">
<label for="id_hospital" class="form-label">
{% translate "Hospital" %} <span class="text-danger">*</span>
</label>
<select name="hospital" id="id_hospital" class="form-select" required>
<option value="">{% translate "Select Hospital" %}</option>
{% for hospital in form.hospital.field.queryset %}
<option value="{{ hospital.id }}"
{% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name }}
</option>
{% endfor %}
</select>
{% if form.hospital.errors %}
<div class="invalid-feedback d-block">
{{ form.hospital.errors.0 }}
</div>
{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<div class="form-section">
<form method="post" class="space-y-6">
{% csrf_token %}
{% if not form.hospital.is_hidden %}
<div>
<label for="id_hospital" class="form-label">
{% translate "Hospital" %} <span class="text-red-500">*</span>
</label>
<select name="hospital" id="id_hospital" class="form-select" required>
<option value="">{% translate "Select Hospital" %}</option>
{% for hospital in form.hospital.field.queryset %}
<option value="{{ hospital.id }}"
{% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name }}
</option>
{% endfor %}
</select>
{% if form.hospital.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.hospital.errors.0 }}
</div>
{% endif %}
</div>
{% endif %}
<div>
<label for="id_name" class="form-label">
{% translate "Threshold Name" %} <span class="text-red-500">*</span>
</label>
<input type="text"
name="name"
id="id_name"
class="form-control"
value="{{ form.name.value|default:'' }}"
required
placeholder="{% translate 'e.g., Daily Complaint Limit' %}">
{% if form.name.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.name.errors.0 }}
</div>
{% endif %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="id_threshold_type" class="form-label">
{% translate "Threshold Type" %} <span class="text-red-500">*</span>
</label>
<select name="threshold_type" id="id_threshold_type" class="form-select" required>
<option value="">{% translate "Select Type" %}</option>
{% for value, label in form.threshold_type.field.choices %}
<option value="{{ value }}"
{% if form.threshold_type.value == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.threshold_type.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.threshold_type.errors.0 }}
</div>
{% endif %}
<div class="col-md-12">
<label for="id_name" class="form-label">
{% translate "Threshold Name" %} <span class="text-danger">*</span>
</label>
<input type="text"
name="name"
id="id_name"
class="form-control"
value="{{ form.name.value|default:'' }}"
required
placeholder="{% translate 'e.g., Daily Complaint Limit' %}">
{% if form.name.errors %}
<div class="invalid-feedback d-block">
{{ form.name.errors.0 }}
</div>
{% endif %}
</div>
<div>
<label for="id_metric_type" class="form-label">
{% translate "Metric Type" %} <span class="text-red-500">*</span>
</label>
<select name="metric_type" id="id_metric_type" class="form-select" required>
<option value="">{% translate "Select Metric" %}</option>
{% for value, label in form.metric_type.field.choices %}
<option value="{{ value }}"
{% if form.metric_type.value == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.metric_type.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.metric_type.errors.0 }}
</div>
<div class="col-md-6">
<label for="id_threshold_type" class="form-label">
{% translate "Threshold Type" %} <span class="text-danger">*</span>
</label>
<select name="threshold_type" id="id_threshold_type" class="form-select" required>
<option value="">{% translate "Select Type" %}</option>
{% for value, label in form.threshold_type.field.choices %}
<option value="{{ value }}"
{% if form.threshold_type.value == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.threshold_type.errors %}
<div class="invalid-feedback d-block">
{{ form.threshold_type.errors.0 }}
</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="id_metric_type" class="form-label">
{% translate "Metric Type" %} <span class="text-danger">*</span>
</label>
<select name="metric_type" id="id_metric_type" class="form-select" required>
<option value="">{% translate "Select Metric" %}</option>
{% for value, label in form.metric_type.field.choices %}
<option value="{{ value }}"
{% if form.metric_type.value == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.metric_type.errors %}
<div class="invalid-feedback d-block">
{{ form.metric_type.errors.0 }}
</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="id_threshold_value" class="form-label">
{% translate "Threshold Value" %} <span class="text-danger">*</span>
</label>
<input type="number"
name="threshold_value"
id="id_threshold_value"
class="form-control"
value="{{ form.threshold_value.value|default:'' }}"
min="1"
required
placeholder="{% translate 'e.g., 10' %}">
{% if form.threshold_value.errors %}
<div class="invalid-feedback d-block">
{{ form.threshold_value.errors.0 }}
</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="id_action" class="form-label">
{% translate "Action to Take" %} <span class="text-danger">*</span>
</label>
<select name="action" id="id_action" class="form-select" required>
<option value="">{% translate "Select Action" %}</option>
{% for value, label in form.action.field.choices %}
<option value="{{ value }}"
{% if form.action.value == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.action.errors %}
<div class="invalid-feedback d-block">
{{ form.action.errors.0 }}
</div>
{% endif %}
</div>
<div class="col-md-12">
<label for="id_complaint_category" class="form-label">
{% translate "Complaint Category (Optional)" %}
</label>
<select name="complaint_category" id="id_complaint_category" class="form-select">
<option value="">{% translate "All Categories" %}</option>
{% for category in form.complaint_category.field.queryset %}
<option value="{{ category.id }}"
{% if form.complaint_category.value == category.id|stringformat:"s" %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
{% if form.complaint_category.errors %}
<div class="invalid-feedback d-block">
{{ form.complaint_category.errors.0 }}
</div>
{% else %}
<div class="form-text">
{% translate "Leave empty to apply to all complaint categories" %}
</div>
{% endif %}
</div>
<div class="col-md-12">
<label for="id_notify_emails" class="form-label">
{% translate "Notify Emails (Optional)" %}
</label>
<input type="text"
name="notify_emails"
id="id_notify_emails"
class="form-control"
value="{{ form.notify_emails.value|default:'' }}"
placeholder="{% translate 'email1@example.com, email2@example.com' %}">
{% if form.notify_emails.errors %}
<div class="invalid-feedback d-block">
{{ form.notify_emails.errors.0 }}
</div>
{% else %}
<div class="form-text">
{% translate "Comma-separated list of email addresses to notify when threshold is reached" %}
</div>
{% endif %}
</div>
<div class="col-12">
<div class="form-check form-switch">
<input type="checkbox"
name="is_active"
id="id_is_active"
class="form-check-input"
{% if form.is_active.value == 'on' or not form.is_active.value %}checked{% endif %}>
<label class="form-check-label" for="id_is_active">
{% translate "Active" %}
</label>
</div>
<div class="form-text">
{% translate "Only active thresholds will be monitored" %}
</div>
</div>
<div class="col-12">
<label for="id_description" class="form-label">
{% translate "Description" %}
</label>
<textarea name="description"
id="id_description"
class="form-control"
rows="3"
placeholder="{% translate 'Optional notes about this threshold' %}">{{ form.description.value|default:'' }}</textarea>
{% if form.description.errors %}
<div class="invalid-feedback d-block">
{{ form.description.errors.0 }}
</div>
{% endif %}
</div>
<div class="col-12">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
{{ action }}
</button>
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i>
{% translate "Cancel" %}
</a>
</div>
</div>
</form>
{% endif %}
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="id_threshold_value" class="form-label">
{% translate "Threshold Value" %} <span class="text-red-500">*</span>
</label>
<input type="number"
name="threshold_value"
id="id_threshold_value"
class="form-control"
value="{{ form.threshold_value.value|default:'' }}"
min="1"
required
placeholder="{% translate 'e.g., 10' %}">
{% if form.threshold_value.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.threshold_value.errors.0 }}
</div>
{% endif %}
</div>
<div>
<label for="id_action" class="form-label">
{% translate "Action to Take" %} <span class="text-red-500">*</span>
</label>
<select name="action" id="id_action" class="form-select" required>
<option value="">{% translate "Select Action" %}</option>
{% for value, label in form.action.field.choices %}
<option value="{{ value }}"
{% if form.action.value == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.action.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.action.errors.0 }}
</div>
{% endif %}
</div>
</div>
<div>
<label for="id_complaint_category" class="form-label">
{% translate "Complaint Category (Optional)" %}
</label>
<select name="complaint_category" id="id_complaint_category" class="form-select">
<option value="">{% translate "All Categories" %}</option>
{% for category in form.complaint_category.field.queryset %}
<option value="{{ category.id }}"
{% if form.complaint_category.value == category.id|stringformat:"s" %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
{% if form.complaint_category.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.complaint_category.errors.0 }}
</div>
{% else %}
<div class="form-text">
{% translate "Leave empty to apply to all complaint categories" %}
</div>
{% endif %}
</div>
<div>
<label for="id_notify_emails" class="form-label">
{% translate "Notify Emails (Optional)" %}
</label>
<input type="text"
name="notify_emails"
id="id_notify_emails"
class="form-control"
value="{{ form.notify_emails.value|default:'' }}"
placeholder="{% translate 'email1@example.com, email2@example.com' %}">
{% if form.notify_emails.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.notify_emails.errors.0 }}
</div>
{% else %}
<div class="form-text">
{% translate "Comma-separated list of email addresses to notify when threshold is reached" %}
</div>
{% endif %}
</div>
<div class="flex items-center gap-3 p-4 bg-gray-50 rounded-xl">
<div class="form-switch">
<input type="checkbox"
name="is_active"
id="id_is_active"
class="form-switch-input"
{% if form.is_active.value == 'on' or not form.is_active.value %}checked{% endif %}>
<label for="id_is_active" class="text-sm font-semibold text-gray-700">
{% translate "Active" %}
</label>
</div>
<div class="form-text ml-2">
{% translate "Only active thresholds will be monitored" %}
</div>
</div>
<div>
<label for="id_description" class="form-label">
{% translate "Description" %}
</label>
<textarea name="description"
id="id_description"
class="form-control resize-none"
rows="3"
placeholder="{% translate 'Optional notes about this threshold' %}">{{ form.description.value|default:'' }}</textarea>
{% if form.description.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.description.errors.0 }}
</div>
{% endif %}
</div>
<div class="flex items-center gap-3 pt-6 border-t border-gray-100">
<button type="submit" class="btn-primary">
<i data-lucide="save" class="w-4 h-4"></i>
{{ action }}
</button>
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn-secondary">
<i data-lucide="x" class="w-4 h-4"></i>
{% translate "Cancel" %}
</a>
</div>
</form>
</div>
<div class="col-lg-4">
<div class="card bg-light">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-info-circle"></i>
{% translate "Help" %}
</h5>
<h6 class="card-subtitle mb-3 text-muted">
{% translate "Understanding Complaint Thresholds" %}
</h6>
<p class="card-text">
{% translate "Thresholds monitor complaint metrics and trigger actions when limits are exceeded." %}
</p>
<h6 class="card-subtitle mb-2 text-muted">
{% translate "Threshold Types" %}
</h6>
<ul class="list-unstyled">
<li class="mb-2">
<i class="fas fa-calendar-day text-primary me-2"></i>
<strong>{% translate "Daily" %}</strong> - {% translate "Monitor daily complaint volume" %}
</li>
<li class="mb-2">
<i class="fas fa-calendar-week text-success me-2"></i>
<strong>{% translate "Weekly" %}</strong> - {% translate "Monitor weekly complaint volume" %}
</li>
<li class="mb-2">
<i class="fas fa-calendar text-warning me-2"></i>
<strong>{% translate "Monthly" %}</strong> - {% translate "Monitor monthly complaint volume" %}
</li>
<li class="mb-2">
<i class="fas fa-tags text-info me-2"></i>
<strong>{% translate "By Category" %}</strong> - {% translate "Monitor specific complaint categories" %}
</li>
</ul>
<hr>
<h6 class="card-subtitle mb-2 text-muted">
{% translate "Metric Types" %}
</h6>
<ul class="list-unstyled">
<li class="mb-2">
<i class="fas fa-list-ol text-secondary me-2"></i>
{% translate "Count" %} - {% translate "Number of complaints" %}
</li>
<li class="mb-2">
<i class="fas fa-percentage text-secondary me-2"></i>
{% translate "Percentage" %} - {% translate "Percentage of total complaints" %}
</li>
</ul>
<hr>
<h6 class="card-subtitle mb-2 text-muted">
{% translate "Actions" %}
</h6>
<ul class="list-unstyled">
<li class="mb-2">
<i class="fas fa-bell text-warning me-2"></i>
{% translate "Send Alert" %} - {% translate "Notify administrators" %}
</li>
<li class="mb-2">
<i class="fas fa-envelope text-info me-2"></i>
{% translate "Send Email" %} - {% translate "Send email notifications" %}
</li>
<li class="mb-2">
<i class="fas fa-file-alt text-success me-2"></i>
{% translate "Generate Report" %} - {% translate "Create detailed report" %}
</li>
</ul>
</div>
</div>
</div>
<div class="lg:col-span-1">
<div class="help-card">
<h5 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
<i data-lucide="info" class="w-5 h-5 text-[#005696]"></i>
{% translate "Help" %}
</h5>
<h6 class="text-sm font-semibold text-gray-500 mb-3">
{% translate "Understanding Complaint Thresholds" %}
</h6>
<p class="text-sm text-gray-600 mb-4">
{% translate "Thresholds monitor complaint metrics and trigger actions when limits are exceeded." %}
</p>
<h6 class="text-sm font-semibold text-gray-500 mb-3">
{% translate "Threshold Types" %}
</h6>
<ul class="space-y-2 text-sm text-gray-600">
<li class="flex items-center gap-2">
<i data-lucide="calendar" class="w-4 h-4 text-[#005696]"></i>
<strong>{% translate "Daily" %}</strong> - {% translate "Monitor daily complaint volume" %}
</li>
<li class="flex items-center gap-2">
<i data-lucide="calendar-days" class="w-4 h-4 text-green-500"></i>
<strong>{% translate "Weekly" %}</strong> - {% translate "Monitor weekly complaint volume" %}
</li>
<li class="flex items-center gap-2">
<i data-lucide="calendar-range" class="w-4 h-4 text-orange-500"></i>
<strong>{% translate "Monthly" %}</strong> - {% translate "Monitor monthly complaint volume" %}
</li>
<li class="flex items-center gap-2">
<i data-lucide="tags" class="w-4 h-4 text-purple-500"></i>
<strong>{% translate "By Category" %}</strong> - {% translate "Monitor specific complaint categories" %}
</li>
</ul>
<hr class="my-4 border-gray-200">
<h6 class="text-sm font-semibold text-gray-500 mb-3">
{% translate "Metric Types" %}
</h6>
<ul class="space-y-2 text-sm text-gray-600">
<li class="flex items-center gap-2">
<i data-lucide="list-ordered" class="w-4 h-4 text-gray-500"></i>
{% translate "Count" %} - {% translate "Number of complaints" %}
</li>
<li class="flex items-center gap-2">
<i data-lucide="percent" class="w-4 h-4 text-gray-500"></i>
{% translate "Percentage" %} - {% translate "Percentage of total complaints" %}
</li>
</ul>
<hr class="my-4 border-gray-200">
<h6 class="text-sm font-semibold text-gray-500 mb-3">
{% translate "Actions" %}
</h6>
<ul class="space-y-2 text-sm text-gray-600">
<li class="flex items-center gap-2">
<i data-lucide="bell" class="w-4 h-4 text-orange-500"></i>
{% translate "Send Alert" %} - {% translate "Notify administrators" %}
</li>
<li class="flex items-center gap-2">
<i data-lucide="mail" class="w-4 h-4 text-blue-500"></i>
{% translate "Send Email" %} - {% translate "Send email notifications" %}
</li>
<li class="flex items-center gap-2">
<i data-lucide="file-text" class="w-4 h-4 text-green-500"></i>
{% translate "Generate Report" %} - {% translate "Create detailed report" %}
</li>
</ul>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}

View File

@ -3,53 +3,209 @@
{% block title %}{% translate "Complaint Thresholds" %} - PX360{% endblock %}
{% block content %}
<div class="page-header">
<div class="page-header-content">
<div>
<h1 class="page-title">
<i class="fas fa-chart-line"></i>
{% translate "Complaint Thresholds" %}
</h1>
<p class="page-description">
{% translate "Configure thresholds for automatic alerts and reports" %}
</p>
</div>
<a href="{% url 'complaints:complaint_threshold_create' %}" class="btn btn-primary">
<i class="fas fa-plus"></i>
{% translate "Create Threshold" %}
</a>
</div>
</div>
{% block extra_css %}
<style>
:root {
--hh-navy: #005696;
--hh-blue: #007bbd;
--hh-light: #eef6fb;
--hh-slate: #64748b;
}
<div class="page-content">
<!-- Filters -->
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title">
<i class="fas fa-filter"></i>
{% translate "Filters" %}
</h5>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.section-icon.primary {
background: linear-gradient(135deg, #005696, #007bbd);
color: white;
}
.section-icon.secondary {
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
color: #005696;
}
.data-table th {
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
padding: 0.875rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--hh-navy);
border-bottom: 2px solid #bae6fd;
}
.data-table td {
padding: 1rem;
border-bottom: 1px solid #f1f5f9;
color: #475569;
font-size: 0.875rem;
}
.data-table tbody tr {
transition: background-color 0.2s ease;
}
.data-table tbody tr:hover {
background-color: var(--hh-light);
}
.btn-primary {
background: linear-gradient(135deg, var(--hh-navy) 0%, var(--hh-blue) 100%);
color: white;
padding: 0.625rem 1.25rem;
border-radius: 0.75rem;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
font-size: 0.875rem;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 86, 150, 0.3);
}
.btn-secondary {
background: white;
color: #475569;
padding: 0.625rem 1.25rem;
border-radius: 0.75rem;
font-weight: 600;
border: 2px solid #e2e8f0;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
font-size: 0.875rem;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #cbd5e1;
transform: translateY(-1px);
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 700;
}
.badge-info {
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
color: #1e40af;
}
.badge-secondary {
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
color: #475569;
}
.badge-warning {
background: linear-gradient(135deg, #fef3c7, #fde68a);
color: #92400e;
}
.badge-success {
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
color: #166534;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
}
</style>
{% endblock %}
{% block content %}
<div class="px-4 py-6">
<!-- Page Header -->
<div class="page-header-gradient animate-in">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold mb-2">
<i data-lucide="trending-up" class="w-7 h-7 inline-block me-2"></i>
{% translate "Complaint Thresholds" %}
</h1>
<p class="text-white/90">{% translate "Configure thresholds for automatic alerts and reports" %}</p>
</div>
<a href="{% url 'complaints:complaint_threshold_create' %}" class="btn-secondary">
<i data-lucide="plus" class="w-4 h-4"></i>
{% translate "Create Threshold" %}
</a>
</div>
<div class="card-body">
<form method="get" class="row g-3">
{% if request.user.is_px_admin %}
<div class="col-md-4">
<label class="form-label">{% translate "Hospital" %}</label>
<select name="hospital" class="form-select">
<option value="">{% translate "All Hospitals" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="col-md-4">
<label class="form-label">{% translate "Threshold Type" %}</label>
<select name="threshold_type" class="form-select">
</div>
<!-- Filters -->
<div class="section-card mb-6 animate-in">
<div class="section-header">
<div class="section-icon secondary">
<i data-lucide="filter" class="w-5 h-5"></i>
</div>
<h2 class="text-lg font-bold text-navy m-0">{% translate "Filters" %}</h2>
</div>
<div class="p-6">
<form method="get" class="flex flex-wrap gap-4">
<div>
<label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Threshold Type" %}</label>
<select name="threshold_type" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% translate "All Types" %}</option>
<option value="daily" {% if filters.threshold_type == "daily" %}selected{% endif %}>{% translate "Daily" %}</option>
<option value="weekly" {% if filters.threshold_type == "weekly" %}selected{% endif %}>{% translate "Weekly" %}</option>
@ -57,23 +213,21 @@
<option value="category" {% if filters.threshold_type == "category" %}selected{% endif %}>{% translate "By Category" %}</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">{% translate "Status" %}</label>
<select name="is_active" class="form-select">
<div>
<label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Status" %}</label>
<select name="is_active" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% translate "All" %}</option>
<option value="true" {% if filters.is_active == "true" %}selected{% endif %}>{% translate "Active" %}</option>
<option value="false" {% if filters.is_active == "false" %}selected{% endif %}>{% translate "Inactive" %}</option>
</select>
</div>
<div class="col-12 text-end">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i>
<div class="flex items-end gap-2">
<button type="submit" class="btn-primary h-[46px]">
<i data-lucide="search" class="w-4 h-4"></i>
{% translate "Apply Filters" %}
</button>
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i>
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn-secondary h-[46px]">
<i data-lucide="x" class="w-4 h-4"></i>
{% translate "Clear" %}
</a>
</div>
@ -82,11 +236,17 @@
</div>
<!-- Thresholds Table -->
<div class="card">
<div class="card-body">
<div class="section-card animate-in">
<div class="section-header">
<div class="section-icon primary">
<i data-lucide="trending-up" class="w-5 h-5"></i>
</div>
<h2 class="text-lg font-bold text-navy m-0">{% translate "All Thresholds" %}</h2>
</div>
<div class="p-0">
{% if thresholds %}
<div class="table-responsive">
<table class="table table-hover">
<div class="overflow-x-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>{% translate "Hospital" %}</th>
@ -97,7 +257,7 @@
<th>{% translate "Category" %}</th>
<th>{% translate "Action" %}</th>
<th>{% translate "Status" %}</th>
<th>{% translate "Actions" %}</th>
<th class="text-right">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
@ -108,10 +268,10 @@
</td>
<td>{{ threshold.name }}</td>
<td>
<span class="badge bg-info">{{ threshold.get_threshold_type_display }}</span>
<span class="badge badge-info">{{ threshold.get_threshold_type_display }}</span>
</td>
<td>
<span class="badge bg-secondary">{{ threshold.get_metric_type_display }}</span>
<span class="badge badge-secondary">{{ threshold.get_metric_type_display }}</span>
</td>
<td>
<strong>{{ threshold.threshold_value }}</strong>
@ -120,25 +280,31 @@
{% if threshold.complaint_category %}
{{ threshold.complaint_category.name }}
{% else %}
<span class="text-muted">{% translate "All Categories" %}</span>
<span class="text-slate-400">{% translate "All Categories" %}</span>
{% endif %}
</td>
<td>
<span class="badge bg-warning text-dark">{{ threshold.get_action_display }}</span>
<span class="badge badge-warning">{{ threshold.get_action_display }}</span>
</td>
<td>
{% if threshold.is_active %}
<span class="badge bg-success">{% translate "Active" %}</span>
<span class="badge badge-success">
<i data-lucide="check-circle" class="w-3 h-3"></i>
{% translate "Active" %}
</span>
{% else %}
<span class="badge bg-secondary">{% translate "Inactive" %}</span>
<span class="badge badge-secondary">
<i data-lucide="x-circle" class="w-3 h-3"></i>
{% translate "Inactive" %}
</span>
{% endif %}
</td>
<td>
<div class="btn-group">
<td class="text-right">
<div class="flex items-center justify-end gap-2">
<a href="{% url 'complaints:complaint_threshold_edit' threshold.id %}"
class="btn btn-sm btn-outline-primary"
class="p-2 text-blue hover:bg-blue-50 rounded-lg transition"
title="{% translate 'Edit' %}">
<i class="fas fa-edit"></i>
<i data-lucide="edit" class="w-4 h-4"></i>
</a>
<form method="post"
action="{% url 'complaints:complaint_threshold_delete' threshold.id %}"
@ -146,9 +312,9 @@
onsubmit="return confirm('{% translate "Are you sure you want to delete this threshold?" %}')">
{% csrf_token %}
<button type="submit"
class="btn btn-sm btn-outline-danger"
class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition"
title="{% translate 'Delete' %}">
<i class="fas fa-trash"></i>
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</div>
@ -161,59 +327,42 @@
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-double-left"></i>
<div class="p-4 border-t border-slate-200">
<div class="flex items-center justify-between">
<p class="text-sm text-slate">
{% blocktrans with start=page_obj.start_index end=page_obj.end_index total=page_obj.paginator.count %}
Showing {{ start }} to {{ end }} of {{ total }} thresholds
{% endblocktrans %}
</p>
<div class="flex gap-2">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
<i data-lucide="chevron-left" class="w-4 h-4 inline"></i>
{% translate "Previous" %}
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-left"></i>
{% endif %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
{% translate "Next" %}
<i data-lucide="chevron-right" class="w-4 h-4 inline"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
{{ num }}
</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-right"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-double-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="fas fa-chart-line fa-3x text-muted mb-3"></i>
<p class="text-muted">
{% translate "No thresholds found. Create your first threshold to get started." %}
</p>
<a href="{% url 'complaints:complaint_threshold_create' %}" class="btn btn-primary">
<i class="fas fa-plus"></i>
<div class="text-center py-12">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="trending-up" class="w-8 h-8 text-slate-400"></i>
</div>
<p class="text-slate font-medium">{% translate "No thresholds found" %}</p>
<p class="text-slate text-sm mt-1">{% translate "Create your first threshold to get started" %}</p>
<a href="{% url 'complaints:complaint_threshold_create' %}" class="btn-primary mt-4">
<i data-lucide="plus" class="w-4 h-4"></i>
{% translate "Create Threshold" %}
</a>
</div>
@ -221,4 +370,10 @@
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}

View File

@ -2,13 +2,21 @@
عزيزي {{ recipient.get_full_name }},
{% if is_unassigned %}
هام: هذا تذكير بشكوى غير معينة تحتاج إلى اهتمامكم.
لم يتم تعيين هذه الشكوى لأي شخص بعد. يرجى تعيينها لأحد أعضاء الفريق المناسبين في أقرب وقت ممكن لضمان معالجتها قبل موعد انتهاء اتفاقية مستوى الخدمة.
بصفتكم مدير مستشفى أو منسق تجربة المرضى، تتلقون هذا الإشعار لأن الشكوى لا تزال معلقة التعيين.
{% else %}
هذه رسالة تذكير آلية لديك شكوى معينة تقترب من موعد نهائي لاتفاقية مستوى الخدمة.
{% endif %}
تفاصيل الشكوى:
- الرقم: #{{ complaint.id|slice:":8" }}
- العنوان: {{ complaint.title }}
- الخطورة: {% if complaint.severity == 'critical' %}حرجة{% elif complaint.severity == 'high' %}عالية{% elif complaint.severity == 'medium' %}متوسطة{% else %}منخفضة{% endif %}
- الأولوية: {% if complaint.priority == 'critical' %}حرجة{% elif complaint.priority == 'high' %}عالية{% elif complaint.priority == 'medium' %}متوسطة{% else %}منخفضة{% endif %}
- الأولوية: {% if complaint.priority == 'urgent' %}عاجلة{% elif complaint.priority == 'high' %}عالية{% elif complaint.priority == 'medium' %}متوسطة{% else %}منخفضة{% endif %}
- الفئة: {% if complaint.category %}{{ complaint.category.name_ar }}{% else %}غير متوفر{% endif %}
- القسم: {% if complaint.department %}{{ complaint.department.name_ar }}{% else %}غير متوفر{% endif %}
- المريض: {% if complaint.patient %}{{ complaint.patient.get_full_name }} (رقم الملف الطبي: {{ complaint.patient.mrn }}){% else %}غير متوفر{% endif %}
@ -16,14 +24,22 @@
معلومات اتفاقية مستوى الخدمة:
- تاريخ الاستحقاق: {{ due_date|date:"Y F d H:i" }}
- الوقت المتبقي: {{ hours_remaining }} ساعة
- الحالة الحالية: {% if complaint.status == 'open' %}مفتوحة{% elif complaint.status == 'in_progress' %قيد التنفيذ{% elif complaint.status == 'resolved' %}تم الحل{% elif complaint.status == 'closed' %}مغلقة{% else %}{{ complaint.status }}{% endif %}
- الحالة الحالية: {% if complaint.status == 'open' %}مفتوحة{% elif complaint.status == 'in_progress' %}قيد التنفيذ{% elif complaint.status == 'resolved' %}تم الحل{% elif complaint.status == 'closed' %}مغلقة{% else %}{{ complaint.status }}{% endif %}
الإجراء المطلوب:
{% if is_unassigned %}
1. تعيين هذه الشكوى لأحد الموظفين المناسبين فوراً
2. التأكد من أن الموظف المُعيَّن على دراية بموعد انتهاء اتفاقية مستوى الخدمة
3. متابعة التقدم لضمان الحل في الوقت المناسب
{% else %}
يرجى مراجعة هذه الشكوى واتخاذ الإجراء المناسب قبل الموعد النهائي لاتفاقية مستوى الخدمة لتجنب تجاوزها.
{% endif %}
يمكنك عرض الشكوى على: {{ site_url }}/complaints/{{ complaint.id }}/
{% if not is_unassigned %}
إذا كنت قد اتخذت بالفعل إجراءً بخصوص هذه الشكوى، يرجى تحديث حالتها في النظام.
{% endif %}
هذه رسالة آلية. يرجى عدم الرد مباشرة على هذا البريد الإلكتروني.

View File

@ -2,7 +2,15 @@ SLA Reminder - Complaint #{{ complaint.id|slice:":8" }}
Dear {{ recipient.get_full_name }},
{% if is_unassigned %}
IMPORTANT: This is a reminder about an UNASSIGNED complaint that needs your attention.
This complaint has not yet been assigned to anyone. Please assign it to an appropriate team member as soon as possible to ensure it is addressed before the SLA deadline.
As a Hospital Administrator or PX Coordinator, you are receiving this notification because the complaint is still pending assignment.
{% else %}
This is an automated reminder that you have an assigned complaint approaching its SLA deadline.
{% endif %}
COMPLAINT DETAILS:
- ID: #{{ complaint.id|slice:":8" }}
@ -19,11 +27,19 @@ SLA INFORMATION:
- Current Status: {{ complaint.get_status_display }}
ACTION REQUIRED:
{% if is_unassigned %}
1. Assign this complaint to an appropriate staff member immediately
2. Ensure the assigned person is aware of the approaching SLA deadline
3. Monitor progress to ensure timely resolution
{% else %}
Please review this complaint and take appropriate action before the SLA deadline to avoid breach.
{% endif %}
You can view the complaint at: {{ site_url }}/complaints/{{ complaint.id }}/
{% if not is_unassigned %}
If you have already addressed this complaint, please update its status in the system.
{% endif %}
This is an automated message. Please do not reply directly to this email.

View File

@ -2,22 +2,38 @@
عزيزي/عزيزتي {{ recipient.get_full_name }},
هذه التذكير الثاني والأخير بأن لديك شكوى ستنتهي مواعيدها في اتفاقية مستوى الخدمة قريباً جداً.
{% if is_unassigned %}
تنبيه هام: هذا تذكير عاجل بشكوى غير معينة تتطلب اهتماماً فورياً.
لم يتم تعيين هذه الشكوى لأي شخص بعد وهي على وشك تجاوز موعد انتهاء اتفاقية مستوى الخدمة. بصفتكم مدير مستشفى أو منسق تجربة المرضى، يجب عليكم اتخاذ إجراء فوري.
هذا التذكير النهائي قبل التصعيد التلقائي.
الإجراء العاجل المطلوب:
1. تعيين هذه الشكوى لأحد الموظفين المناسبين فوراً
2. التأكد من أن الموظف المُعيَّن على دراية بالموعد النهائي الحرج
3. متابعة التقدم بشكل مستمر حتى يتم الحل
عدم تعيين ومعالجة هذه الشكوى قد يؤدي إلى التصعيد التلقائي للإدارة العليا.
{% else %}
هذا التذكير الثاني والأخير بأن لديك شكوى معينة ستنتهي مواعيدها في اتفاقية مستوى الخدمة قريباً جداً.
{% endif %}
تفاصيل الشكوى:
- المعرف: #{{ complaint.id|slice:":8" }}
- العنوان: {{ complaint.title }}
- الخطورة: {{ complaint.get_severity_display_ar }}
- الأولوية: {{ complaint.get_priority_display_ar }}
- الخطورة: {{ complaint.get_severity_display }}
- الأولوية: {{ complaint.get_priority_display }}
- الفئة: {% if complaint.category %}{{ complaint.category.name_ar }}{% else %}غير متوفر{% endif %}
- القسم: {% if complaint.department %}{{ complaint.department.name_ar }}{% else %}غير متوفر{% endif %}
- المريض: {% if complaint.patient %}{{ patient.get_full_name }} (الرقم الطبي: {{ complaint.patient.mrn }}){% else %}غير متوفر{% endif %}
- المريض: {% if complaint.patient %}{{ complaint.patient.get_full_name }} (الرقم الطبي: {{ complaint.patient.mrn }}){% else %}غير متوفر{% endif %}
معلومات اتفاقية مستوى الخدمة:
- تاريخ الاستحقاق: {{ due_date|date:"d F Y H:i" }}
- الوقت المتبقي: {{ hours_remaining }} ساعة
- الحالة الحالية: {{ complaint.get_status_display_ar }}
- الحالة الحالية: {{ complaint.get_status_display }}
{% if not is_unassigned %}
إجراء عاجل مطلوب:
هذه الشكوى تبعد {{ hours_remaining }} ساعة عن تجاوز موعد انتهاء اتفاقية مستوى الخدمة.
يرجى المراجعة واتخاذ إجراء فوري لتجنب التصعيد وعواقب تجاوز الموعد.
@ -26,6 +42,7 @@
1. تحديث حالة الشكوى لتعكس التقدم الحالي
2. إضافة تحديث على الجدول الزمني يوضح التأخير
3. التواصل مع مدير القسم إذا كانت هناك حاجة إلى موارد إضافية
{% endif %}
يمكنك عرض الشكوى على: {{ site_url }}/complaints/{{ complaint.id }}/

View File

@ -2,7 +2,22 @@ URGENT - Second SLA Reminder - Complaint #{{ complaint.id|slice:":8" }}
Dear {{ recipient.get_full_name }},
This is the second and final reminder that you have a complaint that will breach its SLA deadline very soon.
{% if is_unassigned %}
CRITICAL ALERT: This is an URGENT reminder about an UNASSIGNED complaint that requires IMMEDIATE attention.
This complaint has NOT been assigned to anyone and is about to breach its SLA deadline. As a Hospital Administrator or PX Coordinator, you must take immediate action.
This is the FINAL reminder before automatic escalation.
URGENT ACTION REQUIRED:
1. ASSIGN this complaint to an appropriate staff member IMMEDIATELY
2. ENSURE the assigned person is aware of the critical deadline
3. MONITOR progress continuously until resolved
Failure to assign and address this complaint may result in automatic escalation to higher management.
{% else %}
This is the second and final reminder that you have an assigned complaint approaching its SLA deadline.
{% endif %}
COMPLAINT DETAILS:
- ID: #{{ complaint.id|slice:":8" }}
@ -11,13 +26,14 @@ COMPLAINT DETAILS:
- Priority: {{ complaint.get_priority_display }}
- Category: {% if complaint.category %}{{ complaint.category.name_en }}{% else %}N/A{% endif %}
- Department: {% if complaint.department %}{{ complaint.department.name_en }}{% else %}N/A{% endif %}
- Patient: {% if complaint.patient %}{{ patient.get_full_name }} (MRN: {{ complaint.patient.mrn }}){% else %}N/A{% endif %}
- Patient: {% if complaint.patient %}{{ complaint.patient.get_full_name }} (MRN: {{ complaint.patient.mrn }}){% else %}N/A{% endif %}
SLA INFORMATION:
- Due Date: {{ due_date|date:"F d, Y H:i" }}
- Time Remaining: {{ hours_remaining }} hours
- Current Status: {{ complaint.get_status_display }}
{% if not is_unassigned %}
URGENT ACTION REQUIRED:
This complaint is {{ hours_remaining }} hours from breaching its SLA deadline.
Please review and take immediate action to avoid escalation and SLA breach consequences.
@ -26,6 +42,7 @@ If this complaint cannot be resolved within the remaining time, please:
1. Update the complaint status to reflect current progress
2. Add a timeline update explaining the delay
3. Contact your department manager if additional resources are needed
{% endif %}
You can view the complaint at: {{ site_url }}/complaints/{{ complaint.id }}/

View File

@ -3,307 +3,467 @@
{% block title %}{{ title }} - {% translate "Escalation Rules" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.form-section {
background: #fff;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
}
.form-section:hover {
border-color: #005696;
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
}
.form-control, .form-select {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
outline: none;
border-color: #005696;
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #005696;
color: white;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
border: none;
cursor: pointer;
}
.btn-primary:hover {
background: #007bbd;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: white;
color: #64748b;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #005696;
}
.help-card {
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
}
.form-switch {
display: flex;
align-items: center;
gap: 0.75rem;
}
.form-switch-input {
width: 3rem;
height: 1.5rem;
appearance: none;
background: #cbd5e1;
border-radius: 1rem;
position: relative;
cursor: pointer;
transition: all 0.2s ease;
}
.form-switch-input:checked {
background: #005696;
}
.form-switch-input::after {
content: '';
position: absolute;
width: 1.25rem;
height: 1.25rem;
background: white;
border-radius: 50%;
top: 0.125rem;
left: 0.125rem;
transition: all 0.2s ease;
}
.form-switch-input:checked::after {
left: 1.625rem;
}
.invalid-feedback {
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.form-text {
color: #64748b;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.alert-info {
background: #dbeafe;
border: 1px solid #93c5fd;
border-radius: 0.75rem;
padding: 1rem;
}
.alert-info ol {
color: #1e40af;
font-size: 0.875rem;
padding-left: 1.25rem;
}
.alert-info li {
margin-bottom: 0.25rem;
}
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<div class="page-header-content">
<div>
<h1 class="page-title">
<i class="fas fa-arrow-up"></i>
{{ title }}
</h1>
<p class="page-description">
{% if escalation_rule %}
{% translate "Edit escalation rule" %}
{% else %}
{% translate "Create new escalation rule" %}
{% endif %}
</p>
<!-- Gradient Header -->
<div class="page-header-gradient">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<i data-lucide="arrow-up-circle" class="w-6 h-6"></i>
</div>
<div>
<h1 class="text-2xl font-bold">{{ title }}</h1>
<p class="text-white/80">
{% if escalation_rule %}{% translate "Edit escalation rule" %}{% else %}{% translate "Create new escalation rule" %}{% endif %}
</p>
</div>
</div>
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i>
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
{% translate "Back to List" %}
</a>
</div>
</div>
<div class="page-content">
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<form method="post" class="row g-3">
{% csrf_token %}
{% if request.user.is_px_admin %}
<div class="col-md-12">
<label for="id_hospital" class="form-label">
{% translate "Hospital" %} <span class="text-danger">*</span>
</label>
<select name="hospital" id="id_hospital" class="form-select" required>
<option value="">{% translate "Select Hospital" %}</option>
{% for hospital in form.hospital.field.queryset %}
<option value="{{ hospital.id }}"
{% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name }}
</option>
{% endfor %}
</select>
{% if form.hospital.errors %}
<div class="invalid-feedback d-block">
{{ form.hospital.errors.0 }}
</div>
{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<div class="form-section">
<form method="post" class="space-y-6">
{% csrf_token %}
{% if not form.hospital.is_hidden %}
<div>
<label for="id_hospital" class="form-label">
{% translate "Hospital" %} <span class="text-red-500">*</span>
</label>
<select name="hospital" id="id_hospital" class="form-select" required>
<option value="">{% translate "Select Hospital" %}</option>
{% for hospital in form.hospital.field.queryset %}
<option value="{{ hospital.id }}"
{% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name }}
</option>
{% endfor %}
</select>
{% if form.hospital.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.hospital.errors.0 }}
</div>
{% endif %}
</div>
{% endif %}
<div>
<label for="id_name" class="form-label">
{% translate "Rule Name" %} <span class="text-red-500">*</span>
</label>
<input type="text"
name="name"
id="id_name"
class="form-control"
value="{{ form.name.value|default:'' }}"
required
placeholder="{% translate 'e.g., Level 1 Escalation - High Priority' %}">
{% if form.name.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.name.errors.0 }}
</div>
{% endif %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="id_escalation_level" class="form-label">
{% translate "Escalation Level" %} <span class="text-red-500">*</span>
</label>
<select name="escalation_level" id="id_escalation_level" class="form-select" required>
<option value="">{% translate "Select Level" %}</option>
{% for value, label in form.escalation_level.field.choices %}
<option value="{{ value }}"
{% if form.escalation_level.value == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.escalation_level.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.escalation_level.errors.0 }}
</div>
{% endif %}
<div class="col-md-12">
<label for="id_name" class="form-label">
{% translate "Rule Name" %} <span class="text-danger">*</span>
</label>
<input type="text"
name="name"
id="id_name"
class="form-control"
value="{{ form.name.value|default:'' }}"
required
placeholder="{% translate 'e.g., Level 1 Escalation - High Priority' %}">
{% if form.name.errors %}
<div class="invalid-feedback d-block">
{{ form.name.errors.0 }}
</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="id_escalation_level" class="form-label">
{% translate "Escalation Level" %} <span class="text-danger">*</span>
</label>
<select name="escalation_level" id="id_escalation_level" class="form-select" required>
<option value="">{% translate "Select Level" %}</option>
{% for value, label in form.escalation_level.field.choices %}
<option value="{{ value }}"
{% if form.escalation_level.value == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.escalation_level.errors %}
<div class="invalid-feedback d-block">
{{ form.escalation_level.errors.0 }}
</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="id_trigger_hours" class="form-label">
{% translate "Trigger Hours" %} <span class="text-danger">*</span>
</label>
<input type="number"
name="trigger_hours"
id="id_trigger_hours"
class="form-control"
value="{{ form.trigger_hours.value|default:'' }}"
min="1"
step="0.5"
required
placeholder="{% translate 'e.g., 24' %}">
{% if form.trigger_hours.errors %}
<div class="invalid-feedback d-block">
{{ form.trigger_hours.errors.0 }}
</div>
{% else %}
<div class="form-text">
{% translate "Hours after complaint creation to trigger escalation" %}
</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="id_escalate_to_role" class="form-label">
{% translate "Escalate To Role" %}
</label>
<select name="escalate_to_role" id="id_escalate_to_role" class="form-select">
<option value="">{% translate "Select Role" %}</option>
{% for value, label in form.escalate_to_role.field.choices %}
<option value="{{ value }}"
{% if form.escalate_to_role.value == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.escalate_to_role.errors %}
<div class="invalid-feedback d-block">
{{ form.escalate_to_role.errors.0 }}
</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="id_escalate_to_user" class="form-label">
{% translate "Escalate To Specific User" %}
</label>
<select name="escalate_to_user" id="id_escalate_to_user" class="form-select">
<option value="">{% translate "Select User (Optional)" %}</option>
{% for user in users %}
<option value="{{ user.id }}"
{% if form.escalate_to_user.value == user.id|stringformat:"s" %}selected{% endif %}>
{{ user.get_full_name }} ({{ user.email }})
</option>
{% endfor %}
</select>
{% if form.escalate_to_user.errors %}
<div class="invalid-feedback d-block">
{{ form.escalate_to_user.errors.0 }}
</div>
{% else %}
<div class="form-text">
{% translate "Overrides role if specified" %}
</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="id_severity" class="form-label">
{% translate "Severity (Optional)" %}
</label>
<select name="severity" id="id_severity" class="form-select">
<option value="">{% translate "All Severities" %}</option>
{% for value, label in form.severity.field.choices %}
<option value="{{ value }}"
{% if form.severity.value == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.severity.errors %}
<div class="invalid-feedback d-block">
{{ form.severity.errors.0 }}
</div>
{% else %}
<div class="form-text">
{% translate "Leave empty to apply to all severities" %}
</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="id_priority" class="form-label">
{% translate "Priority (Optional)" %}
</label>
<select name="priority" id="id_priority" class="form-select">
<option value="">{% translate "All Priorities" %}</option>
{% for value, label in form.priority.field.choices %}
<option value="{{ value }}"
{% if form.priority.value == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.priority.errors %}
<div class="invalid-feedback d-block">
{{ form.priority.errors.0 }}
</div>
{% else %}
<div class="form-text">
{% translate "Leave empty to apply to all priorities" %}
</div>
{% endif %}
</div>
<div class="col-12">
<div class="form-check form-switch">
<input type="checkbox"
name="is_active"
id="id_is_active"
class="form-check-input"
{% if form.is_active.value == 'on' or not form.is_active.value %}checked{% endif %}>
<label class="form-check-label" for="id_is_active">
{% translate "Active" %}
</label>
</div>
<div class="form-text">
{% translate "Only active rules will be triggered" %}
</div>
</div>
<div class="col-12">
<label for="id_description" class="form-label">
{% translate "Description" %}
</label>
<textarea name="description"
id="id_description"
class="form-control"
rows="3"
placeholder="{% translate 'Optional notes about this escalation rule' %}">{{ form.description.value|default:'' }}</textarea>
{% if form.description.errors %}
<div class="invalid-feedback d-block">
{{ form.description.errors.0 }}
</div>
{% endif %}
</div>
<div class="col-12">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
{{ action }}
</button>
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i>
{% translate "Cancel" %}
</a>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card bg-light">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-info-circle"></i>
{% translate "Help" %}
</h5>
<h6 class="card-subtitle mb-3 text-muted">
{% translate "Understanding Escalation Rules" %}
</h6>
<p class="card-text">
{% translate "Escalation rules automatically reassign complaints to higher-level staff when they exceed specified time thresholds." %}
</p>
<ul class="list-unstyled">
<li class="mb-2">
<i class="fas fa-check text-success me-2"></i>
{% translate "Level 1: Escalate to department head" %}
</li>
<li class="mb-2">
<i class="fas fa-check text-success me-2"></i>
{% translate "Level 2: Escalate to hospital admin" %}
</li>
<li class="mb-2">
<i class="fas fa-check text-success me-2"></i>
{% translate "Level 3: Escalate to PX admin" %}
</li>
</ul>
</div>
<hr>
<h6 class="card-subtitle mb-2 text-muted">
{% translate "Escalation Flow" %}
</h6>
<div class="alert alert-info">
<ol class="mb-0">
<li>{% translate "Complaint created" %}</li>
<li>{% translate "Trigger hours pass" %}</li>
<li>{% translate "Rule checks severity/priority" %}</li>
<li>{% translate "Complaint reassigned automatically" %}</li>
<li>{% translate "Notification sent to new assignee" %}</li>
</ol>
<div>
<label for="id_trigger_hours" class="form-label">
{% translate "Trigger Hours" %} <span class="text-red-500">*</span>
</label>
<input type="number"
name="trigger_hours"
id="id_trigger_hours"
class="form-control"
value="{{ form.trigger_hours.value|default:'' }}"
min="1"
step="0.5"
required
placeholder="{% translate 'e.g., 24' %}">
{% if form.trigger_hours.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.trigger_hours.errors.0 }}
</div>
{% else %}
<div class="form-text">
{% translate "Hours after complaint creation to trigger escalation" %}
</div>
{% endif %}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="id_escalate_to_role" class="form-label">
{% translate "Escalate To Role" %}
</label>
<select name="escalate_to_role" id="id_escalate_to_role" class="form-select">
<option value="">{% translate "Select Role" %}</option>
{% for value, label in form.escalate_to_role.field.choices %}
<option value="{{ value }}"
{% if form.escalate_to_role.value == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.escalate_to_role.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.escalate_to_role.errors.0 }}
</div>
{% endif %}
</div>
<div>
<label for="id_escalate_to_user" class="form-label">
{% translate "Escalate To Specific User" %}
</label>
<select name="escalate_to_user" id="id_escalate_to_user" class="form-select">
<option value="">{% translate "Select User (Optional)" %}</option>
{% for user in users %}
<option value="{{ user.id }}"
{% if form.escalate_to_user.value == user.id|stringformat:"s" %}selected{% endif %}>
{{ user.get_full_name }} ({{ user.email }})
</option>
{% endfor %}
</select>
{% if form.escalate_to_user.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.escalate_to_user.errors.0 }}
</div>
{% else %}
<div class="form-text">
{% translate "Overrides role if specified" %}
</div>
{% endif %}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="id_severity" class="form-label">
{% translate "Severity (Optional)" %}
</label>
<select name="severity" id="id_severity" class="form-select">
<option value="">{% translate "All Severities" %}</option>
{% for value, label in form.severity.field.choices %}
<option value="{{ value }}"
{% if form.severity.value == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.severity.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.severity.errors.0 }}
</div>
{% else %}
<div class="form-text">
{% translate "Leave empty to apply to all severities" %}
</div>
{% endif %}
</div>
<div>
<label for="id_priority" class="form-label">
{% translate "Priority (Optional)" %}
</label>
<select name="priority" id="id_priority" class="form-select">
<option value="">{% translate "All Priorities" %}</option>
{% for value, label in form.priority.field.choices %}
<option value="{{ value }}"
{% if form.priority.value == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.priority.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.priority.errors.0 }}
</div>
{% else %}
<div class="form-text">
{% translate "Leave empty to apply to all priorities" %}
</div>
{% endif %}
</div>
</div>
<div class="flex items-center gap-3 p-4 bg-gray-50 rounded-xl">
<div class="form-switch">
<input type="checkbox"
name="is_active"
id="id_is_active"
class="form-switch-input"
{% if form.is_active.value == 'on' or not form.is_active.value %}checked{% endif %}>
<label for="id_is_active" class="text-sm font-semibold text-gray-700">
{% translate "Active" %}
</label>
</div>
<div class="form-text ml-2">
{% translate "Only active rules will be triggered" %}
</div>
</div>
<div>
<label for="id_description" class="form-label">
{% translate "Description" %}
</label>
<textarea name="description"
id="id_description"
class="form-control resize-none"
rows="3"
placeholder="{% translate 'Optional notes about this escalation rule' %}">{{ form.description.value|default:'' }}</textarea>
{% if form.description.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.description.errors.0 }}
</div>
{% endif %}
</div>
<div class="flex items-center gap-3 pt-6 border-t border-gray-100">
<button type="submit" class="btn-primary">
<i data-lucide="save" class="w-4 h-4"></i>
{{ action }}
</button>
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn-secondary">
<i data-lucide="x" class="w-4 h-4"></i>
{% translate "Cancel" %}
</a>
</div>
</form>
</div>
</div>
<div class="lg:col-span-1">
<div class="help-card">
<h5 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
<i data-lucide="info" class="w-5 h-5 text-[#005696]"></i>
{% translate "Help" %}
</h5>
<h6 class="text-sm font-semibold text-gray-500 mb-3">
{% translate "Understanding Escalation Rules" %}
</h6>
<p class="text-sm text-gray-600 mb-4">
{% translate "Escalation rules automatically reassign complaints to higher-level staff when they exceed specified time thresholds." %}
</p>
<ul class="space-y-2 text-sm text-gray-600">
<li class="flex items-center gap-2">
<i data-lucide="check-circle" class="w-4 h-4 text-green-500"></i>
{% translate "Level 1: Escalate to department head" %}
</li>
<li class="flex items-center gap-2">
<i data-lucide="check-circle" class="w-4 h-4 text-green-500"></i>
{% translate "Level 2: Escalate to hospital admin" %}
</li>
<li class="flex items-center gap-2">
<i data-lucide="check-circle" class="w-4 h-4 text-green-500"></i>
{% translate "Level 3: Escalate to PX admin" %}
</li>
</ul>
<hr class="my-4 border-gray-200">
<h6 class="text-sm font-semibold text-gray-500 mb-3">
{% translate "Escalation Flow" %}
</h6>
<div class="alert-info">
<ol>
<li>{% translate "Complaint created" %}</li>
<li>{% translate "Trigger hours pass" %}</li>
<li>{% translate "Rule checks severity/priority" %}</li>
<li>{% translate "Complaint reassigned automatically" %}</li>
<li>{% translate "Notification sent to new assignee" %}</li>
</ol>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}

View File

@ -3,76 +3,265 @@
{% block title %}{% translate "Escalation Rules" %} - PX360{% endblock %}
{% block content %}
<div class="page-header">
<div class="page-header-content">
<div>
<h1 class="page-title">
<i class="fas fa-arrow-up"></i>
{% translate "Escalation Rules" %}
</h1>
<p class="page-description">
{% translate "Configure automatic complaint escalation based on time thresholds" %}
</p>
</div>
<a href="{% url 'complaints:escalation_rule_create' %}" class="btn btn-primary">
<i class="fas fa-plus"></i>
{% translate "Create Escalation Rule" %}
</a>
</div>
</div>
{% block extra_css %}
<style>
:root {
--hh-navy: #005696;
--hh-blue: #007bbd;
--hh-light: #eef6fb;
--hh-slate: #64748b;
}
<div class="page-content">
<!-- Filters -->
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title">
<i class="fas fa-filter"></i>
{% translate "Filters" %}
</h5>
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.section-card {
background: white;
border-radius: 1rem;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-icon {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.section-icon.primary {
background: linear-gradient(135deg, #005696, #007bbd);
color: white;
}
.section-icon.secondary {
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
color: #005696;
}
.data-table th {
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
padding: 0.875rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--hh-navy);
border-bottom: 2px solid #bae6fd;
}
.data-table td {
padding: 1rem;
border-bottom: 1px solid #f1f5f9;
color: #475569;
font-size: 0.875rem;
}
.data-table tbody tr {
transition: background-color 0.2s ease;
}
.data-table tbody tr:hover {
background-color: var(--hh-light);
}
.btn-primary {
background: linear-gradient(135deg, var(--hh-navy) 0%, var(--hh-blue) 100%);
color: white;
padding: 0.625rem 1.25rem;
border-radius: 0.75rem;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
font-size: 0.875rem;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 86, 150, 0.3);
}
.btn-secondary {
background: white;
color: #475569;
padding: 0.625rem 1.25rem;
border-radius: 0.75rem;
font-weight: 600;
border: 2px solid #e2e8f0;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
font-size: 0.875rem;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #cbd5e1;
transform: translateY(-1px);
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 700;
}
.badge-info {
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
color: #1e40af;
}
.badge-secondary {
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
color: #475569;
}
.badge-success {
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
color: #166534;
}
.severity-low {
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
color: #166534;
}
.severity-medium {
background: linear-gradient(135deg, #fef3c7, #fde68a);
color: #92400e;
}
.severity-high {
background: linear-gradient(135deg, #fee2e2, #fecaca);
color: #991b1b;
}
.severity-critical {
background: linear-gradient(135deg, #7f1d1d, #991b1b);
color: white;
}
.priority-low {
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
color: #475569;
}
.priority-medium {
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
color: #1e40af;
}
.priority-high {
background: linear-gradient(135deg, #fef3c7, #fde68a);
color: #92400e;
}
.priority-urgent {
background: linear-gradient(135deg, #fee2e2, #fecaca);
color: #991b1b;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
}
</style>
{% endblock %}
{% block content %}
<div class="px-4 py-6">
<!-- Page Header -->
<div class="page-header-gradient animate-in">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold mb-2">
<i data-lucide="arrow-up-circle" class="w-7 h-7 inline-block me-2"></i>
{% translate "Escalation Rules" %}
</h1>
<p class="text-white/90">{% translate "Configure automatic complaint escalation based on time thresholds" %}</p>
</div>
<a href="{% url 'complaints:escalation_rule_create' %}" class="btn-secondary">
<i data-lucide="plus" class="w-4 h-4"></i>
{% translate "Create Rule" %}
</a>
</div>
<div class="card-body">
<form method="get" class="row g-3">
{% if request.user.is_px_admin %}
<div class="col-md-4">
<label class="form-label">{% translate "Hospital" %}</label>
<select name="hospital" class="form-select">
<option value="">{% translate "All Hospitals" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
{{ hospital.name }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="col-md-4">
<label class="form-label">{% translate "Escalation Level" %}</label>
<select name="escalation_level" class="form-select">
</div>
<!-- Filters -->
<div class="section-card mb-6 animate-in">
<div class="section-header">
<div class="section-icon secondary">
<i data-lucide="filter" class="w-5 h-5"></i>
</div>
<h2 class="text-lg font-bold text-navy m-0">{% translate "Filters" %}</h2>
</div>
<div class="p-6">
<form method="get" class="flex flex-wrap gap-4">
<div>
<label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Escalation Level" %}</label>
<select name="escalation_level" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% translate "All Levels" %}</option>
<option value="1" {% if filters.escalation_level == "1" %}selected{% endif %}>1</option>
<option value="2" {% if filters.escalation_level == "2" %}selected{% endif %}>2</option>
<option value="3" {% if filters.escalation_level == "3" %}selected{% endif %}>3</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">{% translate "Status" %}</label>
<select name="is_active" class="form-select">
<div>
<label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Status" %}</label>
<select name="is_active" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% translate "All" %}</option>
<option value="true" {% if filters.is_active == "true" %}selected{% endif %}>{% translate "Active" %}</option>
<option value="false" {% if filters.is_active == "false" %}selected{% endif %}>{% translate "Inactive" %}</option>
</select>
</div>
<div class="col-12 text-end">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i>
<div class="flex items-end gap-2">
<button type="submit" class="btn-primary h-[46px]">
<i data-lucide="search" class="w-4 h-4"></i>
{% translate "Apply Filters" %}
</button>
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i>
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn-secondary h-[46px]">
<i data-lucide="x" class="w-4 h-4"></i>
{% translate "Clear" %}
</a>
</div>
@ -81,11 +270,17 @@
</div>
<!-- Escalation Rules Table -->
<div class="card">
<div class="card-body">
<div class="section-card animate-in">
<div class="section-header">
<div class="section-icon primary">
<i data-lucide="arrow-up-circle" class="w-5 h-5"></i>
</div>
<h2 class="text-lg font-bold text-navy m-0">{% translate "All Escalation Rules" %}</h2>
</div>
<div class="p-0">
{% if escalation_rules %}
<div class="table-responsive">
<table class="table table-hover">
<div class="overflow-x-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>{% translate "Hospital" %}</th>
@ -96,7 +291,7 @@
<th>{% translate "Severity" %}</th>
<th>{% translate "Priority" %}</th>
<th>{% translate "Status" %}</th>
<th>{% translate "Actions" %}</th>
<th class="text-right">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
@ -107,7 +302,7 @@
</td>
<td>{{ rule.name }}</td>
<td>
<span class="badge bg-info">
<span class="badge badge-info">
{% translate "Level" %} {{ rule.escalation_level }}
</span>
</td>
@ -116,9 +311,9 @@
{% if rule.escalate_to_user %}
{{ rule.escalate_to_user.get_full_name }}
{% elif rule.escalate_to_role %}
<span class="badge bg-secondary">{{ rule.get_escalate_to_role_display }}</span>
<span class="badge badge-secondary">{{ rule.get_escalate_to_role_display }}</span>
{% else %}
<span class="text-muted">-</span>
<span class="text-slate-400">-</span>
{% endif %}
</td>
<td>
@ -127,7 +322,7 @@
{{ rule.get_severity_display }}
</span>
{% else %}
<span class="text-muted">{% translate "All" %}</span>
<span class="text-slate-400">{% translate "All" %}</span>
{% endif %}
</td>
<td>
@ -136,22 +331,28 @@
{{ rule.get_priority_display }}
</span>
{% else %}
<span class="text-muted">{% translate "All" %}</span>
<span class="text-slate-400">{% translate "All" %}</span>
{% endif %}
</td>
<td>
{% if rule.is_active %}
<span class="badge bg-success">{% translate "Active" %}</span>
<span class="badge badge-success">
<i data-lucide="check-circle" class="w-3 h-3"></i>
{% translate "Active" %}
</span>
{% else %}
<span class="badge bg-secondary">{% translate "Inactive" %}</span>
<span class="badge badge-secondary">
<i data-lucide="x-circle" class="w-3 h-3"></i>
{% translate "Inactive" %}
</span>
{% endif %}
</td>
<td>
<div class="btn-group">
<td class="text-right">
<div class="flex items-center justify-end gap-2">
<a href="{% url 'complaints:escalation_rule_edit' rule.id %}"
class="btn btn-sm btn-outline-primary"
class="p-2 text-blue hover:bg-blue-50 rounded-lg transition"
title="{% translate 'Edit' %}">
<i class="fas fa-edit"></i>
<i data-lucide="edit" class="w-4 h-4"></i>
</a>
<form method="post"
action="{% url 'complaints:escalation_rule_delete' rule.id %}"
@ -159,9 +360,9 @@
onsubmit="return confirm('{% translate "Are you sure you want to delete this escalation rule?" %}')">
{% csrf_token %}
<button type="submit"
class="btn btn-sm btn-outline-danger"
class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition"
title="{% translate 'Delete' %}">
<i class="fas fa-trash"></i>
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</div>
@ -174,59 +375,42 @@
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-double-left"></i>
<div class="p-4 border-t border-slate-200">
<div class="flex items-center justify-between">
<p class="text-sm text-slate">
{% blocktrans with start=page_obj.start_index end=page_obj.end_index total=page_obj.paginator.count %}
Showing {{ start }} to {{ end }} of {{ total }} rules
{% endblocktrans %}
</p>
<div class="flex gap-2">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
<i data-lucide="chevron-left" class="w-4 h-4 inline"></i>
{% translate "Previous" %}
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-left"></i>
{% endif %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
class="px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition text-sm font-medium">
{% translate "Next" %}
<i data-lucide="chevron-right" class="w-4 h-4 inline"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
{{ num }}
</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-right"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="fas fa-angle-double-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="fas fa-arrow-up fa-3x text-muted mb-3"></i>
<p class="text-muted">
{% translate "No escalation rules found. Create your first rule to get started." %}
</p>
<a href="{% url 'complaints:escalation_rule_create' %}" class="btn btn-primary">
<i class="fas fa-plus"></i>
<div class="text-center py-12">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="arrow-up-circle" class="w-8 h-8 text-slate-400"></i>
</div>
<p class="text-slate font-medium">{% translate "No escalation rules found" %}</p>
<p class="text-slate text-sm mt-1">{% translate "Create your first rule to get started" %}</p>
<a href="{% url 'complaints:escalation_rule_create' %}" class="btn-primary mt-4">
<i data-lucide="plus" class="w-4 h-4"></i>
{% translate "Create Escalation Rule" %}
</a>
</div>
@ -234,4 +418,10 @@
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %}

View File

@ -24,23 +24,86 @@
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body { font-family: 'Inter', sans-serif; }
.page-header-gradient {
background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white;
padding: 1.5rem 2rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
}
.form-section {
background: #fff;
border: 2px solid #e2e8f0;
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
}
.form-section:hover {
border-color: #005696;
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1);
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
}
.form-control, .form-select {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
outline: none;
border-color: #005696;
box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1);
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #005696;
color: white;
border-radius: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
border: none;
cursor: pointer;
width: 100%;
}
.btn-primary:hover {
background: #007bbd;
}
</style>
</head>
<body class="bg-light min-h-screen flex items-center py-8 px-4">
<div class="w-full max-w-3xl mx-auto">
<!-- Logo/Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-navy rounded-2xl mb-4">
<i data-lucide="message-square" class="w-8 h-8 text-white"></i>
<!-- Gradient Header -->
<div class="page-header-gradient text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-white/20 rounded-2xl mb-4">
<i data-lucide="message-square" class="w-8 h-8"></i>
</div>
<h1 class="text-2xl font-bold text-navy">{% trans "Submit Your Explanation" %}</h1>
<p class="text-slate mt-1">{% trans "PX360 Complaint Management System" %}</p>
<h1 class="text-2xl font-bold">{% trans "Submit Your Explanation" %}</h1>
<p class="text-white/80 mt-1">{% trans "PX360 Complaint Management System" %}</p>
</div>
<!-- Main Card -->
<div class="bg-white rounded-2xl shadow-lg border border-slate-100 overflow-hidden">
<!-- Main Form Section -->
<div class="form-section">
{% if error %}
<div class="bg-red-50 border-l-4 border-red-500 p-4 m-6 rounded-r-lg">
<div class="bg-red-50 border-l-4 border-red-500 p-4 mb-6 rounded-r-lg">
<div class="flex items-center gap-3">
<i data-lucide="alert-circle" class="w-5 h-5 text-red-500 flex-shrink-0"></i>
<p class="text-red-700 font-medium">{{ error }}</p>
@ -50,7 +113,7 @@
<!-- Staff Information -->
{% if explanation.staff %}
<div class="bg-navy p-6 text-white">
<div class="bg-navy p-6 text-white rounded-t-lg -mx-6 -mt-6 mb-6">
<h2 class="text-sm font-semibold text-blue-200 mb-3 flex items-center gap-2">
<i data-lucide="user" class="w-4 h-4"></i>
{% trans "Requested From" %}
@ -86,7 +149,7 @@
<!-- Original Staff Explanation (shown to manager during escalation) -->
{% if original_explanation %}
<div class="bg-orange-50 border-b border-orange-200 p-6">
<div class="bg-orange-50 border border-orange-200 rounded-xl p-6 mb-6">
<div class="flex items-center gap-2 mb-4">
<div class="w-8 h-8 bg-orange-500 rounded-full flex items-center justify-center">
<i data-lucide="arrow-up-circle" class="w-4 h-4 text-white"></i>
@ -123,7 +186,7 @@
{% endif %}
<!-- Complaint Details -->
<div class="bg-light/50 border-b border-slate-100 p-6">
<div class="bg-light/50 border border-slate-200 rounded-xl p-6 mb-6">
<h2 class="text-lg font-bold text-navy mb-4 flex items-center gap-2">
<i data-lucide="file-text" class="w-5 h-5 text-blue"></i>
{% trans "Complaint Details" %}
@ -137,26 +200,6 @@
<span class="text-slate text-sm">{% trans "Title:" %}</span>
<span class="font-bold text-navy">{{ complaint.title }}</span>
</div>
{% comment %} <div class="flex items-center gap-2">
<span class="text-slate text-sm">{% trans "Severity:" %}</span>
<span class="px-2.5 py-1 rounded-lg text-xs font-bold
{% if complaint.severity == 'critical' %}bg-red-100 text-red-600
{% elif complaint.severity == 'high' %}bg-orange-100 text-orange-600
{% elif complaint.severity == 'medium' %}bg-yellow-100 text-yellow-600
{% else %}bg-green-100 text-green-600{% endif %}">
{{ complaint.get_severity_display }}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-slate text-sm">{% trans "Priority:" %}</span>
<span class="px-2.5 py-1 rounded-lg text-xs font-bold
{% if complaint.priority == 'urgent' %}bg-red-100 text-red-600
{% elif complaint.priority == 'high' %}bg-orange-100 text-orange-600
{% elif complaint.priority == 'medium' %}bg-yellow-100 text-yellow-600
{% else %}bg-blue-100 text-blue-600{% endif %}">
{{ complaint.get_priority_display }}
</span>
</div> {% endcomment %}
{% if complaint.patient %}
<div class="md:col-span-2 flex items-center gap-2">
<span class="text-slate text-sm">{% trans "Patient:" %}</span>
@ -174,12 +217,12 @@
</div>
<!-- Form -->
<form method="post" enctype="multipart/form-data" class="p-6 space-y-6">
<form method="post" enctype="multipart/form-data" class="space-y-6">
{% csrf_token %}
<!-- Explanation Field -->
<div>
<label for="explanation" class="block text-sm font-bold text-navy mb-2">
<label for="explanation" class="form-label">
{% trans "Your Explanation" %} <span class="text-red-500">*</span>
</label>
<p class="text-slate text-sm mb-3">
@ -190,14 +233,14 @@
name="explanation"
rows="6"
required
class="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition resize-none text-sm"
class="form-control resize-none"
placeholder="{% trans 'Write your explanation here...' %}"
></textarea>
</div>
<!-- Attachments -->
<div>
<label for="attachments" class="block text-sm font-bold text-navy mb-2">
<label for="attachments" class="form-label">
{% trans "Attachments (Optional)" %}
</label>
<p class="text-slate text-sm mb-3">
@ -209,7 +252,7 @@
id="attachments"
name="attachments"
multiple
class="block w-full text-sm text-slate file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-light file:text-navy hover:file:bg-slate-100 file:transition cursor-pointer border border-slate-200 rounded-xl"
class="block w-full text-sm text-slate file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-light file:text-navy hover:file:bg-slate-100 file:transition cursor-pointer border-2 border-slate-200 rounded-xl"
>
</div>
<p class="text-xs text-slate mt-2">
@ -231,14 +274,14 @@
</div>
<!-- Submit Button -->
<button type="submit" class="w-full px-6 py-4 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-bold text-lg hover:opacity-90 transition flex items-center justify-center gap-2 shadow-lg">
<button type="submit" class="btn-primary py-4 text-lg">
<i data-lucide="send" class="w-5 h-5"></i>
{% trans "Submit Explanation" %}
</button>
</form>
<!-- Footer -->
<div class="bg-light/30 border-t border-slate-100 p-4 text-center">
<div class="mt-6 pt-4 border-t border-slate-100 text-center">
<p class="text-slate text-xs">{% trans "PX360 Complaint Management System" %}</p>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More