This commit is contained in:
ismail 2026-03-15 23:48:45 +03:00
parent 1091dcdfde
commit 0fc06151eb
157 changed files with 1700336 additions and 15546 deletions

View File

@ -70,7 +70,7 @@ ADMIN_URL=admin/
# Integration APIs # Integration APIs
# HIS API - Hospital Information System for fetching patient discharge data # HIS API - Hospital Information System for fetching patient discharge data
HIS_API_URL=https://his.alhammadi.med.sa:54380/SSRCE/API/FetchPatientVisitTimeStamps HIS_API_URL=https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps
HIS_API_USERNAME=your_his_username HIS_API_USERNAME=your_his_username
HIS_API_PASSWORD=your_his_password HIS_API_PASSWORD=your_his_password
HIS_API_KEY= HIS_API_KEY=

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

View File

@ -599,6 +599,10 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
hospital_id = self.data.get("hospital") hospital_id = self.data.get("hospital")
elif self.initial.get("hospital"): elif self.initial.get("hospital"):
hospital_id = self.initial.get("hospital") hospital_id = self.initial.get("hospital")
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: elif self.user and self.user.hospital:
hospital_id = self.user.hospital.id hospital_id = self.user.hospital.id
@ -714,6 +718,10 @@ class InquiryForm(HospitalFieldMixin, forms.ModelForm):
hospital_id = self.data.get("hospital") hospital_id = self.data.get("hospital")
elif self.initial.get("hospital"): elif self.initial.get("hospital"):
hospital_id = self.initial.get("hospital") hospital_id = self.initial.get("hospital")
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: elif self.user and self.user.hospital:
hospital_id = self.user.hospital.id hospital_id = self.user.hospital.id
@ -729,39 +737,81 @@ class SLAConfigForm(HospitalFieldMixin, forms.ModelForm):
class Meta: class Meta:
model = ComplaintSLAConfig model = ComplaintSLAConfig
fields = ["hospital", "severity", "priority", "sla_hours", "reminder_hours_before", "is_active"] fields = [
"hospital",
"source",
"severity",
"priority",
"sla_hours",
"first_reminder_hours_after",
"second_reminder_hours_after",
"reminder_hours_before",
"second_reminder_enabled",
"second_reminder_hours_before",
"is_active",
]
widgets = { widgets = {
"hospital": forms.Select(attrs={"class": "form-select"}), "hospital": forms.Select(attrs={"class": "form-select"}),
"source": forms.Select(attrs={"class": "form-select"}),
"severity": forms.Select(attrs={"class": "form-select"}), "severity": forms.Select(attrs={"class": "form-select"}),
"priority": forms.Select(attrs={"class": "form-select"}), "priority": forms.Select(attrs={"class": "form-select"}),
"sla_hours": forms.NumberInput(attrs={"class": "form-control", "min": "1"}), "sla_hours": forms.NumberInput(attrs={"class": "form-control", "min": "1"}),
"first_reminder_hours_after": forms.NumberInput(attrs={"class": "form-control", "min": "0"}),
"second_reminder_hours_after": forms.NumberInput(attrs={"class": "form-control", "min": "0"}),
"reminder_hours_before": forms.NumberInput(attrs={"class": "form-control", "min": "0"}), "reminder_hours_before": forms.NumberInput(attrs={"class": "form-control", "min": "0"}),
"second_reminder_enabled": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"second_reminder_hours_before": forms.NumberInput(attrs={"class": "form-control", "min": "0"}),
"is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}), "is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}),
} }
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
hospital = cleaned_data.get("hospital") hospital = cleaned_data.get("hospital")
source = cleaned_data.get("source")
severity = cleaned_data.get("severity") severity = cleaned_data.get("severity")
priority = cleaned_data.get("priority") priority = cleaned_data.get("priority")
sla_hours = cleaned_data.get("sla_hours") sla_hours = cleaned_data.get("sla_hours")
reminder_hours = cleaned_data.get("reminder_hours_before") first_reminder = cleaned_data.get("first_reminder_hours_after")
second_reminder = cleaned_data.get("second_reminder_hours_after")
reminder_hours_before = cleaned_data.get("reminder_hours_before")
# Validate SLA hours is positive # Validate SLA hours is positive
if sla_hours and sla_hours <= 0: if sla_hours and sla_hours <= 0:
raise ValidationError({"sla_hours": "SLA hours must be greater than 0"}) raise ValidationError({"sla_hours": "SLA hours must be greater than 0"})
# Validate reminder hours < SLA hours # Validate first reminder hours
if sla_hours and reminder_hours and reminder_hours >= sla_hours: if first_reminder and first_reminder > 0:
if first_reminder >= sla_hours:
raise ValidationError({"first_reminder_hours_after": "First reminder must be less than SLA hours"})
# Validate second reminder hours
if second_reminder and second_reminder > 0:
if second_reminder >= sla_hours:
raise ValidationError({"second_reminder_hours_after": "Second reminder must be less than SLA hours"})
if first_reminder and second_reminder <= first_reminder:
raise ValidationError({"second_reminder_hours_after": "Second reminder must be after first reminder"})
# Validate legacy reminder hours < SLA hours
if sla_hours and reminder_hours_before and reminder_hours_before >= sla_hours:
raise ValidationError({"reminder_hours_before": "Reminder hours must be less than SLA hours"}) raise ValidationError({"reminder_hours_before": "Reminder hours must be less than SLA hours"})
# Check for unique combination (excluding current instance when editing) # Check for unique combination (excluding current instance when editing)
if hospital and severity and priority: filters = {}
queryset = ComplaintSLAConfig.objects.filter(hospital=hospital, severity=severity, priority=priority) if hospital:
filters["hospital"] = hospital
if source:
filters["source"] = source
if severity:
filters["severity"] = severity
if priority:
filters["priority"] = priority
if filters:
queryset = ComplaintSLAConfig.objects.filter(**filters)
if self.instance.pk: if self.instance.pk:
queryset = queryset.exclude(pk=self.instance.pk) queryset = queryset.exclude(pk=self.instance.pk)
if queryset.exists(): if queryset.exists():
raise ValidationError("An SLA configuration for this hospital, severity, and priority already exists.") raise ValidationError("An SLA configuration with these settings already exists.")
return cleaned_data return cleaned_data
@ -807,13 +857,27 @@ class EscalationRuleForm(HospitalFieldMixin, forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Filter users for escalate_to_user field # Filter users for escalate_to_user field based on hospital
from apps.accounts.models import User from apps.accounts.models import User
if self.user and self.user.is_px_admin(): # Get hospital context
self.fields["escalate_to_user"].queryset = User.objects.filter(is_active=True) hospital = None
if self.data.get("hospital"):
try:
hospital = Hospital.objects.get(id=self.data["hospital"])
except Hospital.DoesNotExist:
pass
elif self.initial.get("hospital"):
hospital = self.initial.get("hospital")
elif self.instance and self.instance.pk and self.instance.hospital:
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: elif self.user and self.user.hospital:
self.fields["escalate_to_user"].queryset = User.objects.filter(is_active=True, hospital=self.user.hospital) hospital = self.user.hospital
if hospital:
self.fields["escalate_to_user"].queryset = User.objects.filter(is_active=True, hospital=hospital)
else: else:
self.fields["escalate_to_user"].queryset = User.objects.none() self.fields["escalate_to_user"].queryset = User.objects.none()

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

View File

@ -515,7 +515,7 @@ def complaint_create(request):
if request.method == "POST": if request.method == "POST":
# Handle form submission # Handle form submission
form = ComplaintForm(request.POST, user=request.user) form = ComplaintForm(request.POST, request=request)
if not form.is_valid(): if not form.is_valid():
# Debug: print form errors # Debug: print form errors
@ -622,7 +622,7 @@ def complaint_create(request):
if hospital_id: if hospital_id:
initial_data["hospital"] = hospital_id initial_data["hospital"] = hospital_id
form = ComplaintForm(user=request.user, initial=initial_data) form = ComplaintForm(request=request, initial=initial_data)
context = { context = {
"form": form, "form": form,
@ -1449,7 +1449,7 @@ def inquiry_create(request):
base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html" base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html"
if request.method == "POST": if request.method == "POST":
form = InquiryForm(request.POST, user=request.user) form = InquiryForm(request.POST, request=request)
if form.is_valid(): if form.is_valid():
try: try:
@ -1484,7 +1484,7 @@ def inquiry_create(request):
if hospital_id: if hospital_id:
initial_data["hospital"] = hospital_id initial_data["hospital"] = hospital_id
form = InquiryForm(user=request.user, initial=initial_data) form = InquiryForm(request=request, initial=initial_data)
context = { context = {
"form": form, "form": form,
@ -2249,7 +2249,7 @@ def sla_config_create(request):
return redirect("accounts:settings") return redirect("accounts:settings")
if request.method == "POST": if request.method == "POST":
form = SLAConfigForm(request.POST, user=user) form = SLAConfigForm(request.POST, request=request)
if form.is_valid(): if form.is_valid():
sla_config = form.save() sla_config = form.save()
@ -2273,7 +2273,7 @@ def sla_config_create(request):
else: else:
messages.error(request, "Please correct the errors below.") messages.error(request, "Please correct the errors below.")
else: else:
form = SLAConfigForm(user=user) form = SLAConfigForm(request=request)
context = { context = {
"form": form, "form": form,
@ -2307,7 +2307,7 @@ def sla_config_edit(request, pk):
return redirect("complaints:sla_config_list") return redirect("complaints:sla_config_list")
if request.method == "POST": if request.method == "POST":
form = SLAConfigForm(request.POST, user=user, instance=sla_config) form = SLAConfigForm(request.POST, request=request, instance=sla_config)
if form.is_valid(): if form.is_valid():
sla_config = form.save() sla_config = form.save()
@ -2331,7 +2331,7 @@ def sla_config_edit(request, pk):
else: else:
messages.error(request, "Please correct the errors below.") messages.error(request, "Please correct the errors below.")
else: else:
form = SLAConfigForm(user=user, instance=sla_config) form = SLAConfigForm(request=request, instance=sla_config)
context = { context = {
"form": form, "form": form,
@ -2452,7 +2452,7 @@ def escalation_rule_create(request):
return redirect("accounts:settings") return redirect("accounts:settings")
if request.method == "POST": if request.method == "POST":
form = EscalationRuleForm(request.POST, user=user) form = EscalationRuleForm(request.POST, request=request)
if form.is_valid(): if form.is_valid():
escalation_rule = form.save() escalation_rule = form.save()
@ -2475,7 +2475,7 @@ def escalation_rule_create(request):
else: else:
messages.error(request, "Please correct the errors below.") messages.error(request, "Please correct the errors below.")
else: else:
form = EscalationRuleForm(user=user) form = EscalationRuleForm(request=request)
context = { context = {
"form": form, "form": form,
@ -2509,7 +2509,7 @@ def escalation_rule_edit(request, pk):
return redirect("complaints:escalation_rule_list") return redirect("complaints:escalation_rule_list")
if request.method == "POST": if request.method == "POST":
form = EscalationRuleForm(request.POST, user=user, instance=escalation_rule) form = EscalationRuleForm(request.POST, request=request, instance=escalation_rule)
if form.is_valid(): if form.is_valid():
escalation_rule = form.save() escalation_rule = form.save()
@ -2532,7 +2532,7 @@ def escalation_rule_edit(request, pk):
else: else:
messages.error(request, "Please correct the errors below.") messages.error(request, "Please correct the errors below.")
else: else:
form = EscalationRuleForm(user=user, instance=escalation_rule) form = EscalationRuleForm(request=request, instance=escalation_rule)
context = { context = {
"form": form, "form": form,
@ -2652,7 +2652,7 @@ def complaint_threshold_create(request):
return redirect("accounts:settings") return redirect("accounts:settings")
if request.method == "POST": if request.method == "POST":
form = ComplaintThresholdForm(request.POST, user=user) form = ComplaintThresholdForm(request.POST, request=request)
if form.is_valid(): if form.is_valid():
threshold = form.save() threshold = form.save()
@ -2675,7 +2675,7 @@ def complaint_threshold_create(request):
else: else:
messages.error(request, "Please correct the errors below.") messages.error(request, "Please correct the errors below.")
else: else:
form = ComplaintThresholdForm(user=user) form = ComplaintThresholdForm(request=request)
context = { context = {
"form": form, "form": form,
@ -2709,7 +2709,7 @@ def complaint_threshold_edit(request, pk):
return redirect("complaints:complaint_threshold_list") return redirect("complaints:complaint_threshold_list")
if request.method == "POST": if request.method == "POST":
form = ComplaintThresholdForm(request.POST, user=user, instance=threshold) form = ComplaintThresholdForm(request.POST, request=request, instance=threshold)
if form.is_valid(): if form.is_valid():
threshold = form.save() threshold = form.save()
@ -2732,7 +2732,7 @@ def complaint_threshold_edit(request, pk):
else: else:
messages.error(request, "Please correct the errors below.") messages.error(request, "Please correct the errors below.")
else: else:
form = ComplaintThresholdForm(user=user, instance=threshold) form = ComplaintThresholdForm(request=request, instance=threshold)
context = { context = {
"form": form, "form": form,

View File

@ -40,7 +40,7 @@ class AIService:
# Default configuration # 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_MODEL = "openrouter/xiaomi/mimo-v2-flash:free"
DEFAULT_TEMPERATURE = 0.3 DEFAULT_TEMPERATURE = 0.3
DEFAULT_MAX_TOKENS = 500 DEFAULT_MAX_TOKENS = 500
@ -521,7 +521,7 @@ class AIService:
# Build kwargs # Build kwargs
kwargs = { kwargs = {
"model": "openrouter/xiaomi/mimo-v2-flash", "model": "openrouter/nvidia/nemotron-3-super-120b-a12b:free",
"messages": messages "messages": messages
} }

View File

@ -3,135 +3,131 @@ Form mixins for tenant-aware forms.
Provides mixins to handle hospital field visibility based on user role. Provides mixins to handle hospital field visibility based on user role.
""" """
from django import forms from django import forms
from apps.organizations.models import Hospital from apps.organizations.models import Hospital
class HospitalFieldMixin: class HospitalFieldMixin:
""" """
Mixin to handle hospital field visibility based on user role. Mixin to handle hospital field - always hidden, auto-set based on user context.
- PX Admins: See dropdown with all active hospitals - PX Admins: Hidden field, auto-set from session (request.tenant_hospital)
- Others: Hidden field, auto-set to user's hospital - Others: Hidden field, auto-set from user's hospital (User.hospital)
Usage: Usage:
class MyForm(HospitalFieldMixin, forms.ModelForm): class MyForm(HospitalFieldMixin, forms.ModelForm):
class Meta: class Meta:
model = MyModel model = MyModel
fields = ['hospital', ...] fields = ['hospital', ...]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Hospital field is automatically configured # Hospital field is automatically configured
In views: In views:
form = MyForm(user=request.user) form = MyForm(request=request)
""" """
def __init__(self, *args, **kwargs): 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) super().__init__(*args, **kwargs)
if self.user and 'hospital' in self.fields: if self.user and "hospital" in self.fields:
self._setup_hospital_field() self._setup_hospital_field()
def _setup_hospital_field(self): def _setup_hospital_field(self):
"""Configure hospital field based on user role.""" """Configure hospital field - always hidden, auto-set based on user context."""
hospital_field = self.fields['hospital'] hospital_field = self.fields["hospital"]
hospital_field.widget = forms.HiddenInput()
hospital_field.required = False
hospital = None
if self.user.is_px_admin(): if self.user.is_px_admin():
# PX Admin: Show dropdown with all active hospitals hospital = getattr(self.request, "tenant_hospital", None)
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'
})
else: else:
# Regular user: Hide field and set default hospital = self.user.hospital
hospital_field.widget = forms.HiddenInput()
hospital_field.required = False if hospital:
hospital_field.initial = hospital
# Set initial value to user's hospital hospital_field.queryset = Hospital.objects.filter(id=hospital.id)
if self.user.hospital: else:
hospital_field.initial = self.user.hospital hospital_field.queryset = Hospital.objects.none()
# 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()
def clean_hospital(self): def clean_hospital(self):
""" """
Ensure non-PX admins can only use their own hospital. Auto-set hospital based on user context.
PX Admins can select any hospital.
""" """
hospital = self.cleaned_data.get('hospital') hospital = self.cleaned_data.get("hospital")
if not self.user: if not self.user:
return hospital return hospital
if self.user.is_px_admin(): if self.user.is_px_admin():
# PX Admin must select a hospital hospital = getattr(self.request, "tenant_hospital", None)
if not hospital: if not hospital:
raise forms.ValidationError("Please select a hospital.") raise forms.ValidationError("No hospital selected. Please select a hospital first.")
return hospital return hospital
else: else:
# Non-PX admins: Force user's hospital
if self.user.hospital: if self.user.hospital:
return self.user.hospital return self.user.hospital
else: else:
raise forms.ValidationError( raise forms.ValidationError("You do not have a hospital assigned. Please contact your administrator.")
"You do not have a hospital assigned. Please contact your administrator."
)
class DepartmentFieldMixin: class DepartmentFieldMixin:
""" """
Mixin to handle department field filtering based on user's hospital. Mixin to handle department field filtering based on user's hospital.
- Filters departments to only show those in the selected/current hospital - Filters departments to only show those in the selected/current hospital
- Works with HospitalFieldMixin - Works with HospitalFieldMixin
Usage: Usage:
class MyForm(HospitalFieldMixin, DepartmentFieldMixin, forms.ModelForm): class MyForm(HospitalFieldMixin, DepartmentFieldMixin, forms.ModelForm):
class Meta: class Meta:
model = MyModel model = MyModel
fields = ['hospital', 'department', ...] fields = ['hospital', 'department', ...]
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if 'department' in self.fields: if "department" in self.fields:
self._setup_department_field() self._setup_department_field()
def _setup_department_field(self): def _setup_department_field(self):
"""Configure department field with hospital-based filtering.""" """Configure department field with hospital-based filtering."""
from apps.organizations.models import Department 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) # Get the hospital (either from form data or user's hospital)
hospital = None hospital = None
if self.data.get('hospital'): if self.data.get("hospital"):
try: try:
hospital = Hospital.objects.get(id=self.data['hospital']) hospital = Hospital.objects.get(id=self.data["hospital"])
except Hospital.DoesNotExist: except Hospital.DoesNotExist:
pass pass
elif self.initial.get('hospital'): elif self.initial.get("hospital"):
hospital = self.initial['hospital'] hospital = self.initial["hospital"]
elif self.instance and self.instance.pk and self.instance.hospital: elif self.instance and self.instance.pk:
hospital = self.instance.hospital # 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: elif self.user and self.user.hospital:
hospital = self.user.hospital hospital = self.user.hospital
if hospital: if hospital:
# Filter departments to user's hospital # Filter departments to user's hospital
department_field.queryset = Department.objects.filter( department_field.queryset = Department.objects.filter(hospital=hospital, status="active").order_by("name")
hospital=hospital,
status='active'
).order_by('name')
else: else:
# No hospital context - empty queryset # No hospital context - empty queryset
department_field.queryset = Department.objects.none() department_field.queryset = Department.objects.none()

View File

@ -2072,7 +2072,7 @@ class Command(BaseCommand):
import os import os
api_url = os.getenv("HIS_API_URL", "https://his.alhammadi.med.sa:54380/SSRCE/API/FetchPatientVisitTimeStamps") api_url = os.getenv("HIS_API_URL", "https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps")
if self.dry_run: if self.dry_run:
self.stdout.write(f" ✓ Would create: HIS Integration") self.stdout.write(f" ✓ Would create: HIS Integration")

View File

@ -1,6 +1,9 @@
""" """
Tenant-aware middleware for multi-tenancy Tenant-aware middleware for multi-tenancy
""" """
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.deprecation import MiddlewareMixin from django.utils.deprecation import MiddlewareMixin
@ -11,17 +14,28 @@ class TenantMiddleware(MiddlewareMixin):
This middleware ensures that: This middleware ensures that:
- authenticated users have their tenant_hospital set from their profile - authenticated users have their tenant_hospital set from their profile
- PX admins can switch between hospitals via session - 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 - Source Users have their source context available
- All requests have tenant 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): def process_request(self, request):
"""Set tenant hospital context on each request.""" """Set tenant hospital context on each request."""
if request.user and request.user.is_authenticated: if request.user and request.user.is_authenticated:
# Store user's role for quick access
request.user_roles = request.user.get_role_names() request.user_roles = request.user.get_role_names()
# Set source user context
request.source_user = None request.source_user = None
request.source_user_profile = None request.source_user_profile = None
if request.user.is_source_user(): if request.user.is_source_user():
@ -30,24 +44,19 @@ class TenantMiddleware(MiddlewareMixin):
request.source_user = profile request.source_user = profile
request.source_user_profile = profile request.source_user_profile = profile
# PX Admins can switch hospitals via session
if request.user.is_px_admin(): 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: if hospital_id:
from apps.organizations.models import Hospital from apps.organizations.models import Hospital
try: try:
# Validate that the hospital exists
request.tenant_hospital = Hospital.objects.get(id=hospital_id) request.tenant_hospital = Hospital.objects.get(id=hospital_id)
except Hospital.DoesNotExist: except Hospital.DoesNotExist:
# Invalid hospital ID, fall back to default
request.tenant_hospital = None request.tenant_hospital = None
# Clear invalid session data request.session.pop("selected_hospital_id", None)
request.session.pop('selected_hospital_id', None)
else: else:
# No hospital selected yet
request.tenant_hospital = None request.tenant_hospital = None
else: else:
# Non-PX Admin users use their assigned hospital
request.tenant_hospital = request.user.hospital request.tenant_hospital = request.user.hospital
else: else:
request.tenant_hospital = None request.tenant_hospital = None
@ -56,3 +65,24 @@ class TenantMiddleware(MiddlewareMixin):
request.source_user_profile = None request.source_user_profile = None
return 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

@ -67,26 +67,21 @@ class FeedbackForm(HospitalFieldMixin, forms.ModelForm):
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
user = kwargs.pop("user", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Filter hospitals based on user permissions # Hospital field is configured by HospitalFieldMixin (always hidden)
if user: # Filter departments and staff based on hospital
if not user.is_px_admin() and user.hospital: hospital = None
self.fields["hospital"].queryset = Hospital.objects.filter(id=user.hospital.id, status="active")
else:
self.fields["hospital"].queryset = Hospital.objects.filter(status="active")
# 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: 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
hospital=self.instance.hospital, status="active" elif self.user and self.user.is_px_admin():
) hospital = getattr(self.request, "tenant_hospital", None)
self.fields["staff"].queryset = Staff.objects.filter(hospital=self.instance.hospital, status="active") elif self.user and self.user.hospital:
hospital = self.user.hospital
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: else:
self.fields["department"].queryset = Department.objects.none() self.fields["department"].queryset = Department.objects.none()
self.fields["staff"].queryset = Staff.objects.none() self.fields["staff"].queryset = Staff.objects.none()

View File

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

View File

@ -68,7 +68,7 @@ class HISClient:
"""Get API URL from configuration or environment.""" """Get API URL from configuration or environment."""
if not self.config: if not self.config:
# Fallback to environment variable # 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 return self.config.api_url
def _get_auth(self) -> Optional[tuple]: def _get_auth(self) -> Optional[tuple]:

View File

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

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

View File

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

View File

@ -93,6 +93,59 @@ def physician_list(request):
for physician in page_obj.object_list: for physician in page_obj.object_list:
physician.current_rating = ratings_dict.get(physician.id) physician.current_rating = ratings_dict.get(physician.id)
departments = Department.objects.filter(status="active")
if not user.is_px_admin() and user.hospital:
departments = departments.filter(hospital=user.hospital)
context = {
"page_obj": page_obj,
"physicians": page_obj.object_list,
"departments": departments,
"filters": request.GET,
}
return render(request, "physicians/physician_list.html", context)
@login_required
def leaderboard(request):
"""
Physician leaderboard - Top rated physicians.
Features:
- Ranked list of physicians by rating
- Filters (hospital, department, year, month)
- Statistics summary
"""
now = timezone.now()
year = int(request.GET.get("year", now.year))
month = int(request.GET.get("month", now.month))
# Base queryset for ratings
queryset = PhysicianMonthlyRating.objects.filter(
year=year, month=month, staff__physician=True
).select_related("staff", "staff__hospital", "staff__department")
# Apply RBAC filters
user = request.user
if not user.is_px_admin() and user.hospital:
queryset = queryset.filter(staff__hospital=user.hospital)
# Apply filters
hospital_filter = request.GET.get("hospital")
if hospital_filter:
queryset = queryset.filter(staff__hospital_id=hospital_filter)
department_filter = request.GET.get("department")
if department_filter:
queryset = queryset.filter(staff__department_id=department_filter)
# Order by rating (highest first)
queryset = queryset.order_by("-average_rating", "-total_surveys")
# Get top 50 for leaderboard
leaderboard = queryset[:50]
departments = Department.objects.filter(status="active") departments = Department.objects.filter(status="active")
if not user.is_px_admin() and user.hospital: if not user.is_px_admin() and user.hospital:
departments = departments.filter(hospital=user.hospital) departments = departments.filter(hospital=user.hospital)
@ -103,7 +156,7 @@ def physician_list(request):
all_ratings = all_ratings.filter(staff__hospital=user.hospital) all_ratings = all_ratings.filter(staff__hospital=user.hospital)
stats = all_ratings.aggregate( stats = all_ratings.aggregate(
total_physicians=Count("id"), average_rating=Avg("average_rating"), total_surveys=Count("total_surveys") total_physicians=Count("id"), average_rating=Avg("average_rating"), total_surveys=Sum("total_surveys")
) )
# Distribution # Distribution
@ -149,6 +202,15 @@ def physician_ratings_dashboard(request):
years = list(range(2024, current_year + 1)) years = list(range(2024, current_year + 1))
years.reverse() # Most recent first years.reverse() # Most recent first
# Get departments for filter dropdown
from apps.organizations.models import Department
if request.user.is_px_admin():
departments = Department.objects.filter(status="active").order_by("name")
elif request.user.hospital:
departments = Department.objects.filter(hospital=request.user.hospital, status="active").order_by("name")
else:
departments = Department.objects.none()
context = { context = {
"years": years, "years": years,
"departments": departments, "departments": departments,
@ -470,6 +532,78 @@ def specialization_overview(request):
return render(request, "physicians/specialization_overview.html", context) return render(request, "physicians/specialization_overview.html", context)
@login_required
def physician_detail(request, pk):
"""
Physician detail view - shows detailed information and ratings for a specific physician.
Features:
- Physician profile information
- Monthly ratings history
- Rating trends over time
- Statistics and achievements
"""
from django.shortcuts import get_object_or_404
# Get the physician (staff member marked as physician)
physician = get_object_or_404(
Staff.objects.filter(Q(physician=True) | Q(staff_type=Staff.StaffType.PHYSICIAN))
.select_related('hospital', 'department'),
pk=pk
)
# Check permissions
user = request.user
if not user.is_px_admin() and user.hospital and physician.hospital != user.hospital:
from django.core.exceptions import PermissionDenied
raise PermissionDenied("You don't have permission to view this physician.")
# Get all monthly ratings for this physician
ratings = PhysicianMonthlyRating.objects.filter(
staff=physician
).order_by('-year', '-month')
# Get current month rating
now = timezone.now()
current_rating = ratings.filter(year=now.year, month=now.month).first()
# Calculate statistics
stats = ratings.aggregate(
total_months=Count('id'),
average_rating=Avg('average_rating'),
total_surveys=Sum('total_surveys'),
total_positive=Sum('positive_count'),
total_neutral=Sum('neutral_count'),
total_negative=Sum('negative_count'),
)
# Get trend data (last 12 months)
trend_data = []
for i in range(11, -1, -1):
m = now.month - i
y = now.year
if m <= 0:
m += 12
y -= 1
rating = ratings.filter(year=y, month=m).first()
trend_data.append({
'period': f"{y}-{m:02d}",
'rating': float(rating.average_rating) if rating else None,
'surveys': rating.total_surveys if rating else 0,
})
context = {
'physician': physician,
'current_rating': current_rating,
'ratings': ratings[:12], # Last 12 months
'stats': stats,
'trend_data': trend_data,
}
return render(request, 'physicians/physician_detail.html', context)
@login_required @login_required
def department_overview(request): def department_overview(request):
""" """

View File

@ -3,7 +3,9 @@ QI Projects Forms
Forms for creating and managing Quality Improvement projects and tasks. Forms for creating and managing Quality Improvement projects and tasks.
""" """
from django import forms from django import forms
from django.forms import inlineformset_factory
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.core.form_mixins import HospitalFieldMixin from apps.core.form_mixins import HospitalFieldMixin
@ -16,220 +18,276 @@ from .models import QIProject, QIProjectTask
class QIProjectForm(HospitalFieldMixin, forms.ModelForm): class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
""" """
Form for creating and editing QI Projects. Form for creating and editing QI Projects.
Hospital field visibility: Hospital field visibility:
- PX Admins: See dropdown with all hospitals - PX Admins: See dropdown with all hospitals
- Others: Hidden field, auto-set to user's hospital - Others: Hidden field, auto-set to user's hospital
""" """
class Meta: class Meta:
model = QIProject model = QIProject
fields = [ fields = [
'name', 'name_ar', 'description', 'hospital', 'department', "name",
'project_lead', 'team_members', 'status', "name_ar",
'start_date', 'target_completion_date', 'outcome_description' "description",
"hospital",
"department",
"project_lead",
"team_members",
"status",
"start_date",
"target_completion_date",
"outcome_description",
] ]
widgets = { widgets = {
'name': forms.TextInput(attrs={ "name": forms.TextInput(
'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', attrs={
'placeholder': _('Project name') "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': _('اسم المشروع'), "name_ar": forms.TextInput(
'dir': 'rtl' 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",
'description': forms.Textarea(attrs={ "placeholder": _("اسم المشروع"),
'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', "dir": "rtl",
'rows': 4, }
'placeholder': _('Describe the project objectives and scope...') ),
}), "description": forms.Textarea(
'hospital': forms.Select(attrs={ 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' "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,
'department': forms.Select(attrs={ "placeholder": _("Describe the project objectives and scope..."),
'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={ "hospital": forms.Select(
'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' 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' ),
}), "department": forms.Select(
'status': forms.Select(attrs={ 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' "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', "project_lead": forms.Select(
'type': 'date' 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' "team_members": forms.SelectMultiple(
}), attrs={
'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 bg-white h-40"
'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...') "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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Filter department choices based on hospital # Filter department choices based on hospital
hospital_id = None hospital_id = None
if self.data.get('hospital'): if self.data.get("hospital"):
hospital_id = self.data.get('hospital') hospital_id = self.data.get("hospital")
elif self.initial.get('hospital'): elif self.initial.get("hospital"):
hospital_id = self.initial.get('hospital') hospital_id = self.initial.get("hospital")
elif self.instance and self.instance.pk and self.instance.hospital: elif self.instance and self.instance.pk and self.instance.hospital:
hospital_id = self.instance.hospital.id 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: elif self.user and self.user.hospital:
hospital_id = self.user.hospital.id hospital_id = self.user.hospital.id
if hospital_id: if hospital_id:
self.fields['department'].queryset = Department.objects.filter( self.fields["department"].queryset = Department.objects.filter(
hospital_id=hospital_id, hospital_id=hospital_id, status="active"
status='active' ).order_by("name")
).order_by('name')
# Filter user choices based on hospital # Filter user choices based on hospital
self.fields['project_lead'].queryset = User.objects.filter( self.fields["project_lead"].queryset = User.objects.filter(
hospital_id=hospital_id, hospital_id=hospital_id, is_active=True
is_active=True ).order_by("first_name", "last_name")
).order_by('first_name', 'last_name')
self.fields["team_members"].queryset = User.objects.filter(
self.fields['team_members'].queryset = User.objects.filter( hospital_id=hospital_id, is_active=True
hospital_id=hospital_id, ).order_by("first_name", "last_name")
is_active=True
).order_by('first_name', 'last_name')
else: else:
self.fields['department'].queryset = Department.objects.none() self.fields["department"].queryset = Department.objects.none()
self.fields['project_lead'].queryset = User.objects.none() self.fields["project_lead"].queryset = User.objects.none()
self.fields['team_members'].queryset = User.objects.none() self.fields["team_members"].queryset = User.objects.none()
class QIProjectTaskForm(forms.ModelForm): class QIProjectTaskForm(forms.ModelForm):
""" """
Form for creating and editing QI Project tasks. Form for creating and editing QI Project tasks.
""" """
class Meta: class Meta:
model = QIProjectTask model = QIProjectTask
fields = ['title', 'description', 'assigned_to', 'status', 'due_date', 'order'] fields = ["title", "description", "assigned_to", "status", "due_date", "order"]
widgets = { widgets = {
'title': forms.TextInput(attrs={ "title": forms.TextInput(
'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', attrs={
'placeholder': _('Task title') "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, "description": forms.Textarea(
'placeholder': _('Task description...') 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",
'assigned_to': forms.Select(attrs={ "rows": 3,
'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' "placeholder": _("Task description..."),
}), }
'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' "assigned_to": forms.Select(
}), attrs={
'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 bg-white"
'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' ),
}), "status": forms.Select(
'order': forms.NumberInput(attrs={ 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', "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"
'min': 0 }
}), ),
"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): def __init__(self, *args, **kwargs):
self.project = kwargs.pop('project', None) self.project = kwargs.pop("project", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Make order field not required (has default value of 0) # 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 # Filter assigned_to choices based on project hospital
if self.project and self.project.hospital: if self.project and self.project.hospital:
self.fields['assigned_to'].queryset = User.objects.filter( self.fields["assigned_to"].queryset = User.objects.filter(
hospital=self.project.hospital, hospital=self.project.hospital, is_active=True
is_active=True ).order_by("first_name", "last_name")
).order_by('first_name', 'last_name')
else: else:
self.fields['assigned_to'].queryset = User.objects.none() self.fields["assigned_to"].queryset = User.objects.none()
class QIProjectTemplateForm(HospitalFieldMixin, forms.ModelForm): class QIProjectTemplateForm(HospitalFieldMixin, forms.ModelForm):
""" """
Form for creating and editing QI Project templates. Form for creating and editing QI Project templates.
Hospital field visibility: Hospital field visibility:
- PX Admins: See dropdown with all hospitals - PX Admins: See dropdown with all hospitals
- Others: Hidden field, auto-set to user's hospital - Others: Hidden field, auto-set to user's hospital
Templates can be: Templates can be:
- Global (hospital=None) - available to all - Global (hospital=None) - available to all
- Hospital-specific - available only to that hospital - Hospital-specific - available only to that hospital
""" """
class Meta: class Meta:
model = QIProject model = QIProject
fields = [ fields = ["name", "name_ar", "description", "hospital", "department", "target_completion_date"]
'name', 'name_ar', 'description', 'hospital',
'department', 'target_completion_date'
]
widgets = { widgets = {
'name': forms.TextInput(attrs={ "name": forms.TextInput(
'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', attrs={
'placeholder': _('Template name') "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': _('اسم القالب'), "name_ar": forms.TextInput(
'dir': 'rtl' 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",
'description': forms.Textarea(attrs={ "placeholder": _("اسم القالب"),
'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', "dir": "rtl",
'rows': 4, }
'placeholder': _('Describe the project template...') ),
}), "description": forms.Textarea(
'hospital': forms.Select(attrs={ 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' "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,
'department': forms.Select(attrs={ "placeholder": _("Describe the project template..."),
'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={ "hospital": forms.Select(
'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', attrs={
'type': 'date' "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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Make hospital optional for templates (global templates) # Make hospital optional for templates (global templates)
self.fields['hospital'].required = False self.fields["hospital"].required = False
self.fields['hospital'].empty_label = _('Global (All Hospitals)') self.fields["hospital"].empty_label = _("Global (All Hospitals)")
# Filter department choices based on hospital # Filter department choices based on hospital
hospital_id = None hospital_id = None
if self.data.get('hospital'): if self.data.get("hospital"):
hospital_id = self.data.get('hospital') hospital_id = self.data.get("hospital")
elif self.initial.get('hospital'): elif self.initial.get("hospital"):
hospital_id = self.initial.get('hospital') hospital_id = self.initial.get("hospital")
elif self.instance and self.instance.pk and self.instance.hospital: elif self.instance and self.instance.pk and self.instance.hospital:
hospital_id = self.instance.hospital.id 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: if hospital_id:
self.fields['department'].queryset = Department.objects.filter( self.fields["department"].queryset = Department.objects.filter(
hospital_id=hospital_id, hospital_id=hospital_id, status="active"
status='active' ).order_by("name")
).order_by('name')
else: else:
self.fields['department'].queryset = Department.objects.none() self.fields["department"].queryset = Department.objects.none()
class ConvertToProjectForm(forms.Form): class ConvertToProjectForm(forms.Form):
@ -237,62 +295,104 @@ class ConvertToProjectForm(forms.Form):
Form for converting a PX Action to a QI Project. Form for converting a PX Action to a QI Project.
Allows selecting a template and customizing the project details. Allows selecting a template and customizing the project details.
""" """
template = forms.ModelChoiceField( template = forms.ModelChoiceField(
queryset=QIProject.objects.none(), queryset=QIProject.objects.none(),
required=False, required=False,
empty_label=_('Blank Project'), empty_label=_("Blank Project"),
label=_('Project Template'), label=_("Project Template"),
widget=forms.Select(attrs={ widget=forms.Select(
'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' 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( project_name = forms.CharField(
max_length=200, max_length=200,
label=_('Project Name'), label=_("Project Name"),
widget=forms.TextInput(attrs={ widget=forms.TextInput(
'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', attrs={
'placeholder': _('Enter project name') "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( project_lead = forms.ModelChoiceField(
queryset=User.objects.none(), queryset=User.objects.none(),
required=True, required=True,
label=_('Project Lead'), label=_("Project Lead"),
widget=forms.Select(attrs={ widget=forms.Select(
'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' 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( target_completion_date = forms.DateField(
required=False, required=False,
label=_('Target Completion Date'), label=_("Target Completion Date"),
widget=forms.DateInput(attrs={ widget=forms.DateInput(
'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', attrs={
'type': 'date' "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): def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None) self.request = kwargs.pop("request", None)
self.action = kwargs.pop('action', None) self.user = self.request.user if self.request else None
self.action = kwargs.pop("action", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.user and self.user.hospital: if self.user and self.user.hospital:
# Filter templates by hospital (or global) # Filter templates by hospital (or global)
from django.db.models import Q 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), Q(hospital=self.user.hospital) | Q(hospital__isnull=True),
status='template' # We'll add this status or use metadata status="template", # We'll add this status or use metadata
).order_by('name') ).order_by("name")
# Filter project lead by hospital # Filter project lead by hospital
self.fields['project_lead'].queryset = User.objects.filter( self.fields["project_lead"].queryset = User.objects.filter(
hospital=self.user.hospital, hospital=self.user.hospital, is_active=True
is_active=True ).order_by("first_name", "last_name")
).order_by('first_name', 'last_name')
else: else:
self.fields['template'].queryset = QIProject.objects.none() self.fields["template"].queryset = QIProject.objects.none()
self.fields['project_lead'].queryset = User.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, Provides full CRUD functionality for Quality Improvement projects,
task management, and template handling. task management, and template handling.
""" """
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
@ -15,7 +16,7 @@ from apps.core.decorators import block_source_user
from apps.organizations.models import Hospital from apps.organizations.models import Hospital
from apps.px_action_center.models import PXAction 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 from .models import QIProject, QIProjectTask
@ -24,70 +25,72 @@ from .models import QIProject, QIProjectTask
def project_list(request): def project_list(request):
"""QI Projects list view with filtering and pagination""" """QI Projects list view with filtering and pagination"""
# Exclude templates from the list # Exclude templates from the list
queryset = QIProject.objects.filter(is_template=False).select_related( queryset = (
'hospital', 'department', 'project_lead' QIProject.objects.filter(is_template=False)
).prefetch_related('team_members', 'related_actions') .select_related("hospital", "department", "project_lead")
.prefetch_related("team_members", "related_actions")
)
# Apply RBAC filters # Apply RBAC filters
user = request.user user = request.user
# Get selected hospital for PX Admins (from middleware) # 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(): if user.is_px_admin():
# PX Admins see all, but filter by selected hospital if set # PX Admins see all, but filter by selected hospital if set
if selected_hospital: if selected_hospital:
queryset = queryset.filter(hospital=selected_hospital) queryset = queryset.filter(hospital=selected_hospital)
elif user.hospital: elif user.hospital:
queryset = queryset.filter(hospital=user.hospital) queryset = queryset.filter(hospital=user.hospital)
# Apply filters # Apply filters
status_filter = request.GET.get('status') status_filter = request.GET.get("status")
if status_filter: if status_filter:
queryset = queryset.filter(status=status_filter) queryset = queryset.filter(status=status_filter)
hospital_filter = request.GET.get('hospital') hospital_filter = request.GET.get("hospital")
if hospital_filter: if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter) queryset = queryset.filter(hospital_id=hospital_filter)
# Search # Search
search_query = request.GET.get('search') search_query = request.GET.get("search")
if search_query: if search_query:
queryset = queryset.filter( queryset = queryset.filter(
Q(name__icontains=search_query) | Q(name__icontains=search_query)
Q(description__icontains=search_query) | | Q(description__icontains=search_query)
Q(name_ar__icontains=search_query) | Q(name_ar__icontains=search_query)
) )
# Ordering # Ordering
queryset = queryset.order_by('-created_at') queryset = queryset.order_by("-created_at")
# Pagination # Pagination
page_size = int(request.GET.get('page_size', 25)) page_size = int(request.GET.get("page_size", 25))
paginator = Paginator(queryset, page_size) 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) page_obj = paginator.get_page(page_number)
# Get hospitals for filter # 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: if not user.is_px_admin() and user.hospital:
hospitals = hospitals.filter(id=user.hospital.id) hospitals = hospitals.filter(id=user.hospital.id)
# Statistics # Statistics
stats = { stats = {
'total': queryset.count(), "total": queryset.count(),
'active': queryset.filter(status='active').count(), "active": queryset.filter(status="active").count(),
'completed': queryset.filter(status='completed').count(), "completed": queryset.filter(status="completed").count(),
'pending': queryset.filter(status='pending').count(), "pending": queryset.filter(status="pending").count(),
} }
context = { context = {
'page_obj': page_obj, "page_obj": page_obj,
'projects': page_obj.object_list, "projects": page_obj.object_list,
'stats': stats, "stats": stats,
'filters': request.GET, "filters": request.GET,
} }
return render(request, 'projects/project_list.html', context) return render(request, "projects/project_list.html", context)
@block_source_user @block_source_user
@ -95,34 +98,32 @@ def project_list(request):
def project_detail(request, pk): def project_detail(request, pk):
"""QI Project detail view with task management""" """QI Project detail view with task management"""
project = get_object_or_404( project = get_object_or_404(
QIProject.objects.filter(is_template=False).select_related( QIProject.objects.filter(is_template=False)
'hospital', 'department', 'project_lead' .select_related("hospital", "department", "project_lead")
).prefetch_related( .prefetch_related("team_members", "related_actions", "tasks"),
'team_members', 'related_actions', 'tasks' pk=pk,
),
pk=pk
) )
# Check permission # Check permission
user = request.user user = request.user
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital: 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.")) messages.error(request, _("You don't have permission to view this project."))
return redirect('projects:project_list') return redirect("projects:project_list")
# Get tasks # Get tasks
tasks = project.tasks.all().order_by('order', 'created_at') tasks = project.tasks.all().order_by("order", "created_at")
# Get related actions # Get related actions
related_actions = project.related_actions.all() related_actions = project.related_actions.all()
context = { context = {
'project': project, "project": project,
'tasks': tasks, "tasks": tasks,
'related_actions': related_actions, "related_actions": related_actions,
'can_edit': user.is_px_admin() or user.is_hospital_admin or user.is_department_manager, "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 @block_source_user
@ -130,63 +131,63 @@ def project_detail(request, pk):
def project_create(request, template_pk=None): def project_create(request, template_pk=None):
"""Create a new QI Project""" """Create a new QI Project"""
user = request.user user = request.user
# Check permission (PX Admin, Hospital Admin, or Department Manager) # 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): 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.")) 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) # 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 = {} initial_data = {}
template = None template = None
if template_id: if template_id:
try: try:
template = QIProject.objects.get(pk=template_id, is_template=True) template = QIProject.objects.get(pk=template_id, is_template=True)
initial_data = { initial_data = {
'name': template.name, "name": template.name,
'name_ar': template.name_ar, "name_ar": template.name_ar,
'description': template.description, "description": template.description,
'department': template.department, "department": template.department,
'target_completion_date': template.target_completion_date, "target_completion_date": template.target_completion_date,
} }
if not user.is_px_admin() and user.hospital: if not user.is_px_admin() and user.hospital:
initial_data['hospital'] = user.hospital initial_data["hospital"] = user.hospital
except QIProject.DoesNotExist: except QIProject.DoesNotExist:
pass pass
# Check for PX Action parameter (convert action to project) # Check for PX Action parameter (convert action to project)
action_id = request.GET.get('action') action_id = request.GET.get("action")
if action_id: if action_id:
try: try:
action = PXAction.objects.get(pk=action_id) action = PXAction.objects.get(pk=action_id)
initial_data['name'] = f"QI Project: {action.title}" initial_data["name"] = f"QI Project: {action.title}"
initial_data['description'] = action.description initial_data["description"] = action.description
if not user.is_px_admin() and user.hospital: if not user.is_px_admin() and user.hospital:
initial_data['hospital'] = user.hospital initial_data["hospital"] = user.hospital
else: else:
initial_data['hospital'] = action.hospital initial_data["hospital"] = action.hospital
except PXAction.DoesNotExist: except PXAction.DoesNotExist:
pass pass
if request.method == 'POST': if request.method == "POST":
form = QIProjectForm(request.POST, user=user) form = QIProjectForm(request.POST, request=request)
# Check for template in POST data (hidden field) # 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: if template_id_post:
try: try:
template = QIProject.objects.get(pk=template_id_post, is_template=True) template = QIProject.objects.get(pk=template_id_post, is_template=True)
except QIProject.DoesNotExist: except QIProject.DoesNotExist:
template = None template = None
if form.is_valid(): if form.is_valid():
project = form.save(commit=False) project = form.save(commit=False)
project.created_by = user project.created_by = user
project.save() project.save()
form.save_m2m() # Save many-to-many relationships form.save_m2m() # Save many-to-many relationships
# If created from template, copy tasks # If created from template, copy tasks
task_count = 0 task_count = 0
if template: if template:
@ -196,10 +197,10 @@ def project_create(request, template_pk=None):
title=template_task.title, title=template_task.title,
description=template_task.description, description=template_task.description,
order=template_task.order, order=template_task.order,
status='pending' status="pending",
) )
task_count += 1 task_count += 1
# If created from action, link it # If created from action, link it
if action_id: if action_id:
try: try:
@ -207,25 +208,25 @@ def project_create(request, template_pk=None):
project.related_actions.add(action) project.related_actions.add(action)
except PXAction.DoesNotExist: except PXAction.DoesNotExist:
pass pass
if template and task_count > 0: if template and task_count > 0:
messages.success( messages.success(
request, request,
_('QI Project created successfully with %(count)d task(s) from template.') % {'count': task_count} _("QI Project created successfully with %(count)d task(s) from template.") % {"count": task_count},
) )
else: else:
messages.success(request, _("QI Project created successfully.")) messages.success(request, _("QI Project created successfully."))
return redirect('projects:project_detail', pk=project.pk) return redirect("projects:project_detail", pk=project.pk)
else: else:
form = QIProjectForm(user=user, initial=initial_data) form = QIProjectForm(request=request, initial=initial_data)
context = { context = {
'form': form, "form": form,
'is_create': True, "is_create": True,
'template': template, "template": template,
} }
return render(request, 'projects/project_form.html', context) return render(request, "projects/project_form.html", context)
@block_source_user @block_source_user
@ -234,33 +235,33 @@ def project_edit(request, pk):
"""Edit an existing QI Project""" """Edit an existing QI Project"""
user = request.user user = request.user
project = get_object_or_404(QIProject, pk=pk, is_template=False) project = get_object_or_404(QIProject, pk=pk, is_template=False)
# Check permission # Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital: 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.")) 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) # 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): 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.")) messages.error(request, _("You don't have permission to edit projects."))
return redirect('projects:project_detail', pk=project.pk) return redirect("projects:project_detail", pk=project.pk)
if request.method == 'POST': if request.method == "POST":
form = QIProjectForm(request.POST, instance=project, user=user) form = QIProjectForm(request.POST, instance=project, request=request)
if form.is_valid(): if form.is_valid():
form.save() form.save()
messages.success(request, _("QI Project updated successfully.")) messages.success(request, _("QI Project updated successfully."))
return redirect('projects:project_detail', pk=project.pk) return redirect("projects:project_detail", pk=project.pk)
else: else:
form = QIProjectForm(instance=project, user=user) form = QIProjectForm(instance=project, request=request)
context = { context = {
'form': form, "form": form,
'project': project, "project": project,
'is_create': False, "is_create": False,
} }
return render(request, 'projects/project_form.html', context) return render(request, "projects/project_form.html", context)
@block_source_user @block_source_user
@ -269,27 +270,27 @@ def project_delete(request, pk):
"""Delete a QI Project""" """Delete a QI Project"""
user = request.user user = request.user
project = get_object_or_404(QIProject, pk=pk, is_template=False) project = get_object_or_404(QIProject, pk=pk, is_template=False)
# Check permission (only PX Admin or Hospital Admin can delete) # Check permission (only PX Admin or Hospital Admin can delete)
if not (user.is_px_admin() or user.is_hospital_admin): if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to delete projects.")) 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: 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.")) messages.error(request, _("You don't have permission to delete this project."))
return redirect('projects:project_list') return redirect("projects:project_list")
if request.method == 'POST': if request.method == "POST":
project_name = project.name project_name = project.name
project.delete() project.delete()
messages.success(request, _('Project "%(name)s" deleted successfully.') % {'name': project_name}) messages.success(request, _('Project "%(name)s" deleted successfully.') % {"name": project_name})
return redirect('projects:project_list') return redirect("projects:project_list")
context = { 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 @block_source_user
@ -298,26 +299,26 @@ def project_save_as_template(request, pk):
"""Save an existing project and its tasks as a template""" """Save an existing project and its tasks as a template"""
user = request.user user = request.user
project = get_object_or_404(QIProject, pk=pk, is_template=False) project = get_object_or_404(QIProject, pk=pk, is_template=False)
# Check permission (only PX Admin or Hospital Admin can create templates) # Check permission (only PX Admin or Hospital Admin can create templates)
if not (user.is_px_admin() or user.is_hospital_admin): if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to create templates.")) 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 # Check hospital access
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital: 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.")) messages.error(request, _("You don't have permission to create templates from this project."))
return redirect('projects:project_list') return redirect("projects:project_list")
if request.method == 'POST': if request.method == "POST":
template_name = request.POST.get('template_name', '').strip() template_name = request.POST.get("template_name", "").strip()
template_description = request.POST.get('template_description', '').strip() template_description = request.POST.get("template_description", "").strip()
make_global = request.POST.get('make_global') == 'on' make_global = request.POST.get("make_global") == "on"
if not template_name: if not template_name:
messages.error(request, _('Please provide a template name.')) messages.error(request, _("Please provide a template name."))
return redirect('projects:project_save_as_template', pk=project.pk) return redirect("projects:project_save_as_template", pk=project.pk)
# Create template from project # Create template from project
template = QIProject.objects.create( template = QIProject.objects.create(
name=template_name, name=template_name,
@ -327,10 +328,10 @@ def project_save_as_template(request, pk):
# If global, hospital is None; otherwise use project's hospital # If global, hospital is None; otherwise use project's hospital
hospital=None if make_global else project.hospital, hospital=None if make_global else project.hospital,
department=project.department, department=project.department,
status='pending', # Default status for templates status="pending", # Default status for templates
created_by=user created_by=user,
) )
# Copy tasks from project to template # Copy tasks from project to template
for task in project.tasks.all(): for task in project.tasks.all():
QIProjectTask.objects.create( QIProjectTask.objects.create(
@ -338,60 +339,59 @@ def project_save_as_template(request, pk):
title=task.title, title=task.title,
description=task.description, description=task.description,
order=task.order, order=task.order,
status='pending' # Reset status for template status="pending", # Reset status for template
) )
messages.success( messages.success(
request, request,
_('Template "%(name)s" created successfully with %(count)d task(s).') % { _('Template "%(name)s" created successfully with %(count)d task(s).')
'name': template_name, % {"name": template_name, "count": project.tasks.count()},
'count': project.tasks.count()
}
) )
return redirect('projects:template_list') return redirect("projects:template_list")
context = { context = {
'project': project, "project": project,
'suggested_name': f"Template: {project.name}", "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 # Task Management Views
# ============================================================================= # =============================================================================
@block_source_user @block_source_user
@login_required @login_required
def task_create(request, project_pk): def task_create(request, project_pk):
"""Add a new task to a project""" """Add a new task to a project"""
user = request.user user = request.user
project = get_object_or_404(QIProject, pk=project_pk, is_template=False) project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
# Check permission # Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital: 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.")) messages.error(request, _("You don't have permission to add tasks to this project."))
return redirect('projects:project_detail', pk=project.pk) return redirect("projects:project_detail", pk=project.pk)
if request.method == 'POST': if request.method == "POST":
form = QIProjectTaskForm(request.POST, project=project) form = QIProjectTaskForm(request.POST, project=project)
if form.is_valid(): if form.is_valid():
task = form.save(commit=False) task = form.save(commit=False)
task.project = project task.project = project
task.save() task.save()
messages.success(request, _("Task added successfully.")) messages.success(request, _("Task added successfully."))
return redirect('projects:project_detail', pk=project.pk) return redirect("projects:project_detail", pk=project.pk)
else: else:
form = QIProjectTaskForm(project=project) form = QIProjectTaskForm(project=project)
context = { context = {
'form': form, "form": form,
'project': project, "project": project,
'is_create': True, "is_create": True,
} }
return render(request, 'projects/task_form.html', context) return render(request, "projects/task_form.html", context)
@block_source_user @block_source_user
@ -401,34 +401,35 @@ def task_edit(request, project_pk, task_pk):
user = request.user user = request.user
project = get_object_or_404(QIProject, pk=project_pk, is_template=False) project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project) task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
# Check permission # Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital: 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.")) messages.error(request, _("You don't have permission to edit tasks in this project."))
return redirect('projects:project_detail', pk=project.pk) return redirect("projects:project_detail", pk=project.pk)
if request.method == 'POST': if request.method == "POST":
form = QIProjectTaskForm(request.POST, instance=task, project=project) form = QIProjectTaskForm(request.POST, instance=task, project=project)
if form.is_valid(): if form.is_valid():
task = form.save() task = form.save()
# If status changed to completed, set completed_date # 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 from django.utils import timezone
task.completed_date = timezone.now().date() task.completed_date = timezone.now().date()
task.save() task.save()
messages.success(request, _("Task updated successfully.")) messages.success(request, _("Task updated successfully."))
return redirect('projects:project_detail', pk=project.pk) return redirect("projects:project_detail", pk=project.pk)
else: else:
form = QIProjectTaskForm(instance=task, project=project) form = QIProjectTaskForm(instance=task, project=project)
context = { context = {
'form': form, "form": form,
'project': project, "project": project,
'task': task, "task": task,
'is_create': False, "is_create": False,
} }
return render(request, 'projects/task_form.html', context) return render(request, "projects/task_form.html", context)
@block_source_user @block_source_user
@ -438,23 +439,23 @@ def task_delete(request, project_pk, task_pk):
user = request.user user = request.user
project = get_object_or_404(QIProject, pk=project_pk, is_template=False) project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project) task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
# Check permission # Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital: 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.")) messages.error(request, _("You don't have permission to delete tasks in this project."))
return redirect('projects:project_detail', pk=project.pk) return redirect("projects:project_detail", pk=project.pk)
if request.method == 'POST': if request.method == "POST":
task.delete() task.delete()
messages.success(request, _("Task deleted successfully.")) messages.success(request, _("Task deleted successfully."))
return redirect('projects:project_detail', pk=project.pk) return redirect("projects:project_detail", pk=project.pk)
context = { context = {
'project': project, "project": project,
'task': task, "task": task,
} }
return render(request, 'projects/task_delete_confirm.html', context) return render(request, "projects/task_delete_confirm.html", context)
@block_source_user @block_source_user
@ -464,68 +465,68 @@ def task_toggle_status(request, project_pk, task_pk):
user = request.user user = request.user
project = get_object_or_404(QIProject, pk=project_pk, is_template=False) project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project) task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
# Check permission # Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital: 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.")) 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 from django.utils import timezone
if task.status == 'completed': if task.status == "completed":
task.status = 'pending' task.status = "pending"
task.completed_date = None task.completed_date = None
else: else:
task.status = 'completed' task.status = "completed"
task.completed_date = timezone.now().date() task.completed_date = timezone.now().date()
task.save() task.save()
messages.success(request, _("Task status updated.")) 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 # Template Management Views
# ============================================================================= # =============================================================================
@block_source_user @block_source_user
@login_required @login_required
def template_list(request): def template_list(request):
"""List QI Project templates""" """List QI Project templates"""
user = request.user user = request.user
# Only admins can manage templates # Only admins can manage templates
if not (user.is_px_admin() or user.is_hospital_admin): if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to view templates.")) messages.error(request, _("You don't have permission to view templates."))
return redirect('projects:project_list') return redirect("projects:project_list")
queryset = QIProject.objects.filter(is_template=True).select_related('hospital', 'department') queryset = QIProject.objects.filter(is_template=True).select_related("hospital", "department")
# Apply RBAC filters # Apply RBAC filters
if not user.is_px_admin(): if not user.is_px_admin():
from django.db.models import Q 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
search_query = request.GET.get('search') search_query = request.GET.get("search")
if search_query: if search_query:
queryset = queryset.filter( queryset = queryset.filter(
Q(name__icontains=search_query) | Q(name__icontains=search_query)
Q(description__icontains=search_query) | | Q(description__icontains=search_query)
Q(name_ar__icontains=search_query) | Q(name_ar__icontains=search_query)
) )
queryset = queryset.order_by('name') queryset = queryset.order_by("name")
context = { context = {
'templates': queryset, "templates": queryset,
'can_create': user.is_px_admin() or user.is_hospital_admin, "can_create": user.is_px_admin() or user.is_hospital_admin,
'can_edit': 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 @block_source_user
@ -533,33 +534,33 @@ def template_list(request):
def template_detail(request, pk): def template_detail(request, pk):
"""View template details with tasks""" """View template details with tasks"""
user = request.user user = request.user
# Only admins can view templates # Only admins can view templates
if not (user.is_px_admin() or user.is_hospital_admin): if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to view templates.")) 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( template = get_object_or_404(
QIProject.objects.filter(is_template=True).select_related('hospital', 'department'), QIProject.objects.filter(is_template=True).select_related("hospital", "department"), pk=pk
pk=pk
) )
# Check permission for hospital-specific templates # Check permission for hospital-specific templates
if not user.is_px_admin(): if not user.is_px_admin():
from django.db.models import Q from django.db.models import Q
if template.hospital and template.hospital != user.hospital: if template.hospital and template.hospital != user.hospital:
messages.error(request, _("You don't have permission to view this template.")) messages.error(request, _("You don't have permission to view this template."))
return redirect('projects:template_list') return redirect("projects:template_list")
# Get tasks # Get tasks
tasks = template.tasks.all().order_by('order', 'created_at') tasks = template.tasks.all().order_by("order", "created_at")
context = { context = {
'template': template, "template": template,
'tasks': tasks, "tasks": tasks,
} }
return render(request, 'projects/template_detail.html', context) return render(request, "projects/template_detail.html", context)
@block_source_user @block_source_user
@ -567,30 +568,41 @@ def template_detail(request, pk):
def template_create(request): def template_create(request):
"""Create a new project template""" """Create a new project template"""
user = request.user user = request.user
# Only admins can create templates # Only admins can create templates
if not (user.is_px_admin() or user.is_hospital_admin): if not (user.is_px_admin() or user.is_hospital_admin):
messages.error(request, _("You don't have permission to create templates.")) messages.error(request, _("You don't have permission to create templates."))
return redirect('projects:project_list') return redirect("projects:project_list")
if request.method == 'POST': if request.method == "POST":
form = QIProjectTemplateForm(request.POST, user=user) form = QIProjectTemplateForm(request.POST, request=request)
if form.is_valid(): if form.is_valid():
template = form.save(commit=False) template = form.save(commit=False)
template.is_template = True template.is_template = True
template.created_by = user template.created_by = user
template.save() 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.")) 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: else:
form = QIProjectTemplateForm(user=user) form = QIProjectTemplateForm(request=request)
formset = TaskTemplateFormSet(prefix='tasktemplate_set')
context = { context = {
'form': form, "form": form,
'is_create': True, "formset": formset,
"is_create": True,
} }
return render(request, 'projects/template_form.html', context) return render(request, "projects/template_form.html", context)
@block_source_user @block_source_user
@ -599,29 +611,33 @@ def template_edit(request, pk):
"""Edit an existing project template""" """Edit an existing project template"""
user = request.user user = request.user
template = get_object_or_404(QIProject, pk=pk, is_template=True) template = get_object_or_404(QIProject, pk=pk, is_template=True)
# Check permission # Check permission
if not user.is_px_admin(): if not user.is_px_admin():
if template.hospital and template.hospital != user.hospital: if template.hospital and template.hospital != user.hospital:
messages.error(request, _("You don't have permission to edit this template.")) messages.error(request, _("You don't have permission to edit this template."))
return redirect('projects:template_list') return redirect("projects:template_list")
if request.method == 'POST': if request.method == "POST":
form = QIProjectTemplateForm(request.POST, instance=template, user=user) form = QIProjectTemplateForm(request.POST, instance=template, request=request)
if form.is_valid(): formset = TaskTemplateFormSet(request.POST, instance=template, prefix='tasktemplate_set')
if form.is_valid() and formset.is_valid():
form.save() form.save()
formset.save()
messages.success(request, _("Project template updated successfully.")) messages.success(request, _("Project template updated successfully."))
return redirect('projects:template_list') return redirect("projects:template_list")
else: else:
form = QIProjectTemplateForm(instance=template, user=user) form = QIProjectTemplateForm(instance=template, request=request)
formset = TaskTemplateFormSet(instance=template, prefix='tasktemplate_set')
context = { context = {
'form': form, "form": form,
'template': template, "formset": formset,
'is_create': False, "template": template,
"is_create": False,
} }
return render(request, 'projects/template_form.html', context) return render(request, "projects/template_form.html", context)
@block_source_user @block_source_user
@ -630,66 +646,67 @@ def template_delete(request, pk):
"""Delete a project template""" """Delete a project template"""
user = request.user user = request.user
template = get_object_or_404(QIProject, pk=pk, is_template=True) template = get_object_or_404(QIProject, pk=pk, is_template=True)
# Check permission # Check permission
if not user.is_px_admin(): if not user.is_px_admin():
if template.hospital and template.hospital != user.hospital: if template.hospital and template.hospital != user.hospital:
messages.error(request, _("You don't have permission to delete this template.")) messages.error(request, _("You don't have permission to delete this template."))
return redirect('projects:template_list') return redirect("projects:template_list")
if request.method == 'POST': if request.method == "POST":
template_name = template.name template_name = template.name
template.delete() template.delete()
messages.success(request, _('Template "%(name)s" deleted successfully.') % {'name': template_name}) messages.success(request, _('Template "%(name)s" deleted successfully.') % {"name": template_name})
return redirect('projects:template_list') return redirect("projects:template_list")
context = { 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 # PX Action Conversion View
# ============================================================================= # =============================================================================
@block_source_user @block_source_user
@login_required @login_required
def convert_action_to_project(request, action_pk): def convert_action_to_project(request, action_pk):
"""Convert a PX Action to a QI Project""" """Convert a PX Action to a QI Project"""
user = request.user user = request.user
# Check permission # Check permission
if not (user.is_px_admin() or user.is_hospital_admin or user.is_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.")) 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) action = get_object_or_404(PXAction, pk=action_pk)
# Check hospital access # Check hospital access
if not user.is_px_admin() and user.hospital and action.hospital != user.hospital: 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.")) messages.error(request, _("You don't have permission to convert this action."))
return redirect('px_action_center:action_detail', pk=action_pk) return redirect("px_action_center:action_detail", pk=action_pk)
if request.method == 'POST': if request.method == "POST":
form = ConvertToProjectForm(request.POST, user=user, action=action) form = ConvertToProjectForm(request.POST, request=request, action=action)
if form.is_valid(): if form.is_valid():
# Create project from template or blank # Create project from template or blank
template = form.cleaned_data.get('template') template = form.cleaned_data.get("template")
if template: if template:
# Copy from template # Copy from template
project = QIProject.objects.create( project = QIProject.objects.create(
name=form.cleaned_data['project_name'], name=form.cleaned_data["project_name"],
name_ar=template.name_ar, name_ar=template.name_ar,
description=template.description, description=template.description,
hospital=action.hospital, hospital=action.hospital,
department=template.department, department=template.department,
project_lead=form.cleaned_data['project_lead'], project_lead=form.cleaned_data["project_lead"],
target_completion_date=form.cleaned_data['target_completion_date'], target_completion_date=form.cleaned_data["target_completion_date"],
status='pending', status="pending",
created_by=user created_by=user,
) )
# Copy tasks from template # Copy tasks from template
for template_task in template.tasks.all(): for template_task in template.tasks.all():
@ -698,34 +715,34 @@ def convert_action_to_project(request, action_pk):
title=template_task.title, title=template_task.title,
description=template_task.description, description=template_task.description,
order=template_task.order, order=template_task.order,
status='pending' status="pending",
) )
else: else:
# Create blank project # Create blank project
project = QIProject.objects.create( project = QIProject.objects.create(
name=form.cleaned_data['project_name'], name=form.cleaned_data["project_name"],
description=action.description, description=action.description,
hospital=action.hospital, hospital=action.hospital,
project_lead=form.cleaned_data['project_lead'], project_lead=form.cleaned_data["project_lead"],
target_completion_date=form.cleaned_data['target_completion_date'], target_completion_date=form.cleaned_data["target_completion_date"],
status='pending', status="pending",
created_by=user created_by=user,
) )
# Link to the action # Link to the action
project.related_actions.add(action) project.related_actions.add(action)
messages.success(request, _("PX Action converted to QI Project successfully.")) 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: else:
initial_data = { 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 = { context = {
'form': form, "form": form,
'action': action, "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 PX Action Center forms - Manual action creation and management
""" """
from django import forms from django import forms
from django.db.models import Q from django.db.models import Q
@ -12,127 +13,128 @@ from .models import PXAction, ActionSource, ActionStatus
class ManualActionForm(forms.ModelForm): class ManualActionForm(forms.ModelForm):
""" """
Form for manually creating PX Actions. Form for manually creating PX Actions.
Features: Features:
- Source type selection including meeting types - Source type selection including meeting types
- User permission-based filtering - User permission-based filtering
- Cascading dropdowns (hospital → department) - Cascading dropdowns (hospital → department)
- Assignment to users - Assignment to users
""" """
class Meta: class Meta:
model = PXAction model = PXAction
fields = [ fields = [
'source_type', 'title', 'description', "source_type",
'hospital', 'department', 'category', "title",
'priority', 'severity', 'assigned_to', "description",
'due_at', 'requires_approval', 'action_plan' "hospital",
"department",
"category",
"priority",
"severity",
"assigned_to",
"due_at",
"requires_approval",
"action_plan",
] ]
widgets = { widgets = {
'title': forms.TextInput(attrs={ "title": forms.TextInput(attrs={"class": "form-control", "placeholder": "Brief title for the action plan"}),
'class': 'form-control', "description": forms.Textarea(
'placeholder': 'Brief title for the action plan' attrs={
}), "class": "form-control",
'description': forms.Textarea(attrs={ "rows": 4,
'class': 'form-control', "placeholder": "Detailed description of the issue or improvement needed",
'rows': 4, }
'placeholder': 'Detailed description of the issue or improvement needed' ),
}), "due_at": forms.DateTimeInput(attrs={"type": "datetime-local", "class": "form-control"}),
'due_at': forms.DateTimeInput(attrs={ "action_plan": forms.Textarea(
'type': 'datetime-local', attrs={"class": "form-control", "rows": 4, "placeholder": "Proposed action steps to address the issue"}
'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): 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) super().__init__(*args, **kwargs)
# Make hospital field required # Make hospital field hidden and auto-set based on user context
self.fields['hospital'].required = True self.fields["hospital"].widget = forms.HiddenInput()
self.fields['hospital'].widget.attrs['class'] = 'form-select' self.fields["hospital"].required = False
# Make source type required # Set hospital value based on user context
self.fields['source_type'].required = True if user:
self.fields['source_type'].widget.attrs['class'] = 'form-select' if user.is_px_admin() and tenant_hospital:
self.fields["hospital"].initial = tenant_hospital
# Make category required self.fields["hospital"].queryset = self.fields["hospital"].queryset.filter(id=tenant_hospital.id)
self.fields['category'].required = True hospital = tenant_hospital
self.fields['category'].widget.attrs['class'] = 'form-select' elif user.hospital:
self.fields["hospital"].initial = user.hospital
# Make priority and severity required self.fields["hospital"].queryset = self.fields["hospital"].queryset.filter(id=user.hospital.id)
self.fields['priority'].required = True hospital = user.hospital
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
else: else:
# User doesn't have a hospital, can't create actions hospital = None
self.fields['hospital'].queryset = self.fields['hospital'].queryset.none() self.fields["hospital"].queryset = self.fields["hospital"].queryset.none()
else:
# Filter departments based on selected hospital hospital = None
if user and user.hospital:
self.fields['department'].queryset = self.fields['department'].queryset.filter( # Make source type required
hospital=user.hospital self.fields["source_type"].required = True
) self.fields["source_type"].widget.attrs["class"] = "form-select"
self.fields['department'].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 # Filter assignable users
from apps.accounts.models import User from apps.accounts.models import User
if user and user.hospital:
self.fields['assigned_to'].queryset = User.objects.filter( if hospital:
is_active=True, self.fields["assigned_to"].queryset = User.objects.filter(is_active=True, hospital=hospital).order_by(
hospital=user.hospital "first_name", "last_name"
).order_by('first_name', 'last_name') )
self.fields['assigned_to'].widget.attrs['class'] = 'form-select' 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'
else: else:
self.fields['assigned_to'].queryset = User.objects.none() self.fields["assigned_to"].queryset = User.objects.none()
# Set default for requires_approval # Set default for requires_approval
self.fields['requires_approval'].initial = True self.fields["requires_approval"].initial = True
self.fields['requires_approval'].widget.attrs['class'] = 'form-check-input' self.fields["requires_approval"].widget.attrs["class"] = "form-check-input"
def clean_hospital(self): def clean_hospital(self):
"""Validate hospital field""" """Validate hospital field"""
hospital = self.cleaned_data.get('hospital') hospital = self.cleaned_data.get("hospital")
if not hospital: if not hospital:
raise forms.ValidationError("Hospital is required.") raise forms.ValidationError("Hospital is required.")
return hospital return hospital
def clean_due_at(self): def clean_due_at(self):
"""Validate due date is in the future""" """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: if due_at:
from django.utils import timezone from django.utils import timezone
if due_at <= timezone.now(): if due_at <= timezone.now():
raise forms.ValidationError("Due date must be in the future.") raise forms.ValidationError("Due date must be in the future.")
return due_at return due_at
def save(self, commit=True): def save(self, commit=True):
"""Save action with manual source type""" """Save action with manual source type"""
action = super().save(commit=False) action = super().save(commit=False)
action.source_type = ActionSource.MANUAL action.source_type = ActionSource.MANUAL
if commit: if commit:
action.save() action.save()
return action return action

View File

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

View File

@ -1,6 +1,7 @@
""" """
Survey forms for CRUD operations Survey forms for CRUD operations
""" """
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -12,344 +13,319 @@ from .models import SurveyInstance, SurveyTemplate, SurveyQuestion
class SurveyTemplateForm(HospitalFieldMixin, forms.ModelForm): class SurveyTemplateForm(HospitalFieldMixin, forms.ModelForm):
""" """
Form for creating/editing survey templates. Form for creating/editing survey templates.
Hospital field visibility: Hospital field visibility:
- PX Admins: See dropdown with all hospitals - PX Admins: See dropdown with all hospitals
- Others: Hidden field, auto-set to user's hospital - Others: Hidden field, auto-set to user's hospital
""" """
class Meta: class Meta:
model = SurveyTemplate model = SurveyTemplate
fields = [ fields = ["name", "name_ar", "hospital", "survey_type", "scoring_method", "negative_threshold", "is_active"]
'name', 'name_ar', 'hospital', 'survey_type',
'scoring_method', 'negative_threshold', 'is_active'
]
widgets = { widgets = {
'name': forms.TextInput(attrs={ "name": forms.TextInput(attrs={"class": "form-control", "placeholder": "e.g., MD Consultation Feedback"}),
'class': 'form-control', "name_ar": forms.TextInput(attrs={"class": "form-control", "placeholder": "الاسم بالعربية"}),
'placeholder': 'e.g., MD Consultation Feedback' "hospital": forms.Select(attrs={"class": "form-select"}),
}), "survey_type": forms.Select(attrs={"class": "form-select"}),
'name_ar': forms.TextInput(attrs={ "scoring_method": forms.Select(attrs={"class": "form-select"}),
'class': 'form-control', "negative_threshold": forms.NumberInput(
'placeholder': 'الاسم بالعربية' attrs={"class": "form-control", "step": "0.1", "min": "1", "max": "5"}
}), ),
'hospital': forms.Select(attrs={'class': 'form-select'}), "is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}),
'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): class SurveyQuestionForm(forms.ModelForm):
"""Form for creating/editing survey questions""" """Form for creating/editing survey questions"""
class Meta: class Meta:
model = SurveyQuestion model = SurveyQuestion
fields = [ fields = ["text", "text_ar", "question_type", "order", "is_required", "choices_json"]
'text', 'text_ar', 'question_type', 'order',
'is_required', 'choices_json'
]
widgets = { widgets = {
'text': forms.Textarea(attrs={ "text": forms.Textarea(
'class': 'form-control', attrs={"class": "form-control", "rows": 2, "placeholder": "Enter question in English"}
'rows': 2, ),
'placeholder': 'Enter question in English' "text_ar": forms.Textarea(
}), attrs={"class": "form-control", "rows": 2, "placeholder": "أدخل السؤال بالعربية"}
'text_ar': forms.Textarea(attrs={ ),
'class': 'form-control', "question_type": forms.Select(attrs={"class": "form-select"}),
'rows': 2, "order": forms.NumberInput(attrs={"class": "form-control", "min": "0"}),
'placeholder': 'أدخل السؤال بالعربية' "is_required": forms.CheckboxInput(attrs={"class": "form-check-input"}),
}), "choices_json": forms.Textarea(
'question_type': forms.Select(attrs={'class': 'form-select'}), attrs={
'order': forms.NumberInput(attrs={ "class": "form-control",
'class': 'form-control', "rows": 5,
'min': '0' "placeholder": '[{"value": "1", "label": "Option 1", "label_ar": "خيار 1"}]',
}), }
'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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['choices_json'].required = False self.fields["choices_json"].required = False
self.fields['choices_json'].help_text = _( self.fields["choices_json"].help_text = _(
'JSON array of choices for multiple choice questions. ' "JSON array of choices for multiple choice questions. "
'Format: [{"value": "1", "label": "Option 1", "label_ar": "خيار 1"}]' 'Format: [{"value": "1", "label": "Option 1", "label_ar": "خيار 1"}]'
) )
SurveyQuestionFormSet = forms.inlineformset_factory( SurveyQuestionFormSet = forms.inlineformset_factory(
SurveyTemplate, SurveyTemplate, SurveyQuestion, form=SurveyQuestionForm, extra=1, can_delete=True, min_num=1, validate_min=True
SurveyQuestion,
form=SurveyQuestionForm,
extra=1,
can_delete=True,
min_num=1,
validate_min=True
) )
class ManualSurveySendForm(forms.Form): class ManualSurveySendForm(forms.Form):
"""Form for manually sending surveys to patients or staff""" """Form for manually sending surveys to patients or staff"""
RECIPIENT_TYPE_CHOICES = [ RECIPIENT_TYPE_CHOICES = [
('patient', _('Patient')), ("patient", _("Patient")),
('staff', _('Staff')), ("staff", _("Staff")),
] ]
DELIVERY_CHANNEL_CHOICES = [ DELIVERY_CHANNEL_CHOICES = [
('email', _('Email')), ("email", _("Email")),
('sms', _('SMS')), ("sms", _("SMS")),
] ]
def __init__(self, user, *args, **kwargs): def __init__(self, request=None, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.user = user self.request = request
# Filter survey templates by user's hospital self.user = request.user if request else None
if user.hospital:
self.fields['survey_template'].queryset = SurveyTemplate.objects.filter( # Determine hospital context
hospital=user.hospital, hospital = None
is_active=True 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( survey_template = forms.ModelChoiceField(
queryset=SurveyTemplate.objects.filter(is_active=True), queryset=SurveyTemplate.objects.filter(is_active=True),
label=_('Survey Template'), label=_("Survey Template"),
widget=forms.Select(attrs={ widget=forms.Select(attrs={"class": "form-select", "data-placeholder": _("Select a survey template")}),
'class': 'form-select',
'data-placeholder': _('Select a survey template')
})
) )
recipient_type = forms.ChoiceField( recipient_type = forms.ChoiceField(
choices=RECIPIENT_TYPE_CHOICES, choices=RECIPIENT_TYPE_CHOICES,
label=_('Recipient Type'), label=_("Recipient Type"),
widget=forms.RadioSelect(attrs={ widget=forms.RadioSelect(attrs={"class": "form-check-input"}),
'class': 'form-check-input'
})
) )
recipient = forms.CharField( recipient = forms.CharField(
label=_('Recipient'), label=_("Recipient"),
widget=forms.TextInput(attrs={ widget=forms.TextInput(
'class': 'form-control', attrs={
'placeholder': _('Search by name or ID...'), "class": "form-control",
'data-search-url': '/api/recipients/search/' "placeholder": _("Search by name or ID..."),
}), "data-search-url": "/api/recipients/search/",
help_text=_('Start typing to search for patient or staff') }
),
help_text=_("Start typing to search for patient or staff"),
) )
delivery_channel = forms.ChoiceField( delivery_channel = forms.ChoiceField(
choices=DELIVERY_CHANNEL_CHOICES, choices=DELIVERY_CHANNEL_CHOICES,
label=_('Delivery Channel'), label=_("Delivery Channel"),
widget=forms.Select(attrs={ widget=forms.Select(attrs={"class": "form-select"}),
'class': 'form-select'
})
) )
custom_message = forms.CharField( custom_message = forms.CharField(
label=_('Custom Message (Optional)'), label=_("Custom Message (Optional)"),
required=False, required=False,
widget=forms.Textarea(attrs={ widget=forms.Textarea(
'class': 'form-control', attrs={
'rows': 3, "class": "form-control",
'placeholder': _('Add a custom message to the survey invitation...') "rows": 3,
}) "placeholder": _("Add a custom message to the survey invitation..."),
}
),
) )
class ManualPhoneSurveySendForm(forms.Form): class ManualPhoneSurveySendForm(forms.Form):
"""Form for sending surveys to a manually entered phone number""" """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) super().__init__(*args, **kwargs)
self.user = user self.request = request
# Filter survey templates by user's hospital self.user = request.user if request else None
if user.hospital:
self.fields['survey_template'].queryset = SurveyTemplate.objects.filter( # Determine hospital context
hospital=user.hospital, hospital = None
is_active=True 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( survey_template = forms.ModelChoiceField(
queryset=SurveyTemplate.objects.filter(is_active=True), queryset=SurveyTemplate.objects.filter(is_active=True),
label=_('Survey Template'), label=_("Survey Template"),
widget=forms.Select(attrs={ widget=forms.Select(attrs={"class": "form-select"}),
'class': 'form-select'
})
) )
phone_number = forms.CharField( phone_number = forms.CharField(
label=_('Phone Number'), label=_("Phone Number"),
widget=forms.TextInput(attrs={ widget=forms.TextInput(attrs={"class": "form-control", "placeholder": _("+966501234567")}),
'class': 'form-control', help_text=_("Enter phone number with country code (e.g., +966...)"),
'placeholder': _('+966501234567')
}),
help_text=_('Enter phone number with country code (e.g., +966...)')
) )
recipient_name = forms.CharField( recipient_name = forms.CharField(
label=_('Recipient Name (Optional)'), label=_("Recipient Name (Optional)"),
required=False, required=False,
widget=forms.TextInput(attrs={ widget=forms.TextInput(attrs={"class": "form-control", "placeholder": _("Patient Name")}),
'class': 'form-control',
'placeholder': _('Patient Name')
})
) )
custom_message = forms.CharField( custom_message = forms.CharField(
label=_('Custom Message (Optional)'), label=_("Custom Message (Optional)"),
required=False, required=False,
widget=forms.Textarea(attrs={ widget=forms.Textarea(
'class': 'form-control', attrs={
'rows': 3, "class": "form-control",
'placeholder': _('Add a custom message to the survey invitation...') "rows": 3,
}) "placeholder": _("Add a custom message to the survey invitation..."),
}
),
) )
def clean_phone_number(self): def clean_phone_number(self):
phone = self.cleaned_data['phone_number'].strip() phone = self.cleaned_data["phone_number"].strip()
# Remove spaces, dashes, parentheses # Remove spaces, dashes, parentheses
phone = phone.replace(' ', '').replace('-', '').replace('(', '').replace(')', '') phone = phone.replace(" ", "").replace("-", "").replace("(", "").replace(")", "")
if not phone.startswith('+'): if not phone.startswith("+"):
raise forms.ValidationError(_('Phone number must start with country code (e.g., +966)')) raise forms.ValidationError(_("Phone number must start with country code (e.g., +966)"))
return phone return phone
class BulkCSVSurveySendForm(forms.Form): class BulkCSVSurveySendForm(forms.Form):
"""Form for bulk sending surveys via CSV upload""" """Form for bulk sending surveys via CSV upload"""
survey_template = forms.ModelChoiceField( survey_template = forms.ModelChoiceField(
queryset=SurveyTemplate.objects.filter(is_active=True), queryset=SurveyTemplate.objects.filter(is_active=True),
label=_('Survey Template'), label=_("Survey Template"),
widget=forms.Select(attrs={ widget=forms.Select(attrs={"class": "form-select"}),
'class': 'form-select'
})
) )
csv_file = forms.FileField( csv_file = forms.FileField(
label=_('CSV File'), label=_("CSV File"),
widget=forms.FileInput(attrs={ widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv"}),
'class': 'form-control', help_text=_("Upload CSV with phone numbers. Format: phone_number,name(optional)"),
'accept': '.csv'
}),
help_text=_('Upload CSV with phone numbers. Format: phone_number,name(optional)')
) )
custom_message = forms.CharField( custom_message = forms.CharField(
label=_('Custom Message (Optional)'), label=_("Custom Message (Optional)"),
required=False, required=False,
widget=forms.Textarea(attrs={ widget=forms.Textarea(
'class': 'form-control', attrs={
'rows': 3, "class": "form-control",
'placeholder': _('Add a custom message to the survey invitation...') "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) super().__init__(*args, **kwargs)
self.user = user self.request = request
# Filter survey templates by user's hospital self.user = request.user if request else None
if user.hospital:
self.fields['survey_template'].queryset = SurveyTemplate.objects.filter( # Determine hospital context
hospital=user.hospital, hospital = None
is_active=True 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): class HISPatientImportForm(HospitalFieldMixin, forms.Form):
""" """
Form for importing patient data from HIS/MOH Statistics CSV. Form for importing patient data from HIS/MOH Statistics CSV.
Hospital field visibility: Hospital field visibility:
- PX Admins: See dropdown with all hospitals - PX Admins: See dropdown with all hospitals
- Others: Hidden field, auto-set to user's hospital - Others: Hidden field, auto-set to user's hospital
""" """
hospital = forms.ModelChoiceField( hospital = forms.ModelChoiceField(
queryset=Hospital.objects.filter(status='active'), queryset=Hospital.objects.filter(status="active"),
label=_('Hospital'), label=_("Hospital"),
widget=forms.Select(attrs={ widget=forms.Select(attrs={"class": "form-select"}),
'class': 'form-select' help_text=_("Select the hospital for these patient records"),
}),
help_text=_('Select the hospital for these patient records')
) )
csv_file = forms.FileField( csv_file = forms.FileField(
label=_('HIS Statistics CSV File'), label=_("HIS Statistics CSV File"),
widget=forms.FileInput(attrs={ widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv"}),
'class': 'form-control', help_text=_("Upload MOH Statistics CSV with patient visit data"),
'accept': '.csv'
}),
help_text=_('Upload MOH Statistics CSV with patient visit data')
) )
skip_header_rows = forms.IntegerField( skip_header_rows = forms.IntegerField(
label=_('Skip Header Rows'), label=_("Skip Header Rows"),
initial=5, initial=5,
min_value=0, min_value=0,
max_value=10, max_value=10,
widget=forms.NumberInput(attrs={ widget=forms.NumberInput(attrs={"class": "form-control"}),
'class': 'form-control' help_text=_("Number of metadata/header rows to skip before data rows"),
}),
help_text=_('Number of metadata/header rows to skip before data rows')
) )
class HISSurveySendForm(forms.Form): class HISSurveySendForm(forms.Form):
"""Form for sending surveys to imported HIS patients""" """Form for sending surveys to imported HIS patients"""
survey_template = forms.ModelChoiceField( survey_template = forms.ModelChoiceField(
queryset=SurveyTemplate.objects.filter(is_active=True), queryset=SurveyTemplate.objects.filter(is_active=True),
label=_('Survey Template'), label=_("Survey Template"),
widget=forms.Select(attrs={ widget=forms.Select(attrs={"class": "form-select"}),
'class': 'form-select'
})
) )
delivery_channel = forms.ChoiceField( delivery_channel = forms.ChoiceField(
choices=[ choices=[
('sms', _('SMS')), ("sms", _("SMS")),
('email', _('Email')), ("email", _("Email")),
('both', _('Both SMS and Email')), ("both", _("Both SMS and Email")),
], ],
label=_('Delivery Channel'), label=_("Delivery Channel"),
initial='sms', initial="sms",
widget=forms.Select(attrs={ widget=forms.Select(attrs={"class": "form-select"}),
'class': 'form-select'
})
) )
custom_message = forms.CharField( custom_message = forms.CharField(
label=_('Custom Message (Optional)'), label=_("Custom Message (Optional)"),
required=False, required=False,
widget=forms.Textarea(attrs={ widget=forms.Textarea(
'class': 'form-control', attrs={
'rows': 3, "class": "form-control",
'placeholder': _('Add a custom message to the survey invitation...') "rows": 3,
}) "placeholder": _("Add a custom message to the survey invitation..."),
}
),
) )
patient_ids = forms.CharField( patient_ids = forms.CharField(widget=forms.HiddenInput(), required=True)
widget=forms.HiddenInput(),
required=True def __init__(self, request=None, *args, **kwargs):
)
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.user = user self.request = request
if user.hospital: self.user = request.user if request else None
self.fields['survey_template'].queryset = SurveyTemplate.objects.filter(
hospital=user.hospital, # Determine hospital context
is_active=True 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 Handles importing patient data from HIS/MOH Statistics CSV
and sending surveys to imported patients. and sending surveys to imported patients.
""" """
import csv import csv
import io import io
import logging import logging
@ -33,263 +34,295 @@ logger = logging.getLogger(__name__)
def his_patient_import(request): def his_patient_import(request):
""" """
Import patients from HIS/MOH Statistics CSV. Import patients from HIS/MOH Statistics CSV.
CSV Format (MOH Statistics): CSV Format (MOH Statistics):
SNo, Facility Name, Visit Type, Admit Date, Discharge Date, SNo, Facility Name, Visit Type, Admit Date, Discharge Date,
Patient Name, File Number, SSN, Mobile No, Payment Type, Patient Name, File Number, SSN, Mobile No, Payment Type,
Gender, Full Age, Nationality, Date of Birth, Diagnosis Gender, Full Age, Nationality, Date of Birth, Diagnosis
""" """
user = request.user user = request.user
# Check permission # Check permission
if not user.is_px_admin() and not user.is_hospital_admin(): if not user.is_px_admin() and not user.is_hospital_admin():
messages.error(request, "You don't have permission to import patient data.") 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 storage for imported patients
session_key = f'his_import_{user.id}' session_key = f"his_import_{user.id}"
if request.method == 'POST': if request.method == "POST":
form = HISPatientImportForm(request.POST, request.FILES, user=user) form = HISPatientImportForm(request.POST, request.FILES, request=request)
if form.is_valid(): if form.is_valid():
try: try:
hospital = form.cleaned_data['hospital'] hospital = form.cleaned_data["hospital"]
csv_file = form.cleaned_data['csv_file'] csv_file = form.cleaned_data["csv_file"]
skip_rows = form.cleaned_data['skip_header_rows'] skip_rows = form.cleaned_data["skip_header_rows"]
# Parse CSV # 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) io_string = io.StringIO(decoded_file)
reader = csv.reader(io_string) reader = csv.reader(io_string)
# Skip header/metadata rows # Skip header/metadata rows
for _ in range(skip_rows): for _ in range(skip_rows):
next(reader, None) next(reader, None)
# Read header row # Read header row
header = next(reader, None) header = next(reader, None)
if not header: if not header:
messages.error(request, "CSV file is empty or has no data rows.") 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 # Find column indices
header = [h.strip().lower() for h in header] header = [h.strip().lower() for h in header]
col_map = { col_map = {
'file_number': _find_column(header, ['file number', 'file_number', 'mrn', 'file no']), "file_number": _find_column(header, ["file number", "file_number", "mrn", "file no"]),
'patient_name': _find_column(header, ['patient name', 'patient_name', 'name']), "patient_name": _find_column(header, ["patient name", "patient_name", "name"]),
'mobile_no': _find_column(header, ['mobile no', 'mobile_no', 'mobile', 'phone']), "mobile_no": _find_column(header, ["mobile no", "mobile_no", "mobile", "phone"]),
'ssn': _find_column(header, ['ssn', 'national id', 'national_id', 'id number']), "ssn": _find_column(header, ["ssn", "national id", "national_id", "id number"]),
'gender': _find_column(header, ['gender', 'sex']), "gender": _find_column(header, ["gender", "sex"]),
'visit_type': _find_column(header, ['visit type', 'visit_type', 'type']), "visit_type": _find_column(header, ["visit type", "visit_type", "type"]),
'admit_date': _find_column(header, ['admit date', 'admit_date', 'admission date']), "admit_date": _find_column(header, ["admit date", "admit_date", "admission date"]),
'discharge_date': _find_column(header, ['discharge date', 'discharge_date']), "discharge_date": _find_column(header, ["discharge date", "discharge_date"]),
'facility': _find_column(header, ['facility name', 'facility', 'hospital']), "facility": _find_column(header, ["facility name", "facility", "hospital"]),
'nationality': _find_column(header, ['nationality', 'country']), "nationality": _find_column(header, ["nationality", "country"]),
'dob': _find_column(header, ['date of birth', 'dob', 'birth date']), "dob": _find_column(header, ["date of birth", "dob", "birth date"]),
} }
# Check required columns # 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.") messages.error(request, "Could not find 'File Number' column in CSV.")
return render(request, 'surveys/his_patient_import.html', {'form': form}) return render(request, "surveys/his_patient_import.html", {"form": form})
if col_map['patient_name'] is None: if col_map["patient_name"] is None:
messages.error(request, "Could not find 'Patient Name' column in CSV.") 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 # Process data rows
imported_patients = [] imported_patients = []
errors = [] errors = []
row_num = skip_rows + 1 row_num = skip_rows + 1
for row in reader: for row in reader:
row_num += 1 row_num += 1
if not row or not any(row): # Skip empty rows if not row or not any(row): # Skip empty rows
continue continue
try: try:
# Extract data # Extract data
file_number = _get_cell(row, col_map['file_number'], '').strip() file_number = _get_cell(row, col_map["file_number"], "").strip()
patient_name = _get_cell(row, col_map['patient_name'], '').strip() patient_name = _get_cell(row, col_map["patient_name"], "").strip()
mobile_no = _get_cell(row, col_map['mobile_no'], '').strip() mobile_no = _get_cell(row, col_map["mobile_no"], "").strip()
ssn = _get_cell(row, col_map['ssn'], '').strip() ssn = _get_cell(row, col_map["ssn"], "").strip()
gender = _get_cell(row, col_map['gender'], '').strip().lower() gender = _get_cell(row, col_map["gender"], "").strip().lower()
visit_type = _get_cell(row, col_map['visit_type'], '').strip() visit_type = _get_cell(row, col_map["visit_type"], "").strip()
admit_date = _get_cell(row, col_map['admit_date'], '').strip() admit_date = _get_cell(row, col_map["admit_date"], "").strip()
discharge_date = _get_cell(row, col_map['discharge_date'], '').strip() discharge_date = _get_cell(row, col_map["discharge_date"], "").strip()
facility = _get_cell(row, col_map['facility'], '').strip() facility = _get_cell(row, col_map["facility"], "").strip()
nationality = _get_cell(row, col_map['nationality'], '').strip() nationality = _get_cell(row, col_map["nationality"], "").strip()
dob = _get_cell(row, col_map['dob'], '').strip() dob = _get_cell(row, col_map["dob"], "").strip()
# Skip if missing required fields # Skip if missing required fields
if not file_number or not patient_name: if not file_number or not patient_name:
continue continue
# Clean phone number # Clean phone number
if mobile_no: if mobile_no:
mobile_no = mobile_no.replace(' ', '').replace('-', '') mobile_no = mobile_no.replace(" ", "").replace("-", "")
if not mobile_no.startswith('+'): if not mobile_no.startswith("+"):
# Assume Saudi number if starts with 0 # Assume Saudi number if starts with 0
if mobile_no.startswith('05'): if mobile_no.startswith("05"):
mobile_no = '+966' + mobile_no[1:] mobile_no = "+966" + mobile_no[1:]
elif mobile_no.startswith('5'): elif mobile_no.startswith("5"):
mobile_no = '+966' + mobile_no mobile_no = "+966" + mobile_no
# Parse name (First Middle Last format) # Parse name (First Middle Last format)
name_parts = patient_name.split() name_parts = patient_name.split()
first_name = name_parts[0] if name_parts else '' first_name = name_parts[0] if name_parts else ""
last_name = name_parts[-1] if len(name_parts) > 1 else '' last_name = name_parts[-1] if len(name_parts) > 1 else ""
# Parse dates # Parse dates
parsed_admit = _parse_date(admit_date) parsed_admit = _parse_date(admit_date)
parsed_discharge = _parse_date(discharge_date) parsed_discharge = _parse_date(discharge_date)
parsed_dob = _parse_date(dob) parsed_dob = _parse_date(dob)
# Normalize gender # Normalize gender
if gender in ['male', 'm']: if gender in ["male", "m"]:
gender = 'male' gender = "male"
elif gender in ['female', 'f']: elif gender in ["female", "f"]:
gender = 'female' gender = "female"
else: else:
gender = '' gender = ""
imported_patients.append({ imported_patients.append(
'row_num': row_num, {
'file_number': file_number, "row_num": row_num,
'patient_name': patient_name, "file_number": file_number,
'first_name': first_name, "patient_name": patient_name,
'last_name': last_name, "first_name": first_name,
'mobile_no': mobile_no, "last_name": last_name,
'ssn': ssn, "mobile_no": mobile_no,
'gender': gender, "ssn": ssn,
'visit_type': visit_type, "gender": gender,
'admit_date': parsed_admit.isoformat() if parsed_admit else None, "visit_type": visit_type,
'discharge_date': parsed_discharge.isoformat() if parsed_discharge else None, "admit_date": parsed_admit.isoformat() if parsed_admit else None,
'facility': facility, "discharge_date": parsed_discharge.isoformat() if parsed_discharge else None,
'nationality': nationality, "facility": facility,
'dob': parsed_dob.isoformat() if parsed_dob else None, "nationality": nationality,
}) "dob": parsed_dob.isoformat() if parsed_dob else None,
}
)
except Exception as e: except Exception as e:
errors.append(f"Row {row_num}: {str(e)}") errors.append(f"Row {row_num}: {str(e)}")
logger.error(f"Error processing row {row_num}: {e}") logger.error(f"Error processing row {row_num}: {e}")
# Store in session for review step # Store in session for review step
request.session[session_key] = { request.session[session_key] = {
'hospital_id': str(hospital.id), "hospital_id": str(hospital.id),
'patients': imported_patients, "patients": imported_patients,
'errors': errors, "errors": errors,
'total_count': len(imported_patients) "total_count": len(imported_patients),
} }
# Log audit # Log audit
AuditService.log_event( 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()}", description=f"Imported {len(imported_patients)} patients from HIS CSV by {user.get_full_name()}",
user=user, user=user,
metadata={ metadata={
'hospital': hospital.name, "hospital": hospital.name,
'total_count': len(imported_patients), "total_count": len(imported_patients),
'error_count': len(errors) "error_count": len(errors),
} },
) )
if imported_patients: if imported_patients:
messages.success( messages.success(
request, 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: else:
messages.error(request, "No valid patient records found in CSV.") messages.error(request, "No valid patient records found in CSV.")
except Exception as e: except Exception as e:
logger.error(f"Error processing HIS CSV: {str(e)}", exc_info=True) logger.error(f"Error processing HIS CSV: {str(e)}", exc_info=True)
messages.error(request, f"Error processing CSV: {str(e)}") messages.error(request, f"Error processing CSV: {str(e)}")
else: else:
form = HISPatientImportForm(user=user) form = HISPatientImportForm(request=request)
context = { 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 @login_required
def his_patient_review(request): def his_patient_review(request):
""" """
Review imported patients before creating records and sending surveys. Review imported patients before creating records and sending surveys.
Shows summary statistics instead of full patient list for performance.
""" """
user = request.user user = request.user
session_key = f'his_import_{user.id}' session_key = f"his_import_{user.id}"
import_data = request.session.get(session_key) import_data = request.session.get(session_key)
if not import_data: if not import_data:
messages.error(request, "No import data found. Please upload CSV first.") messages.error(request, "No import data found. Please upload CSV first.")
return redirect('surveys:his_patient_import') return redirect("surveys:his_patient_import")
hospital = get_object_or_404(Hospital, id=import_data['hospital_id']) hospital = get_object_or_404(Hospital, id=import_data["hospital_id"])
patients = import_data['patients'] patients = import_data["patients"]
errors = import_data.get('errors', []) errors = import_data.get("errors", [])
# Check for existing patients # 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: for p in patients:
existing = Patient.objects.filter(mrn=p['file_number']).first() existing = Patient.objects.filter(mrn=p["file_number"]).first()
p['exists'] = existing is not None p["exists"] = existing is not None
if request.method == 'POST':
action = request.POST.get('action')
selected_ids = request.POST.getlist('selected_patients')
if action == 'create': if p["exists"]:
# Create/update patient records 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 created_count = 0
updated_count = 0 updated_count = 0
with transaction.atomic(): with transaction.atomic():
for p in patients: for p in patients:
if p['file_number'] in selected_ids: patient, created = Patient.objects.update_or_create(
patient, created = Patient.objects.update_or_create( mrn=p["file_number"],
mrn=p['file_number'], defaults={
defaults={ "first_name": p["first_name"],
'first_name': p['first_name'], "last_name": p["last_name"],
'last_name': p['last_name'], "phone": p["mobile_no"],
'phone': p['mobile_no'], "national_id": p["ssn"],
'national_id': p['ssn'], "gender": p["gender"] if p["gender"] else "",
'gender': p['gender'] if p['gender'] else '', "primary_hospital": hospital,
'primary_hospital': hospital, },
} )
) if created:
if created: created_count += 1
created_count += 1 else:
else: updated_count += 1
updated_count += 1 # Store only the patient ID to avoid serialization issues
# Store only the patient ID to avoid serialization issues p["patient_id"] = str(patient.id)
p['patient_id'] = str(patient.id)
# Update session with created patients # Update session with created patients
request.session[session_key]['patients'] = patients request.session[session_key]["patients"] = patients
request.session.modified = True request.session.modified = True
messages.success( messages.success(
request, request, f"Created {created_count} new patients, updated {updated_count} existing patients."
f"Created {created_count} new patients, updated {updated_count} existing patients."
) )
return redirect('surveys:his_patient_survey_send') return redirect("surveys:his_patient_survey_send")
elif action == 'cancel': elif action == "cancel":
del request.session[session_key] del request.session[session_key]
messages.info(request, "Import cancelled.") 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 = { context = {
'hospital': hospital, "hospital": hospital,
'patients': patients, "errors": errors,
'errors': errors, "total_count": total_count,
'total_count': len(patients), "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 @login_required
@ -298,47 +331,67 @@ def his_patient_survey_send(request):
Send surveys to imported patients - Queues a background task. Send surveys to imported patients - Queues a background task.
""" """
user = request.user user = request.user
session_key = f'his_import_{user.id}' session_key = f"his_import_{user.id}"
import_data = request.session.get(session_key) import_data = request.session.get(session_key)
if not import_data: if not import_data:
messages.error(request, "No import data found. Please upload CSV first.") messages.error(request, "No import data found. Please upload CSV first.")
return redirect('surveys:his_patient_import') return redirect("surveys:his_patient_import")
hospital = get_object_or_404(Hospital, id=import_data['hospital_id']) hospital = get_object_or_404(Hospital, id=import_data["hospital_id"])
all_patients = import_data['patients'] all_patients = import_data["patients"]
# Filter only patients with records in database # 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 = [] patients = []
for p in all_patients: for p in all_patients:
if 'patient_id' in p: if "patient_id" in p and p["patient_id"] in patient_objs:
patients.append(p) # 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: if not patients:
messages.error(request, "No patients have been created yet. Please create patients first.") messages.error(request, "No patients have been created yet. Please create patients first.")
return redirect('surveys:his_patient_review') return redirect("surveys:his_patient_review")
if request.method == 'POST': if request.method == "POST":
form = HISSurveySendForm(request.POST, user=user) form = HISSurveySendForm(data=request.POST, request=request)
if form.is_valid(): if form.is_valid():
survey_template = form.cleaned_data['survey_template'] survey_template = form.cleaned_data["survey_template"]
delivery_channel = form.cleaned_data['delivery_channel'] delivery_channel = form.cleaned_data["delivery_channel"]
custom_message = form.cleaned_data.get('custom_message', '') custom_message = form.cleaned_data.get("custom_message", "")
selected_ids = request.POST.getlist('selected_patients') selected_ids = request.POST.getlist("selected_patients")
# Filter selected patients # Filter selected patients from render data
selected_patients = [p for p in patients if p['file_number'] in selected_ids] selected_patients_render = [p for p in patients if p["file_number"] in selected_ids]
if not selected_patients: if not selected_patients_render:
messages.error(request, "No patients selected.") messages.error(request, "No patients selected.")
return render(request, 'surveys/his_patient_survey_send.html', { return render(
'form': form, request,
'hospital': hospital, "surveys/his_patient_survey_send.html",
'patients': patients, {
'total_count': len(patients), "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 # Create bulk job
job = BulkSurveyJob.objects.create( job = BulkSurveyJob.objects.create(
name=f"HIS Import - {hospital.name} - {timezone.now().strftime('%Y-%m-%d %H:%M')}", 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), total_patients=len(selected_patients),
delivery_channel=delivery_channel, delivery_channel=delivery_channel,
custom_message=custom_message, custom_message=custom_message,
patient_data=selected_patients patient_data=selected_patients,
) )
# Queue the background task # Queue the background task
send_bulk_surveys.delay(str(job.id)) send_bulk_surveys.delay(str(job.id))
# Log audit # Log audit
AuditService.log_event( AuditService.log_event(
event_type='his_survey_queued', event_type="his_survey_queued",
description=f"Queued bulk survey job for {len(selected_patients)} patients", description=f"Queued bulk survey job for {len(selected_patients)} patients",
user=user, user=user,
metadata={ metadata={
'job_id': str(job.id), "job_id": str(job.id),
'hospital': hospital.name, "hospital": hospital.name,
'survey_template': survey_template.name, "survey_template": survey_template.name,
'patient_count': len(selected_patients) "patient_count": len(selected_patients),
} },
) )
# Clear session # Clear session
del request.session[session_key] del request.session[session_key]
messages.success( messages.success(
request, request,
f"Survey sending job queued for {len(selected_patients)} patients. " 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: else:
form = HISSurveySendForm(user=user) form = HISSurveySendForm(request=request)
context = { context = {
'form': form, "form": form,
'hospital': hospital, "hospital": hospital,
'patients': patients, "patients": patients,
'total_count': len(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 # Helper functions
def _find_column(header, possible_names): def _find_column(header, possible_names):
"""Find column index by possible names""" """Find column index by possible names"""
for name in possible_names: for name in possible_names:
@ -401,7 +455,7 @@ def _find_column(header, possible_names):
return None return None
def _get_cell(row, index, default=''): def _get_cell(row, index, default=""):
"""Safely get cell value""" """Safely get cell value"""
if index is None or index >= len(row): if index is None or index >= len(row):
return default return default
@ -412,24 +466,23 @@ def _parse_date(date_str):
"""Parse date from various formats""" """Parse date from various formats"""
if not date_str: if not date_str:
return None return None
formats = [ formats = [
'%d-%b-%Y %H:%M:%S', "%d-%b-%Y %H:%M:%S",
'%d-%b-%Y', "%d-%b-%Y",
'%d/%m/%Y %H:%M:%S', "%d/%m/%Y %H:%M:%S",
'%d/%m/%Y', "%d/%m/%Y",
'%Y-%m-%d %H:%M:%S', "%Y-%m-%d %H:%M:%S",
'%Y-%m-%d', "%Y-%m-%d",
] ]
for fmt in formats: for fmt in formats:
try: try:
return datetime.strptime(date_str.strip(), fmt).date() return datetime.strptime(date_str.strip(), fmt).date()
except ValueError: except ValueError:
continue continue
return None
return None
@login_required @login_required
@ -439,19 +492,19 @@ def bulk_job_status(request, job_id):
""" """
user = request.user user = request.user
job = get_object_or_404(BulkSurveyJob, id=job_id) job = get_object_or_404(BulkSurveyJob, id=job_id)
# Check permission # Check permission
if not user.is_px_admin() and job.created_by != user and job.hospital != user.hospital: 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.") messages.error(request, "You don't have permission to view this job.")
return redirect('surveys:instance_list') return redirect("surveys:instance_list")
context = { context = {
'job': job, "job": job,
'progress': job.progress_percentage, "progress": job.progress_percentage,
'is_complete': job.is_complete, "is_complete": job.is_complete,
'results': job.results, "results": job.results,
} }
return render(request, 'surveys/bulk_job_status.html', context) return render(request, "surveys/bulk_job_status.html", context)
@login_required @login_required
@ -460,7 +513,7 @@ def bulk_job_list(request):
List all bulk survey jobs for the user. List all bulk survey jobs for the user.
""" """
user = request.user user = request.user
# Filter jobs # Filter jobs
if user.is_px_admin(): if user.is_px_admin():
jobs = BulkSurveyJob.objects.all() jobs = BulkSurveyJob.objects.all()
@ -468,10 +521,10 @@ def bulk_job_list(request):
jobs = BulkSurveyJob.objects.filter(hospital=user.hospital) jobs = BulkSurveyJob.objects.filter(hospital=user.hospital)
else: else:
jobs = BulkSurveyJob.objects.filter(created_by=user) 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 = { 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

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

View File

@ -270,7 +270,7 @@ def survey_template_create(request):
return redirect("surveys:template_list") return redirect("surveys:template_list")
if request.method == "POST": if request.method == "POST":
form = SurveyTemplateForm(request.POST, user=user) form = SurveyTemplateForm(request.POST, request=request)
formset = SurveyQuestionFormSet(request.POST) formset = SurveyQuestionFormSet(request.POST)
if form.is_valid() and formset.is_valid(): if form.is_valid() and formset.is_valid():
@ -286,7 +286,7 @@ def survey_template_create(request):
messages.success(request, "Survey template created successfully.") messages.success(request, "Survey template created successfully.")
return redirect("surveys:template_detail", pk=template.pk) return redirect("surveys:template_detail", pk=template.pk)
else: else:
form = SurveyTemplateForm(user=user) form = SurveyTemplateForm(request=request)
formset = SurveyQuestionFormSet() formset = SurveyQuestionFormSet()
context = { context = {
@ -345,7 +345,7 @@ def survey_template_edit(request, pk):
return redirect("surveys:template_list") return redirect("surveys:template_list")
if request.method == "POST": if request.method == "POST":
form = SurveyTemplateForm(request.POST, instance=template, user=user) form = SurveyTemplateForm(request.POST, instance=template, request=request)
formset = SurveyQuestionFormSet(request.POST, instance=template) formset = SurveyQuestionFormSet(request.POST, instance=template)
if form.is_valid() and formset.is_valid(): if form.is_valid() and formset.is_valid():
@ -355,7 +355,7 @@ def survey_template_edit(request, pk):
messages.success(request, "Survey template updated successfully.") messages.success(request, "Survey template updated successfully.")
return redirect("surveys:template_detail", pk=template.pk) return redirect("surveys:template_detail", pk=template.pk)
else: else:
form = SurveyTemplateForm(instance=template, user=user) form = SurveyTemplateForm(instance=template, request=request)
formset = SurveyQuestionFormSet(instance=template) formset = SurveyQuestionFormSet(instance=template)
context = { context = {
@ -786,7 +786,7 @@ def manual_survey_send(request):
return redirect("surveys:instance_list") return redirect("surveys:instance_list")
if request.method == "POST": if request.method == "POST":
form = ManualSurveySendForm(user, request.POST) form = ManualSurveySendForm(request=request, data=request.POST)
if form.is_valid(): if form.is_valid():
try: try:
@ -877,7 +877,7 @@ def manual_survey_send(request):
messages.error(request, f"Error sending survey: {str(e)}") messages.error(request, f"Error sending survey: {str(e)}")
return render(request, "surveys/manual_send.html", {"form": form}) return render(request, "surveys/manual_send.html", {"form": form})
else: else:
form = ManualSurveySendForm(user) form = ManualSurveySendForm(request=request)
context = { context = {
"form": form, "form": form,
@ -905,7 +905,7 @@ def manual_survey_send_phone(request):
return redirect("surveys:instance_list") return redirect("surveys:instance_list")
if request.method == "POST": if request.method == "POST":
form = ManualPhoneSurveySendForm(user, request.POST) form = ManualPhoneSurveySendForm(request=request, data=request.POST)
if form.is_valid(): if form.is_valid():
try: try:
@ -968,7 +968,7 @@ def manual_survey_send_phone(request):
messages.error(request, f"Error sending survey: {str(e)}") messages.error(request, f"Error sending survey: {str(e)}")
return render(request, "surveys/manual_send_phone.html", {"form": form}) return render(request, "surveys/manual_send_phone.html", {"form": form})
else: else:
form = ManualPhoneSurveySendForm(user) form = ManualPhoneSurveySendForm(request=request)
context = { context = {
"form": form, "form": form,
@ -1005,7 +1005,7 @@ def manual_survey_send_csv(request):
return redirect("surveys:instance_list") return redirect("surveys:instance_list")
if request.method == "POST": if request.method == "POST":
form = BulkCSVSurveySendForm(user, request.POST, request.FILES) form = BulkCSVSurveySendForm(request=request, data=request.POST, files=request.FILES)
if form.is_valid(): if form.is_valid():
try: try:
@ -1150,7 +1150,7 @@ def manual_survey_send_csv(request):
messages.error(request, f"Error processing CSV: {str(e)}") messages.error(request, f"Error processing CSV: {str(e)}")
return render(request, "surveys/manual_send_csv.html", {"form": form}) return render(request, "surveys/manual_send_csv.html", {"form": form})
else: else:
form = BulkCSVSurveySendForm(user) form = BulkCSVSurveySendForm(request=request)
context = { context = {
"form": form, "form": form,

View File

@ -9,6 +9,11 @@ from celery.schedules import crontab
# Set the default Django settings module for the 'celery' program. # Set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') 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') app = Celery('px360')
# Using a string here means the worker doesn't have to serialize # Using a string here means the worker doesn't have to serialize
@ -30,7 +35,7 @@ app.conf.beat_schedule = {
# Fetch surveys from HIS every 5 minutes # Fetch surveys from HIS every 5 minutes
'fetch-his-surveys': { 'fetch-his-surveys': {
'task': 'apps.integrations.tasks.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': { 'options': {
'expires': 240, # Task expires after 4 minutes if not picked up 'expires': 240, # Task expires after 4 minutes if not picked up
} }

View File

@ -1,36 +1,46 @@
""" """
Custom Celery Beat scheduler to fix Python 3.12 zoneinfo compatibility issue. 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): def apply_tzcrontab_patch():
"""
Custom model entry that fixes the zoneinfo.ZoneInfo compatibility issue.
""" """
Apply monkey-patch to django_celery_beat.tzcrontab.TzAwareCrontab
to fix zoneinfo.ZoneInfo compatibility.
def _default_now(self): This should be called at Celery app initialization.
"""
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.
""" """
from django_celery_beat import tzcrontab
Entry = PatchedModelEntry original_init = tzcrontab.TzAwareCrontab.__init__
def _default_now(self): @functools.wraps(original_init)
""" def patched_init(self, *args, **kwargs):
Return the current time in the configured timezone. # Get the tz argument, default to None
This fixes the AttributeError: 'zoneinfo.ZoneInfo' object has no attribute 'localize' tz = kwargs.get('tz', None)
"""
from django.utils import timezone # Check if it's a zoneinfo.ZoneInfo (no 'normalize' attribute)
return timezone.now() 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

View File

@ -28,7 +28,7 @@ dependencies = [
"openpyxl>=3.1.5", "openpyxl>=3.1.5",
"litellm>=1.0.0", "litellm>=1.0.0",
"watchdog>=6.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", "google-api-python-client>=2.187.0",
"tweepy>=4.16.0", "tweepy>=4.16.0",
"google-auth-oauthlib>=1.2.3", "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 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 %} {% 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 --> <!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8"> <div class="page-header-gradient flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-3"> <div>
<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"> <nav aria-label="breadcrumb" class="mb-2">
<i data-lucide="{% if form.instance.pk %}edit{% else %}plus{% endif %}" class="w-8 h-8 text-white"></i> <ol class="flex items-center gap-2 text-sm text-white/80">
</div> <li><a href="{% url 'accounts:acknowledgements:ack_category_list' %}" class="hover:text-white transition">{% trans "Categories" %}</a></li>
<div> <li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<h1 class="text-2xl font-bold text-navy"> <li class="text-white">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Create" %}{% endif %}</li>
{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "New Category" %}{% endif %} </ol>
</h1> </nav>
<p class="text-slate text-sm"> <h1 class="text-2xl font-bold flex items-center gap-2">
{% if form.instance.pk %}{{ form.instance.name_en }}{% else %}{% trans "Create a new acknowledgement category" %}{% endif %} <i data-lucide="{% if form.instance.pk %}edit{% else %}plus{% endif %}" class="w-8 h-8"></i>
</p> {% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "New Category" %}{% endif %}
</div> </h1>
</div> </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> <i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back to Categories" %} {% trans "Back to Categories" %}
</a> </a>
</div> </div>
<!-- Form Card --> <!-- Form Section -->
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden"> <div class="form-section">
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-blue-50 to-transparent"> <div class="flex items-center gap-2 mb-4 pb-3 border-b border-slate-100">
<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-600"></i>
<i data-lucide="file-text" class="w-5 h-5 text-blue-500"></i> <h2 class="text-lg font-bold text-slate-800">{% trans "Category Details" %}</h2>
{% trans "Category Details" %}
</h2>
</div> </div>
<form method="post" class="p-6 space-y-6"> <form method="post" class="space-y-6">
{% csrf_token %} {% csrf_token %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <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)" %} {% trans "Name (English)" %}
</label> </label>
{{ form.name_en }} {{ form.name_en }}
@ -50,7 +130,7 @@
</div> </div>
<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)" %} {% trans "Name (Arabic)" %}
</label> </label>
{{ form.name_ar }} {{ form.name_ar }}
@ -62,7 +142,7 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<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" %} {% trans "Code" %}
</label> </label>
{{ form.code }} {{ form.code }}
@ -72,7 +152,7 @@
</div> </div>
<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" %} {% trans "Display Order" %}
</label> </label>
{{ form.order }} {{ form.order }}
@ -82,7 +162,7 @@
</div> </div>
<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" %} {% trans "Icon" %}
</label> </label>
{{ form.icon }} {{ form.icon }}
@ -94,7 +174,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <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" %} {% trans "Color" %}
</label> </label>
<div class="flex gap-3 items-center"> <div class="flex gap-3 items-center">
@ -107,21 +187,21 @@
</div> </div>
<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" %} {% trans "Status" %}
</label> </label>
<div class="flex items-center gap-3 mt-3"> <div class="flex items-center gap-3 mt-3">
{{ form.is_active }} {{ form.is_active }}
<span class="text-slate">{% trans "Active" %}</span> <span class="text-slate-600">{% trans "Active" %}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="flex justify-end gap-3 pt-6 border-t border-slate-100"> <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" %} {% trans "Cancel" %}
</a> </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> <i data-lucide="save" class="w-4 h-4"></i>
{% trans "Save Category" %} {% trans "Save Category" %}
</button> </button>
@ -150,4 +230,4 @@ document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons(); lucide.createIcons();
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -3,42 +3,88 @@
{% block title %}{% trans "Acknowledgement Categories" %} - PX360{% endblock %} {% 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 %} {% 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-light to-blue-50 min-h-screen">
<!-- Page Header --> <!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8"> <div class="page-header-gradient">
<div class="flex items-center gap-3"> <div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<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"> <div class="flex items-center gap-3">
<i data-lucide="folder" class="w-8 h-8 text-white"></i> <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>
<div> <div class="flex gap-3">
<h1 class="text-2xl font-bold text-navy"> <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">
{% trans "Acknowledgement Categories" %} <i data-lucide="arrow-left" class="w-4 h-4"></i>
</h1> {% trans "Back" %}
<p class="text-slate text-sm"> </a>
{% trans "Manage categories for acknowledgement items" %} <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">
</p> <i data-lucide="plus" class="w-4 h-4"></i>
{% trans "New Category" %}
</a>
</div> </div>
</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> </div>
<!-- Categories List --> <!-- Categories List -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden"> <div class="section-card">
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-blue-50 to-transparent"> <div class="section-header">
<h2 class="text-lg font-bold text-navy flex items-center gap-2"> <div class="section-icon bg-blue-100">
<i data-lucide="list" class="w-5 h-5 text-blue-500"></i> <i data-lucide="list" class="w-5 h-5 text-blue-600"></i>
{% trans "All Categories" %} </div>
</h2> <h2 class="text-lg font-bold text-navy">{% trans "All Categories" %}</h2>
</div> </div>
<div class="p-6"> <div class="p-6">
{% if categories %} {% if categories %}
@ -130,4 +176,4 @@ document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons(); lucide.createIcons();
}); });
</script> </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 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 %} {% 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 --> <!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8"> <div class="page-header-gradient flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-3"> <div>
<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"> <nav aria-label="breadcrumb" class="mb-2">
<i data-lucide="{% if form.instance.pk %}edit{% else %}plus{% endif %}" class="w-8 h-8 text-white"></i> <ol class="flex items-center gap-2 text-sm text-white/80">
</div> <li><a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="hover:text-white transition">{% trans "Checklist Items" %}</a></li>
<div> <li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<h1 class="text-2xl font-bold text-navy"> <li class="text-white">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Create" %}{% endif %}</li>
{% if form.instance.pk %}{% trans "Edit Checklist Item" %}{% else %}{% trans "New Checklist Item" %}{% endif %} </ol>
</h1> </nav>
<p class="text-slate text-sm"> <h1 class="text-2xl font-bold flex items-center gap-2">
{% if form.instance.pk %}{{ form.instance.text_en }}{% else %}{% trans "Create a new acknowledgement checklist item" %}{% endif %} <i data-lucide="{% if form.instance.pk %}edit{% else %}plus{% endif %}" class="w-8 h-8"></i>
</p> {% if form.instance.pk %}{% trans "Edit Checklist Item" %}{% else %}{% trans "New Checklist Item" %}{% endif %}
</div> </h1>
</div> </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> <i data-lucide="arrow-left" class="w-4 h-4"></i>
{% trans "Back to List" %} {% trans "Back to List" %}
</a> </a>
</div> </div>
<!-- Form Card --> <!-- Form Section -->
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden"> <div class="form-section">
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-purple-50 to-transparent"> <div class="flex items-center gap-2 mb-4 pb-3 border-b border-slate-100">
<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-600"></i>
<i data-lucide="file-text" class="w-5 h-5 text-purple-500"></i> <h2 class="text-lg font-bold text-slate-800">{% trans "Checklist Item Details" %}</h2>
{% trans "Checklist Item Details" %}
</h2>
</div> </div>
<form method="post" class="p-6 space-y-6"> <form method="post" class="space-y-6">
{% csrf_token %} {% csrf_token %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <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" %} * {% trans "Category" %} *
</label> </label>
{{ form.category }} {{ form.category }}
@ -50,7 +130,7 @@
</div> </div>
<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" %} {% trans "Code" %}
</label> </label>
{{ form.code }} {{ form.code }}
@ -61,7 +141,7 @@
</div> </div>
<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)" %} * {% trans "Text (English)" %} *
</label> </label>
{{ form.text_en }} {{ form.text_en }}
@ -71,7 +151,7 @@
</div> </div>
<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)" %} {% trans "Text (Arabic)" %}
</label> </label>
{{ form.text_ar }} {{ form.text_ar }}
@ -81,7 +161,7 @@
</div> </div>
<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)" %} {% trans "Description (English)" %}
</label> </label>
{{ form.description_en }} {{ form.description_en }}
@ -91,7 +171,7 @@
</div> </div>
<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)" %} {% trans "Description (Arabic)" %}
</label> </label>
{{ form.description_ar }} {{ form.description_ar }}
@ -102,7 +182,7 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<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" %} {% trans "Display Order" %}
</label> </label>
{{ form.order }} {{ form.order }}
@ -115,7 +195,7 @@
<div class="mt-6"> <div class="mt-6">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{{ form.requires_signature }} {{ 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" %} {% trans "Requires Signature" %}
</label> </label>
</div> </div>
@ -126,7 +206,7 @@
<div class="mt-6"> <div class="mt-6">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{{ form.is_active }} {{ 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" %} {% trans "Active" %}
</label> </label>
</div> </div>
@ -135,10 +215,10 @@
</div> </div>
<div class="flex justify-end gap-3 pt-6 border-t border-slate-100"> <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" %} {% trans "Cancel" %}
</a> </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> <i data-lucide="save" class="w-4 h-4"></i>
{% trans "Save Item" %} {% trans "Save Item" %}
</button> </button>
@ -153,4 +233,4 @@ document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons(); lucide.createIcons();
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -3,73 +3,121 @@
{% block title %}{% trans "Acknowledgement Checklist Items" %} - PX360{% endblock %} {% 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 %} {% 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-light to-blue-50 min-h-screen">
<!-- Page Header --> <!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8"> <div class="page-header-gradient">
<div class="flex items-center gap-3"> <div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<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"> <div class="flex items-center gap-3">
<i data-lucide="check-square" class="w-8 h-8 text-white"></i> <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>
<div> <div class="flex gap-3">
<h1 class="text-2xl font-bold text-navy"> <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">
{% trans "Checklist Items" %} <i data-lucide="arrow-left" class="w-4 h-4"></i>
</h1> {% trans "Back" %}
<p class="text-slate text-sm"> </a>
{% trans "Manage acknowledgement checklist items" %} <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">
</p> <i data-lucide="plus" class="w-4 h-4"></i>
{% trans "New Item" %}
</a>
</div> </div>
</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> </div>
<!-- Filter Section --> <!-- Filter Section -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-4 mb-6"> <div class="section-card mb-6">
<form method="get" class="flex flex-wrap gap-4 items-end"> <div class="p-4">
<div> <form method="get" class="flex flex-wrap gap-4 items-end">
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Category" %}</label> <div>
<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"> <label class="block text-xs font-semibold text-slate mb-1">{% trans "Category" %}</label>
<option value="">{% trans "All Categories" %}</option> <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">
{% for cat in categories %} <option value="">{% trans "All Categories" %}</option>
<option value="{{ cat.id }}" {% if request.GET.category == cat.id|stringformat:"s" %}selected{% endif %}>{{ cat.name_en }}</option> {% for cat in categories %}
{% endfor %} <option value="{{ cat.id }}" {% if request.GET.category == cat.id|stringformat:"s" %}selected{% endif %}>{{ cat.name_en }}</option>
</select> {% endfor %}
</div> </select>
<div> </div>
<label class="block text-xs font-semibold text-slate mb-1">{% trans "Status" %}</label> <div>
<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"> <label class="block text-xs font-semibold text-slate mb-1">{% trans "Status" %}</label>
<option value="">{% trans "All" %}</option> <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="true" {% if request.GET.is_active == "true" %}selected{% endif %}>{% trans "Active" %}</option> <option value="">{% trans "All" %}</option>
<option value="false" {% if request.GET.is_active == "false" %}selected{% endif %}>{% trans "Inactive" %}</option> <option value="true" {% if request.GET.is_active == "true" %}selected{% endif %}>{% trans "Active" %}</option>
</select> <option value="false" {% if request.GET.is_active == "false" %}selected{% endif %}>{% trans "Inactive" %}</option>
</div> </select>
<button type="submit" class="px-4 py-2 bg-blue text-white rounded-xl text-sm font-semibold hover:bg-navy transition"> </div>
<i data-lucide="filter" class="w-4 h-4 inline mr-1"></i> <button type="submit" class="px-4 py-2 bg-blue text-white rounded-xl text-sm font-semibold hover:bg-navy transition">
{% trans "Filter" %} <i data-lucide="filter" class="w-4 h-4 inline mr-1"></i>
</button> {% trans "Filter" %}
<a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="px-4 py-2 text-slate hover:text-navy text-sm"> </button>
{% trans "Clear" %} <a href="{% url 'accounts:acknowledgements:ack_checklist_list' %}" class="px-4 py-2 text-slate hover:text-navy text-sm">
</a> {% trans "Clear" %}
</form> </a>
</form>
</div>
</div> </div>
<!-- Checklist Items List --> <!-- Checklist Items List -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden"> <div class="section-card">
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-purple-50 to-transparent"> <div class="section-header">
<h2 class="text-lg font-bold text-navy flex items-center gap-2"> <div class="section-icon bg-purple-100">
<i data-lucide="list" class="w-5 h-5 text-purple-500"></i> <i data-lucide="list" class="w-5 h-5 text-purple-600"></i>
{% trans "All Checklist Items" %} </div>
<span class="ml-2 px-2 py-0.5 bg-slate-100 text-slate text-sm rounded-full">{{ items.count }}</span> <h2 class="text-lg font-bold text-navy">{% trans "All Checklist Items" %}</h2>
</h2> <span class="ml-2 px-2 py-0.5 bg-slate-100 text-slate text-sm rounded-full">{{ items.count }}</span>
</div> </div>
<div class="p-6"> <div class="p-6">
{% if items %} {% if items %}
@ -157,4 +205,4 @@ document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons(); lucide.createIcons();
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -3,36 +3,82 @@
{% block title %}{% trans "Completed Acknowledgements" %} - PX360{% endblock %} {% 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 %} {% 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-light to-blue-50 min-h-screen">
<!-- Page Header --> <!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8"> <div class="page-header-gradient">
<div class="flex items-center gap-3"> <div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<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"> <div class="flex items-center gap-3">
<i data-lucide="check-circle" class="w-8 h-8 text-white"></i> <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>
<div> <div class="flex gap-3">
<h1 class="text-2xl font-bold text-navy"> <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">
{% trans "Completed Acknowledgements" %} <i data-lucide="arrow-left" class="w-4 h-4"></i>
</h1> {% trans "Back to Dashboard" %}
<p class="text-slate text-sm"> </a>
{% trans "View your signed acknowledgements" %}
</p>
</div> </div>
</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> </div>
<!-- Stats Card --> <!-- 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="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"> <div class="section-icon bg-emerald-100">
<i data-lucide="file-check" class="w-6 h-6 text-white"></i> <i data-lucide="file-check" class="w-6 h-6 text-emerald-600"></i>
</div> </div>
<div> <div>
<p class="text-xs font-semibold text-slate uppercase">{% trans "Total Signed" %}</p> <p class="text-xs font-semibold text-slate uppercase">{% trans "Total Signed" %}</p>
@ -42,12 +88,12 @@
</div> </div>
<!-- Acknowledgements List --> <!-- Acknowledgements List -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden"> <div class="section-card">
<div class="px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-emerald-50 to-transparent"> <div class="section-header">
<h2 class="text-lg font-bold text-navy flex items-center gap-2"> <div class="section-icon bg-emerald-100">
<i data-lucide="clipboard-check" class="w-5 h-5 text-emerald-500"></i> <i data-lucide="clipboard-check" class="w-5 h-5 text-emerald-600"></i>
{% trans "Signed Documents" %} </div>
</h2> <h2 class="text-lg font-bold text-navy">{% trans "Signed Documents" %}</h2>
</div> </div>
<div class="p-6"> <div class="p-6">
{% if acknowledgements %} {% if acknowledgements %}
@ -130,4 +176,4 @@ document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons(); lucide.createIcons();
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -1,135 +1,73 @@
{% extends 'emails/base_email_template.html' %}
{% load i18n %} {% 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 --> {% block title %}{% trans "Password Reset Request - PX360 Al Hammadi Hospital" %}{% endblock %}
<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>
<div class="button-container"> {% block preheader %}{% trans "We received a request to reset your password. Click to reset it now." %}{% endblock %}
<a href="{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %}" class="reset-button">
{% trans "Reset My Password" %}
</a>
</div>
<p>{% trans "Or copy and paste this link into your browser:" %}</p> {% block hero_title %}{% trans "Password Reset Request" %}{% endblock %}
<p class="link-text">{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %}</p>
<div class="warning-box"> {% block hero_subtitle %}{% trans "Patient Experience Management System" %}{% endblock %}
<strong>{% trans "Important:" %}</strong><br>
{% 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." %} {% 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> <!-- Support Message -->
</div> <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 --> {% block cta_url %}{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %}{% endblock %}
<div class="footer"> {% block cta_text %}{% trans "Reset My Password" %}{% endblock %}
<p>{% trans "This is an automated email from PX360" %}</p>
<p>&copy; {% now "Y" %} Al Hammadi Hospital. {% trans "All rights reserved." %}</p> {% block footer_address %}
</div> PX360 - Patient Experience Management<br>
</div> Al Hammadi Hospital
</body> {% endblock %}
</html>

View File

@ -3,35 +3,83 @@
{% block title %}{% trans "Acknowledgement Checklist Items" %}{% endblock %} {% 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 %} {% 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-light to-blue-50 min-h-screen">
<!-- Page Header --> <!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"> <div class="page-header-gradient">
<div class="flex items-center gap-3"> <div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<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"> <div class="flex items-center gap-3">
<i data-lucide="list-checks" class="w-8 h-8 text-white"></i> <div class="section-icon bg-white/20">
</div> <i data-lucide="list-checks" class="w-6 h-6 text-white"></i>
<div> </div>
<h2 class="text-3xl font-bold text-navy mb-1"> <div>
{% trans "Checklist Items" %} <h2 class="text-2xl font-bold">
</h2> {% trans "Checklist Items" %}
<p class="text-slate">{% trans "Manage acknowledgement checklist items" %}</p> </h2>
<p class="text-white/80">{% trans "Manage acknowledgement checklist items" %}</p>
</div>
</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> </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> </div>
<!-- Checklist Items List --> <!-- Checklist Items List -->
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden"> <div class="section-card">
<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"> <div class="section-header 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"> <div class="flex items-center gap-2">
<i data-lucide="clipboard-list" class="w-5 h-5 text-blue"></i> <div class="section-icon bg-gradient-to-br from-blue to-navy">
{% trans "All Items" %} <i data-lucide="clipboard-list" class="w-5 h-5 text-white"></i>
</h3> </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="flex items-center gap-2 w-full md:w-auto">
<div class="relative flex-1 md:w-64"> <div class="relative flex-1 md:w-64">
<input type="text" id="searchInput" placeholder="{% trans 'Search items...' %}" <input type="text" id="searchInput" placeholder="{% trans 'Search items...' %}"

View File

@ -1,163 +1,107 @@
<!DOCTYPE html> {% extends 'emails/base_email_template.html' %}
<html lang="en" dir="ltr">
<head> {% block title %}User Onboarding Completed - Al Hammadi Hospital{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> {% block preheader %}A new user has completed onboarding and is now active.{% endblock %}
<title>User Onboarding Completed</title>
<style> {% block hero_title %}✅ User Onboarding Completed{% endblock %}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; {% block hero_subtitle %}A new team member has joined PX360{% endblock %}
line-height: 1.6;
color: #333; {% block content %}
max-width: 600px; <!-- Notification Message -->
margin: 0 auto; <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
padding: 20px; <tr>
background-color: #f5f5f5; <td style="padding-bottom: 20px;">
} <p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
.container { A new user has successfully completed the onboarding process and is now active in the PX360 system.
background-color: #ffffff; </p>
border-radius: 8px; </td>
padding: 40px; </tr>
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); </table>
}
.header { <!-- User Information Card -->
text-align: center; <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;">
margin-bottom: 30px; <tr>
} <td style="padding: 20px;">
.logo { <h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696; text-align: center;">
font-size: 28px; User Information
font-weight: bold; </h3>
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>
<div class="user-info"> <!-- Detail Rows -->
<table> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <tr>
<td>Name:</td> <td width="120" style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
<td>{{ user.get_full_name|default:"Not provided" }}</td> Name:
</tr> </td>
<tr> <td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
<td>Email:</td> {{ user.get_full_name|default:"Not provided" }}
<td>{{ user.email }}</td> </td>
</tr> </tr>
<tr> <tr>
<td>Username:</td> <td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
<td>{{ user.username }}</td> Email:
</tr> </td>
<tr> <td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
<td>Employee ID:</td> {{ user.email }}
<td>{{ user.employee_id|default:"Not provided" }}</td> </td>
</tr> </tr>
<tr> <tr>
<td>Hospital:</td> <td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
<td>{{ user.hospital.name|default:"Not assigned" }}</td> Username:
</tr> </td>
<tr> <td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
<td>Department:</td> {{ user.username }}
<td>{{ user.department.name|default:"Not assigned" }}</td> </td>
</tr> </tr>
<tr> <tr>
<td>Completed At:</td> <td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
<td>{{ user.acknowledgement_completed_at|date:"F j, Y, g:i a" }}</td> Employee ID:
</tr> </td>
</table> <td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
</div> {{ user.employee_id|default:"Not provided" }}
</td>
<div class="button-container"> </tr>
<a href="{{ user_detail_url }}" class="button">View User Details</a> <tr>
</div> <td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
</div> Hospital:
</td>
<p class="timestamp"> <td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
This notification was sent on {{ "now"|date:"F j, Y, g:i a" }} {{ user.hospital.name|default:"Not assigned" }}
</p> </td>
</tr>
<div class="footer"> <tr>
<p>This is an automated notification from PX360.</p> <td style="padding: 10px 0; font-size: 14px; color: #64748b; font-weight: 500;">
<p>&copy; {{ "now"|date:"Y" }} PX360 - Patient Experience Platform</p> Department:
</div> </td>
</div> <td style="padding: 10px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
</body> {{ user.department.name|default:"Not assigned" }}
</html> </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 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 %} {% 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-light to-blue-50 min-h-screen">
<!-- Header --> <!-- Page Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8"> <div class="page-header-gradient">
<div class="flex items-center gap-3"> <div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<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"> <div class="flex items-center gap-3">
<i data-lucide="file-text" class="w-8 h-8 text-white"></i> <div class="section-icon bg-white/20">
</div> <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-blue hover:text-navy mb-2 font-medium"> <div>
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i> <a href="{% url 'accounts:acknowledgement-dashboard' %}" class="inline-flex items-center text-white/80 hover:text-white mb-1 font-medium transition">
{% trans "Back to Dashboard" %} <i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
</a> {% trans "Back to Dashboard" %}
<h1 class="text-3xl font-bold text-navy"> </a>
{% trans "Onboarding Content" %} <h1 class="text-2xl font-bold">
</h1> {% trans "Onboarding Content" %}
<p class="text-slate"> </h1>
{% trans "Manage the content shown during staff onboarding" %} <p class="text-white/80">{% trans "Manage the content shown during staff onboarding" %}</p>
</p> </div>
</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> </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> </div>
<!-- Content List --> <!-- 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 %} {% if content_items %}
<div class="divide-y divide-blue-50"> <div class="divide-y divide-blue-50">
{% for item in content_items %} {% for item in content_items %}

View File

@ -1,138 +1,108 @@
<!DOCTYPE html> {% extends 'emails/base_email_template.html' %}
<html lang="en" dir="ltr">
<head> {% block title %}Welcome to PX360 - Al Hammadi Hospital{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> {% block preheader %}You have been invited to join PX360. Complete your account setup.{% endblock %}
<title>Welcome to PX360</title>
<style> {% block hero_title %}Welcome to PX360!{% endblock %}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; {% block hero_subtitle %}Your comprehensive Patient Experience management platform{% endblock %}
line-height: 1.6;
color: #333; {% block content %}
max-width: 600px; <!-- Greeting -->
margin: 0 auto; <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
padding: 20px; <tr>
background-color: #f5f5f5; <td style="padding-bottom: 20px;">
} <p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
.container { Hello <strong>{{ user.first_name|default:user.email }}</strong>,
background-color: #ffffff; </p>
border-radius: 8px; <p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
padding: 40px; You have been invited to join PX360, our comprehensive Patient Experience management platform. To complete your account setup, please click the button below.
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); </p>
} </td>
.header { </tr>
text-align: center; </table>
margin-bottom: 30px;
} <!-- What You'll Do -->
.logo { <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
font-size: 28px; <tr>
font-weight: bold; <td>
color: #0d6efd; <h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696;">
} During the onboarding process, you will:
h1 { </h3>
color: #0d6efd; </td>
font-size: 24px; </tr>
margin-bottom: 20px; <tr>
} <td>
.greeting { <!-- Item 1 -->
font-size: 18px; <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
margin-bottom: 20px; <tr>
} <td width="40" valign="top" style="padding-right: 10px;">
.content { <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>
margin-bottom: 30px; </td>
} <td>
.button { <p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
display: inline-block; Learn about PX360 features and your role responsibilities
background-color: #0d6efd; </p>
color: #ffffff !important; </td>
text-decoration: none; </tr>
padding: 14px 30px; </table>
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>
<div class="button-container"> <!-- Item 2 -->
<a href="{{ activation_url }}" class="button">Complete Account Setup</a> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
</div> <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> <!-- Item 3 -->
<ul> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
<li>Learn about PX360 features and your role responsibilities</li> <tr>
<li>Review and acknowledge important policies and guidelines</li> <td width="40" valign="top" style="padding-right: 10px;">
<li>Set up your username and password</li> <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>
<li>Complete your profile information</li> </td>
</ul> <td>
</div> <p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
Set up your username and password
<div class="expiry-notice"> </p>
<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. </td>
</div> </tr>
</table>
<p class="link-fallback">
If the button above doesn't work, copy and paste this link into your browser:<br> <!-- Item 4 -->
<a href="{{ activation_url }}">{{ activation_url }}</a> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
</p> <tr>
<td width="40" valign="top" style="padding-right: 10px;">
<div class="footer"> <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>
<p>This is an automated message from PX360. Please do not reply to this email.</p> </td>
<p>If you did not expect this invitation or have questions, please contact your system administrator.</p> <td>
<p>&copy; {{ "now"|date:"Y" }} PX360 - Patient Experience Platform</p> <p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
</div> Complete your profile information
</div> </p>
</body> </td>
</html> </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 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 %} {% 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-light to-blue-50 min-h-screen">
<!-- Header --> <!-- Page Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8"> <div class="page-header-gradient">
<div class="flex items-center gap-3"> <div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<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"> <div class="flex items-center gap-3">
<i data-lucide="user-plus" class="w-8 h-8 text-white"></i> <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>
<div> <div class="flex gap-3">
<a href="{% url 'accounts:acknowledgement-dashboard' %}" class="inline-flex items-center text-blue hover:text-navy mb-2 font-medium"> <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="arrow-left" class="w-4 h-4 mr-2"></i> <i data-lucide="plus" class="w-4 h-4"></i>
{% trans "Back to Dashboard" %} {% trans "Create New User" %}
</a> </button>
<h1 class="text-3xl font-bold text-navy"> </div>
{% trans "Provisional Accounts" %} </div>
</h1> </div>
<p class="text-slate">
{% trans "View accounts pending activation" %} <!-- Statistics Cards -->
</p> <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> </div>
</div> </div>
<!-- Accounts List --> <!-- 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 %} {% if provisional_accounts %}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">
@ -80,7 +173,7 @@
{% endif %} {% endif %}
</td> </td>
<td class="px-6 py-4"> <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>
<td class="px-6 py-4"> <td class="px-6 py-4">
{% if account.invitation_expires_at %} {% if account.invitation_expires_at %}
@ -99,7 +192,7 @@
<i data-lucide="check" class="w-3 h-3"></i> <i data-lucide="check" class="w-3 h-3"></i>
{% trans "Active" %} {% trans "Active" %}
</span> </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"> <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> <i data-lucide="alert-circle" class="w-3 h-3"></i>
{% trans "Expired" %} {% trans "Expired" %}
@ -112,12 +205,23 @@
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="flex gap-2"> <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' %}"> <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="mail" class="w-4 h-4"></i> <i data-lucide="eye" class="w-4 h-4"></i>
</button> </a>
<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' %}"> <form method="post" action="{% url 'accounts:bulk-resend-invitations' %}" class="inline" onsubmit="return confirm('{% trans "Resend invitation to this user?" %}')">
<i data-lucide="user-x" class="w-4 h-4"></i> {% csrf_token %}
</button> <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> </div>
</td> </td>
</tr> </tr>
@ -137,7 +241,139 @@
</div> </div>
</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> <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() { document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons(); lucide.createIcons();
}); });

View File

@ -1,134 +1,97 @@
<!DOCTYPE html> {% extends 'emails/base_email_template.html' %}
<html lang="en" dir="ltr">
<head> {% block title %}Reminder: Complete Your PX360 Account Setup - Al Hammadi Hospital{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> {% block preheader %}Your PX360 account setup is pending. Please complete before expiry.{% endblock %}
<title>Reminder: Complete Your PX360 Account Setup</title>
<style> {% block hero_title %}⏰ Reminder: Complete Your Setup{% endblock %}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; {% block hero_subtitle %}Your PX360 account invitation is still active{% endblock %}
line-height: 1.6;
color: #333; {% block content %}
max-width: 600px; <!-- Greeting -->
margin: 0 auto; <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
padding: 20px; <tr>
background-color: #f5f5f5; <td style="padding-bottom: 20px;">
} <p style="margin: 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
.container { Hello <strong>{{ user.first_name|default:user.email }}</strong>,
background-color: #ffffff; </p>
border-radius: 8px; <p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
padding: 40px; 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!
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); </p>
} <p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
.header { Click the button below to continue where you left off:
text-align: center; </p>
margin-bottom: 30px; </td>
} </tr>
.logo { </table>
font-size: 28px;
font-weight: bold; <!-- Quick Benefits -->
color: #0d6efd; <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 25px;">
} <tr>
h1 { <td>
color: #fd7e14; <h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696;">
font-size: 24px; Why Complete Your Setup?
margin-bottom: 20px; </h3>
} </td>
.greeting { </tr>
font-size: 18px; <tr>
margin-bottom: 20px; <td>
} <!-- Benefit 1 -->
.content { <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
margin-bottom: 30px; <tr>
} <td width="40" valign="top" style="padding-right: 10px;">
.button { <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>
display: inline-block; </td>
background-color: #0d6efd; <td>
color: #ffffff !important; <p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
text-decoration: none; <strong>Access Your Dashboard:</strong> Manage your tasks and responsibilities
padding: 14px 30px; </p>
border-radius: 6px; </td>
font-weight: bold; </tr>
font-size: 16px; </table>
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>
<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"> <!-- Benefit 3 -->
<a href="{{ activation_url }}" class="button">Complete Account Setup</a> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
</div> <tr>
</div> <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>
<div class="expiry-notice"> </td>
<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. <td>
</div> <p style="margin: 0; font-size: 15px; color: #1e293b; line-height: 1.5;">
<strong>Take Action:</strong> Respond to complaints and feedback
<p class="link-fallback"> </p>
If the button above doesn't work, copy and paste this link into your browser:<br> </td>
<a href="{{ activation_url }}">{{ activation_url }}</a> </tr>
</p> </table>
</td>
<div class="footer"> </tr>
<p>This is an automated reminder from PX360. Please do not reply to this email.</p> </table>
<p>If you have already completed your registration, please disregard this message.</p> {% endblock %}
<p>If you have questions, please contact your system administrator.</p>
<p>&copy; {{ "now"|date:"Y" }} PX360 - Patient Experience Platform</p> {% block cta_url %}{{ activation_url }}{% endblock %}
</div> {% block cta_text %}Complete Account Setup{% endblock %}
</div>
</body> {% block info_title %}⚠️ Time Sensitive: Invitation Expiry{% endblock %}
</html> {% 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" %} {% trans "Hospital Notifications" %}
</button> </button>
{% endif %} {% 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>
</div> </div>
@ -715,6 +721,150 @@
</div> </div>
</div> </div>
{% endif %} {% 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> </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 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 %} {% 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">
<!-- Header --> <!-- Page Header -->
<div class="flex items-center gap-3 mb-8"> <div class="page-header-gradient flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<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> <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 %} {% if action == 'create' %}{% trans "New Acknowledgement" %}{% else %}{% trans "Edit Acknowledgement" %}{% endif %}
</h1> </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> </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> </div>
<!-- Form Section -->
<div class="max-w-3xl"> <div class="max-w-3xl">
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden"> <div class="form-section">
<form method="post" enctype="multipart/form-data" class="p-6 space-y-6"> <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 %} {% csrf_token %}
<!-- Title --> <!-- Title -->
<div> <div>
<label class="block text-sm font-bold text-navy mb-2"> <label class="form-label">
{% trans "Title" %} <span class="text-red-500">*</span> {% trans "Title" %} <span class="text-red-500">*</span>
</label> </label>
<input type="text" name="title" required <input type="text" name="title" required
value="{{ acknowledgement.title|default:'' }}" 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' %}"> placeholder="{% trans 'Enter acknowledgement title' %}">
</div> </div>
<!-- Description --> <!-- Description -->
<div> <div>
<label class="block text-sm font-bold text-navy mb-2"> <label class="form-label">
{% trans "Description" %} {% trans "Description" %}
</label> </label>
<textarea name="description" rows="5" <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> placeholder="{% trans 'Enter detailed description of the acknowledgement' %}">{{ acknowledgement.description|default:'' }}</textarea>
</div> </div>
<!-- PDF Document --> <!-- PDF Document -->
<div> <div>
<label class="block text-sm font-bold text-navy mb-2"> <label class="form-label">
{% trans "PDF Document" %} {% trans "PDF Document" %}
</label> </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" <input type="file" name="pdf_document" accept=".pdf"
class="w-full" class="w-full"
onchange="updateFileName(this)"> onchange="updateFileName(this)">
{% if acknowledgement.pdf_document %} {% if acknowledgement.pdf_document %}
<p class="text-sm text-slate mt-2"> <p class="text-sm text-slate-600 mt-2">
{% trans "Current:" %} <a href="{{ acknowledgement.pdf_document.url }}" target="_blank" class="text-blue hover:underline">{{ acknowledgement.pdf_document.name }}</a> {% trans "Current:" %} <a href="{{ acknowledgement.pdf_document.url }}" target="_blank" class="text-blue-600 hover:underline">{{ acknowledgement.pdf_document.name }}</a>
</p> </p>
{% endif %} {% endif %}
</div> </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> </div>
<!-- Settings --> <!-- Settings -->
@ -70,10 +163,10 @@
<label class="flex items-center gap-3 cursor-pointer"> <label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="is_active" <input type="checkbox" name="is_active"
{% if acknowledgement.is_active|default:True %}checked{% endif %} {% 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> <div>
<span class="font-semibold text-navy">{% trans "Active" %}</span> <span class="font-semibold text-slate-800">{% trans "Active" %}</span>
<p class="text-xs text-slate">{% trans "Show in employee checklist" %}</p> <p class="text-xs text-slate-500">{% trans "Show in employee checklist" %}</p>
</div> </div>
</label> </label>
</div> </div>
@ -81,10 +174,10 @@
<label class="flex items-center gap-3 cursor-pointer"> <label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="is_required" <input type="checkbox" name="is_required"
{% if acknowledgement.is_required|default:True %}checked{% endif %} {% 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> <div>
<span class="font-semibold text-navy">{% trans "Required" %}</span> <span class="font-semibold text-slate-800">{% trans "Required" %}</span>
<p class="text-xs text-slate">{% trans "Must be signed by all employees" %}</p> <p class="text-xs text-slate-500">{% trans "Must be signed by all employees" %}</p>
</div> </div>
</label> </label>
</div> </div>
@ -92,24 +185,23 @@
<!-- Order --> <!-- Order -->
<div> <div>
<label class="block text-sm font-bold text-navy mb-2"> <label class="form-label">
{% trans "Display Order" %} {% trans "Display Order" %}
</label> </label>
<input type="number" name="order" <input type="number" name="order"
value="{{ acknowledgement.order|default:0 }}" 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"> class="form-control w-32">
<p class="text-xs text-slate mt-2">{% trans "Lower numbers appear first" %}</p> <p class="text-xs text-slate-500 mt-2">{% trans "Lower numbers appear first" %}</p>
</div> </div>
<!-- Actions --> <!-- 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' %}" <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" %} {% trans "Cancel" %}
</a> </a>
<button type="submit" <button type="submit" class="btn-primary">
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-4 h-4"></i>
<i data-lucide="save" class="w-5 h-5"></i>
{% if action == 'create' %}{% trans "Create Acknowledgement" %}{% else %}{% trans "Save Changes" %}{% endif %} {% if action == 'create' %}{% trans "Create Acknowledgement" %}{% else %}{% trans "Save Changes" %}{% endif %}
</button> </button>
</div> </div>

View File

@ -3,39 +3,85 @@
{% block title %}{% trans "Manage Acknowledgements" %} - PX360{% endblock %} {% 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 %} {% 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-light to-blue-50 min-h-screen">
<!-- Header --> <!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8"> <div class="page-header-gradient">
<div class="flex items-center gap-3"> <div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<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"> <div class="flex items-center gap-3">
<i data-lucide="clipboard-list" class="w-6 h-6 text-white"></i> <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>
<div> <div class="flex gap-3">
<h1 class="text-2xl font-bold text-navy">{% trans "Acknowledgements" %}</h1> <a href="{% url 'accounts:simple_acknowledgements:admin_signatures' %}"
<p class="text-slate text-sm">{% trans "Manage employee acknowledgement forms" %}</p> 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> </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> </div>
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8"> <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-5"> <div class="section-card p-5">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-10 h-10 bg-blue-100 rounded-xl flex items-center justify-center"> <div class="section-icon bg-blue-100">
<i data-lucide="clipboard" class="w-5 h-5 text-blue"></i> <i data-lucide="clipboard" class="w-5 h-5 text-blue-600"></i>
</div> </div>
<div> <div>
<p class="text-xs text-slate uppercase font-semibold">{% trans "Total" %}</p> <p class="text-xs text-slate uppercase font-semibold">{% trans "Total" %}</p>
@ -43,9 +89,9 @@
</div> </div>
</div> </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="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> <i data-lucide="check-circle" class="w-5 h-5 text-emerald-600"></i>
</div> </div>
<div> <div>
@ -57,9 +103,12 @@
</div> </div>
<!-- Acknowledgements Table --> <!-- Acknowledgements Table -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden"> <div class="section-card">
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50"> <div class="section-header">
<h2 class="font-bold text-navy">{% trans "All Acknowledgements" %}</h2> <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>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">

View File

@ -3,84 +3,134 @@
{% block title %}{% trans "My Acknowledgements" %} - PX360{% endblock %} {% 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 %} {% 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-light to-blue-50 min-h-screen">
<!-- Page Header --> <!-- Page Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8"> <div class="page-header-gradient">
<div class="flex items-center gap-3"> <div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<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"> <div class="flex items-center gap-3">
<i data-lucide="clipboard-signature" class="w-8 h-8 text-white"></i> <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>
<div> <div class="flex gap-3">
<h1 class="text-2xl font-bold text-navy">{% trans "My Acknowledgements" %}</h1> {% if user.is_px_admin or user.is_staff %}
<p class="text-slate text-sm">{% trans "Review and sign required documents" %}</p> <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> </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> </div>
{% if pending > 0 %} {% if pending > 0 %}
<!-- Alert Banner --> <!-- 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="section-card mb-6 border-orange-300 bg-gradient-to-r from-orange-500 to-orange-600">
<div class="flex items-center gap-4"> <div class="p-6 text-white">
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center"> <div class="flex items-center gap-4">
<i data-lucide="bell" class="w-6 h-6"></i> <div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
</div> <i data-lucide="bell" class="w-6 h-6"></i>
<div class="flex-1"> </div>
<h2 class="text-lg font-bold">{% trans "Action Required" %}</h2> <div class="flex-1">
<p class="text-orange-100"> <h2 class="text-lg font-bold">{% trans "Action Required" %}</h2>
{% blocktrans %} <p class="text-orange-100">
You have {{ pending }} acknowledgement(s) waiting for your signature. Please review and sign them below. {% blocktrans %}
{% endblocktrans %} You have {{ pending }} acknowledgement(s) waiting for your signature. Please review and sign them below.
</p> {% endblocktrans %}
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Progress Card --> <!-- Progress Card -->
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 p-6 mb-6"> <div class="section-card mb-6">
<div class="flex justify-between items-center mb-3"> <div class="p-6">
<span class="font-semibold text-navy">{% trans "Completion Progress" %}</span> <div class="flex justify-between items-center mb-3">
<span class="text-2xl font-bold text-blue">{{ progress }}%</span> <span class="font-semibold text-navy">{% trans "Completion Progress" %}</span>
</div> <span class="text-2xl font-bold text-blue">{{ progress }}%</span>
<div class="w-full bg-slate-200 rounded-full h-3 overflow-hidden"> </div>
<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="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>
</div> </div>
<!-- Pending Acknowledgements --> <!-- Pending Acknowledgements -->
{% if pending_acks %} {% if pending_acks %}
<div class="bg-white rounded-2xl shadow-sm border border-orange-200 overflow-hidden mb-6"> <div class="section-card mb-6 border-orange-200">
<div class="px-6 py-4 border-b border-orange-100 bg-gradient-to-r from-orange-50 to-transparent"> <div class="section-header border-orange-200 bg-gradient-to-r from-orange-50 to-transparent">
<h5 class="font-bold text-navy flex items-center gap-2"> <div class="section-icon bg-orange-100">
<i data-lucide="alert-circle" class="w-5 h-5 text-orange-500"></i> <i data-lucide="alert-circle" class="w-5 h-5 text-orange-600"></i>
{% trans "Pending Signatures" %} </div>
<span class="bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full text-sm">{{ pending_acks|length }}</span> <h2 class="text-lg font-bold text-navy">{% trans "Pending Signatures" %}</h2>
</h5> <span class="bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full text-sm">{{ pending_acks|length }}</span>
</div> </div>
<div class="divide-y divide-orange-50"> <div class="divide-y divide-orange-50">
{% for item in pending_acks %} {% for item in pending_acks %}
@ -134,13 +184,13 @@
<!-- Completed Acknowledgements --> <!-- Completed Acknowledgements -->
{% if signed_acks %} {% if signed_acks %}
<div class="bg-white rounded-2xl shadow-sm border border-emerald-200 overflow-hidden"> <div class="section-card border-emerald-200">
<div class="px-6 py-4 border-b border-emerald-100 bg-gradient-to-r from-emerald-50 to-transparent"> <div class="section-header border-emerald-200 bg-gradient-to-r from-emerald-50 to-transparent">
<h5 class="font-bold text-navy flex items-center gap-2"> <div class="section-icon bg-emerald-100">
<i data-lucide="check-circle" class="w-5 h-5 text-emerald-500"></i> <i data-lucide="check-circle" class="w-5 h-5 text-emerald-600"></i>
{% trans "Completed" %} </div>
<span class="bg-emerald-100 text-emerald-700 px-2 py-0.5 rounded-full text-sm">{{ signed_acks|length }}</span> <h2 class="text-lg font-bold text-navy">{% trans "Completed" %}</h2>
</h5> <span class="bg-emerald-100 text-emerald-700 px-2 py-0.5 rounded-full text-sm">{{ signed_acks|length }}</span>
</div> </div>
<div class="divide-y divide-emerald-50"> <div class="divide-y divide-emerald-50">
{% for item in signed_acks %} {% for item in signed_acks %}
@ -181,12 +231,14 @@
{% endif %} {% endif %}
{% if not pending_acks and not signed_acks %} {% if not pending_acks and not signed_acks %}
<div class="text-center py-12"> <div class="section-card">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4"> <div class="text-center py-12">
<i data-lucide="clipboard-check" class="w-8 h-8 text-slate-400"></i> <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> </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> </div>
{% endif %} {% endif %}
</div> </div>

View File

@ -3,157 +3,235 @@
{% block title %}{% trans "Action Plans" %} - PX360{% endblock %} {% 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 %} {% block content %}
<div class="mb-8"> <!-- Page Header -->
<div class="page-header-gradient">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div> <div>
<h1 class="text-3xl font-bold text-navy mb-2 flex items-center gap-3"> <h1 class="text-3xl font-bold mb-2 flex items-center gap-3">
<i data-lucide="check-circle-2" class="w-8 h-8 text-orange-500"></i> <i data-lucide="check-circle-2" class="w-8 h-8"></i>
{% trans "Action Plans" %} {% trans "Action Plans" %}
</h1> </h1>
<p class="text-slate">{% trans "Manage improvement action plans" %}</p> <p class="opacity-90">{% trans "Manage improvement action plans" %}</p>
</div> </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" %} <i data-lucide="plus" class="w-5 h-5"></i> {% trans "Create Action Plan" %}
</a> </a>
</div> </div>
</div> </div>
<!-- Filters --> <!-- Filters Section -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 mb-6"> <div class="section-card 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"> <div class="section-header">
<h3 class="font-bold text-gray-800 flex items-center gap-2"> <div class="section-icon section-icon-blue">
<i data-lucide="filter" class="w-5 h-5 text-navy"></i> <i data-lucide="filter" class="w-5 h-5"></i>
{% trans "Filters" %} </div>
</h3> <h3 class="font-bold text-gray-800">{% trans "Filters" %}</h3>
</div> </div>
<div class="p-6"> <div class="p-6">
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4"> <form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="md:col-span-2"> <div class="md:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-2">{% trans "Search" %}</label> <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 }}"> <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>
<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>
</div> </div>
</div> <div>
</form> <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>
</div> </div>
<!-- Action Plans Grid --> <!-- Action Plans Section -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="section-card">
{% for action in actions %} <div class="section-header">
<div class="bg-white rounded-2xl shadow-sm border border-gray-50 overflow-hidden hover:shadow-md transition"> <div class="section-icon section-icon-orange">
<div class="p-6"> <i data-lucide="check-square" class="w-5 h-5"></i>
<div class="flex justify-between items-start mb-4"> </div>
<div class="flex gap-2"> <h3 class="font-bold text-gray-800">{% trans "Action Plans" %}</h3>
{% if action.priority == 'high' %} <span class="ml-auto bg-gray-100 text-gray-600 px-3 py-1 rounded-full text-sm font-semibold">
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-red-100 text-red-600">{% trans "High" %}</span> {{ page_obj.paginator.count|default:actions|length }} {% trans "Total" %}
{% elif action.priority == 'medium' %} </span>
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-orange-100 text-orange-600">{% trans "Medium" %}</span> </div>
{% else %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-green-100 text-green-600">{% trans "Low" %}</span> <div class="p-6">
{% endif %} <!-- 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' %} <h3 class="font-bold text-gray-800 mb-2">{{ action.title }}</h3>
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-green-100 text-green-600">{% trans "Completed" %}</span> <p class="text-gray-500 text-sm mb-4 line-clamp-2">{{ action.description|truncatewords:20 }}</p>
{% 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> <div class="space-y-2 text-sm">
{% elif action.status == 'cancelled' %} {% if action.due_date %}
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-gray-100 text-gray-600">{% trans "Cancelled" %}</span> <div class="flex items-center gap-2 text-gray-600">
{% else %} <i data-lucide="calendar" class="w-4 h-4 text-[#005696]"></i>
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-yellow-100 text-yellow-600">{% trans "Pending" %}</span> <span>{% trans "Due:" %} {{ action.due_date|date:"M d, Y" }}</span>
{% endif %} </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> </div>
<span class="text-xs text-gray-400">{{ action.created_at|date:"M d, Y" }}</span>
</div> </div>
{% empty %}
<h3 class="font-bold text-gray-800 mb-2">{{ action.title }}</h3> <div class="col-span-full">
<p class="text-gray-500 text-sm mb-4 line-clamp-2">{{ action.description|truncatewords:20 }}</p> <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;">
<div class="space-y-2 text-sm"> <i data-lucide="check-square" class="w-8 h-8"></i>
{% if action.due_date %} </div>
<div class="flex items-center gap-2 text-gray-600"> <h3 class="text-xl font-bold text-gray-800 mb-2">{% trans "No Action Plans Found" %}</h3>
<i data-lucide="calendar" class="w-4 h-4"></i> <p class="text-gray-500 mb-6">{% trans "Get started by creating your first action plan" %}</p>
<span>{% trans "Due:" %} {{ action.due_date|date:"M d, Y" }}</span> <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>
</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 %} {% endif %}
{% if action.assigned_to %} <span class="px-4 py-2 bg-[#005696] text-white rounded-xl text-sm font-bold">
<div class="flex items-center gap-2 text-gray-600"> {% blocktrans with current=page_obj.number total=page_obj.paginator.num_pages %}Page {{ current }} of {{ total }}{% endblocktrans %}
<i data-lucide="user" class="w-4 h-4"></i> </span>
<span>{{ action.assigned_to.get_full_name|default:action.assigned_to.username }}</span>
</div> {% 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 %} {% endif %}
</div> </div>
</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 %} {% endif %}
</div> </div>
</div> </div>
{% endif %} {% endblock %}
{% endblock %}

View File

@ -3,100 +3,174 @@
{% block title %}{% trans "Sentiment Analysis Results" %}{% endblock %} {% 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 %} {% 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 --> <!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="page-header-gradient">
<div> <div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<h1 class="h3 mb-0">{% trans "Sentiment Analysis Results" %}</h1> <div class="flex items-center gap-3">
<p class="text-muted">{% trans "AI-powered sentiment analysis of text content" %}</p> <div class="section-icon bg-white/20">
</div> <i data-lucide="brain" class="w-6 h-6 text-white"></i>
<div> </div>
<a href="{% url 'ai_engine:analyze_text' %}" class="btn btn-primary"> <div>
<i class="bi bi-plus-circle"></i> {% trans "Analyze Text" %} <h1 class="text-2xl font-bold">{% trans "Sentiment Analysis Results" %}</h1>
</a> <p class="text-white/80 text-sm">{% trans "AI-powered sentiment analysis of text content" %}</p>
<a href="{% url 'ai_engine:sentiment_dashboard' %}" class="btn btn-outline-secondary"> </div>
<i class="bi bi-graph-up"></i> {% trans "Dashboard" %} </div>
</a> <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>
</div> </div>
<!-- Statistics Cards --> <!-- Statistics Cards -->
<div class="row mb-4"> <div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="col-md-3"> <div class="section-card">
<div class="card"> <div class="section-header">
<div class="card-body"> <div class="section-icon bg-blue-100">
<h6 class="text-muted mb-2">{% trans "Total Results" %}</h6> <i data-lucide="bar-chart-2" class="w-5 h-5 text-blue-600"></i>
<h3 class="mb-0">{{ stats.total }}</h3> </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>
</div> </div>
<div class="col-md-3"> <div class="section-card">
<div class="card border-success"> <div class="section-header">
<div class="card-body"> <div class="section-icon bg-emerald-100">
<h6 class="text-muted mb-2">{% trans "Positive" %}</h6> <i data-lucide="smile" class="w-5 h-5 text-emerald-600"></i>
<h3 class="mb-0 text-success"> </div>
{{ stats.positive }} <small>({{ stats.positive_pct }}%)</small> <div>
</h3> <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>
</div> </div>
<div class="col-md-3"> <div class="section-card">
<div class="card border-secondary"> <div class="section-header">
<div class="card-body"> <div class="section-icon bg-slate-100">
<h6 class="text-muted mb-2">{% trans "Neutral" %}</h6> <i data-lucide="meh" class="w-5 h-5 text-slate-600"></i>
<h3 class="mb-0 text-secondary"> </div>
{{ stats.neutral }} <small>({{ stats.neutral_pct }}%)</small> <div>
</h3> <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>
</div> </div>
<div class="col-md-3"> <div class="section-card">
<div class="card border-danger"> <div class="section-header">
<div class="card-body"> <div class="section-icon bg-red-100">
<h6 class="text-muted mb-2">{% trans "Negative" %}</h6> <i data-lucide="frown" class="w-5 h-5 text-red-600"></i>
<h3 class="mb-0 text-danger"> </div>
{{ stats.negative }} <small>({{ stats.negative_pct }}%)</small> <div>
</h3> <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>
</div> </div>
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="card mb-4"> <div class="section-card mb-8">
<div class="card-header"> <div class="section-header">
<h5 class="mb-0">{% trans "Filters" %}</h5> <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>
<div class="card-body"> <div class="p-6">
<form method="get" class="row g-3"> <form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="col-md-2"> <div>
<label class="block text-sm font-bold text-navy mb-2">{% trans "Sentiment" %}</label>
{{ filter_form.sentiment }} {{ filter_form.sentiment }}
</div> </div>
<div class="col-md-2"> <div>
<label class="block text-sm font-bold text-navy mb-2">{% trans "Language" %}</label>
{{ filter_form.language }} {{ filter_form.language }}
</div> </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 }} {{ filter_form.ai_service }}
</div> </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 }} {{ filter_form.min_confidence }}
</div> </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 }} {{ filter_form.search }}
</div> </div>
<div class="col-md-2"> <div class="flex items-end gap-2">
{{ filter_form.date_from }} <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">
</div> <i data-lucide="filter" class="w-4 h-4"></i> {% trans "Apply Filters" %}
<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" %}
</button> </button>
<a href="{% url 'ai_engine:sentiment_list' %}" class="btn btn-outline-secondary"> <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 class="bi bi-x-circle"></i> {% trans "Clear" %} <i data-lucide="x-circle" class="w-4 h-4"></i> {% trans "Clear" %}
</a> </a>
</div> </div>
</form> </form>
@ -104,89 +178,97 @@
</div> </div>
<!-- Results Table --> <!-- Results Table -->
<div class="card"> <div class="section-card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="section-header flex justify-between items-center">
<h5 class="mb-0">{% trans "Results" %} ({{ page_obj.paginator.count }})</h5> <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> <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="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="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> <option value="100" {% if request.GET.page_size == '100' %}selected{% endif %}>100 per page</option>
</select> </select>
</div> </div>
</div> </div>
<div class="table-responsive"> <div class="overflow-x-auto">
<table class="table table-hover mb-0"> <table class="w-full">
<thead> <thead class="bg-blue-50">
<tr> <tr>
<th>{% trans "Text" %}</th> <th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Text" %}</th>
<th>{% trans "Sentiment" %}</th> <th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Sentiment" %}</th>
<th>{% trans "Score" %}</th> <th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Score" %}</th>
<th>{% trans "Confidence" %}</th> <th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Confidence" %}</th>
<th>{% trans "Language" %}</th> <th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Language" %}</th>
<th>{% trans "Related To" %}</th> <th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% trans "Related To" %}</th>
<th>{% trans "Date" %}</th> <th class="px-6 py-4 text-left text-xs font-bold text-blue-800 uppercase tracking-wider">{% 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 "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-blue-50">
{% for result in results %} {% for result in results %}
<tr> <tr class="hover:bg-blue-50/50 transition">
<td> <td class="px-6 py-4">
<div class="text-truncate" style="max-width: 300px;" title="{{ result.text }}"> <div class="truncate max-w-xs" title="{{ result.text }}">
{{ result.text }} {{ result.text }}
</div> </div>
</td> </td>
<td> <td class="px-6 py-4">
{% if result.sentiment == 'positive' %} {% 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' %} {% 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 %} {% 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 %} {% endif %}
</td> </td>
<td> <td class="px-6 py-4">
<span class="badge bg-light text-dark">{{ result.sentiment_score|floatformat:2 }}</span> <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>
<td> <td class="px-6 py-4">
<div class="progress" style="height: 20px;"> <div class="progress-bar">
<div class="progress-bar" role="progressbar" <div class="progress-bar-fill" style="width: {{ result.confidence|floatformat:0 }}%"></div>
style="width: {{ result.confidence|floatformat:0 }}%"
aria-valuenow="{{ result.confidence|floatformat:0 }}"
aria-valuemin="0" aria-valuemax="100">
{{ result.confidence|floatformat:0 }}%
</div>
</div> </div>
<span class="text-xs text-slate mt-1">{{ result.confidence|floatformat:0 }}%</span>
</td> </td>
<td> <td class="px-6 py-4">
{% if result.language == 'ar' %} {% 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 %} {% 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 %} {% endif %}
</td> </td>
<td> <td class="px-6 py-4">
{% if result.content_type %} {% if result.content_type %}
<small class="text-muted">{{ result.content_type.model }}</small> <small class="text-slate">{{ result.content_type.model }}</small>
{% else %} {% else %}
<small class="text-muted">-</small> <small class="text-slate">-</small>
{% endif %} {% endif %}
</td> </td>
<td> <td class="px-6 py-4">
<small>{{ result.created_at|date:"Y-m-d H:i" }}</small> <small class="text-slate">{{ result.created_at|date:"Y-m-d H:i" }}</small>
</td> </td>
<td> <td class="px-6 py-4">
<a href="{% url 'ai_engine:sentiment_detail' result.id %}" <a href="{% url 'ai_engine:sentiment_detail' result.id %}"
class="btn btn-sm btn-outline-primary"> 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 class="bi bi-eye"></i> <i data-lucide="eye" class="w-4 h-4"></i>
</a> </a>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="8" class="text-center text-muted py-4"> <td colspan="8" class="text-center py-12">
{% trans "No sentiment results found." %} <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> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -196,30 +278,38 @@
<!-- Pagination --> <!-- Pagination -->
{% if page_obj.has_other_pages %} {% if page_obj.has_other_pages %}
<div class="card-footer"> <div class="px-6 py-4 border-t border-slate-100">
<nav aria-label="Page navigation"> <nav class="flex justify-center">
<ul class="pagination justify-content-center mb-0"> <ul class="flex gap-1">
{% if page_obj.has_previous %} {% if page_obj.has_previous %}
<li class="page-item"> <li>
<a class="page-link" href="?page=1{% for key, value in filters.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">{% trans "First" %}</a> <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>
<li class="page-item"> <li>
<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> <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> </li>
{% endif %} {% endif %}
<li class="page-item active"> <li>
<span class="page-link"> <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 }} {% trans "Page" %} {{ page_obj.number }} {% trans "of" %} {{ page_obj.paginator.num_pages }}
</span> </span>
</li> </li>
{% if page_obj.has_next %} {% if page_obj.has_next %}
<li class="page-item"> <li>
<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> <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>
<li class="page-item"> <li>
<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> <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> </li>
{% endif %} {% endif %}
</ul> </ul>
@ -228,4 +318,10 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %} {% endblock %}

View File

@ -4,20 +4,71 @@
{% block title %}KPI Definitions - PX360{% endblock %} {% 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 %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4"> <!-- Page Header -->
<div> <div class="page-header-gradient">
<h2 class="mb-1"> <div class="d-flex justify-content-between align-items-center">
<i class="bi bi-speedometer text-primary me-2"></i> <div>
KPI Definitions <h2 class="mb-1">
</h2> <i data-lucide="gauge" class="me-2" style="width: 28px; height: 28px; vertical-align: text-bottom;"></i>
<p class="text-muted mb-0">Manage key performance indicators</p> KPI Definitions
</h2>
<p class="mb-0 opacity-75">Manage key performance indicators</p>
</div>
</div> </div>
</div> </div>
<div class="card"> <!-- Table Section -->
<div class="card-body p-0"> <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"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
@ -73,7 +124,7 @@
{% empty %} {% empty %}
<tr> <tr>
<td colspan="6" class="text-center py-5"> <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> <p class="text-muted mt-3">No KPIs defined</p>
</td> </td>
</tr> </tr>
@ -90,7 +141,7 @@
{% if page_obj.has_previous %} {% if page_obj.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}"> <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> </a>
</li> </li>
{% endif %} {% endif %}
@ -108,7 +159,7 @@
{% if page_obj.has_next %} {% if page_obj.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}"> <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> </a>
</li> </li>
{% endif %} {% endif %}
@ -117,13 +168,3 @@
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% 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

@ -5,6 +5,42 @@
{% block extra_css %} {% block extra_css %}
<style> <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-completed { background-color: #dcfce7; color: #166534; }
.status-pending { background-color: #fef9c3; color: #854d0e; } .status-pending { background-color: #fef9c3; color: #854d0e; }
.status-generating { background-color: #e0f2fe; color: #075985; } .status-generating { background-color: #e0f2fe; color: #075985; }
@ -16,29 +52,29 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Header --> <!-- Page Header -->
<header class="mb-6"> <div class="page-header-gradient">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <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> <i data-lucide="bar-chart-3" class="w-7 h-7"></i>
{% trans "KPI Reports" %} {% trans "KPI Reports" %}
</h1> </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>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="relative group"> <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...' %}" <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> </div>
<a href="{% url 'analytics:kpi_report_generate' %}" <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" %} <i data-lucide="plus" class="w-4 h-4"></i> {% trans "Generate Report" %}
</a> </a>
</div> </div>
</div> </div>
</header> </div>
<!-- Statistics Cards --> <!-- Statistics Cards -->
<div class="grid grid-cols-4 gap-6 mb-6"> <div class="grid grid-cols-4 gap-6 mb-6">
@ -80,222 +116,233 @@
</div> </div>
</div> </div>
<!-- Filter Tabs --> <!-- Filter Section -->
<div class="bg-white px-6 py-4 rounded-t-2xl border-b flex items-center justify-between"> <div class="section-card mb-6">
<div class="flex items-center gap-3"> <div class="section-header">
<a href="?" class="filter-btn px-4 py-1.5 rounded-full text-xs font-semibold transition {% if not filters.status %}active{% endif %}"> <div class="section-icon" style="background: linear-gradient(135deg, #005696, #007bbd);">
{% trans "All Reports" %} <i data-lucide="filter" style="width: 20px; height: 20px; color: white;"></i>
</a> </div>
<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 %}"> <h5 class="mb-0 fw-bold">{% trans "Filters" %}</h5>
{% trans "Completed" %} <button onclick="toggleFilters()" class="ms-auto text-slate hover:text-navy transition p-2 rounded-lg hover:bg-white">
</a> <i data-lucide="chevron-up" id="filterToggleIcon" class="w-5 h-5"></i>
<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" %}
</button> </button>
</div> </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> <!-- Filter Tabs -->
</p> <div class="px-6 py-4 border-b flex items-center justify-between">
</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 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) --> <!-- Advanced Filters (Hidden by default) -->
<div id="advancedFilters" class="hidden bg-slate-50 px-6 py-4 border-b"> <div id="advancedFilters" class="hidden px-6 py-4 bg-slate-50 border-b">
<form method="get" class="flex flex-wrap gap-4"> <form method="get" class="flex flex-wrap gap-4">
{% if filters.status %} {% if filters.status %}
<input type="hidden" name="status" value="{{ filters.status }}"> <input type="hidden" name="status" value="{{ filters.status }}">
{% endif %} {% endif %}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label class="text-xs font-bold text-slate uppercase">{% trans "Report Type" %}</label> <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"> <select name="report_type" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
<option value="">{% trans "All Types" %}</option> <option value="">{% trans "All Types" %}</option>
{% for type_value, type_label in report_types %} {% for type_value, type_label in report_types %}
<option value="{{ type_value }}" {% if filters.report_type == type_value %}selected{% endif %}> <option value="{{ type_value }}" {% if filters.report_type == type_value %}selected{% endif %}>
{{ type_label }} {{ type_label }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label class="text-xs font-bold text-slate uppercase">{% trans "Year" %}</label> <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"> <select name="year" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
<option value="">{% trans "All Years" %}</option> <option value="">{% trans "All Years" %}</option>
{% for y in years %} {% for y in years %}
<option value="{{ y }}" {% if filters.year == y|stringformat:'s' %}selected{% endif %}> <option value="{{ y }}" {% if filters.year == y|stringformat:'s' %}selected{% endif %}>
{{ y }} {{ y }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label class="text-xs font-bold text-slate uppercase">{% trans "Month" %}</label> <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"> <select name="month" class="px-3 py-1.5 bg-white border rounded-lg text-xs">
<option value="">{% trans "All Months" %}</option> <option value="">{% trans "All Months" %}</option>
{% for m, m_label in months %} {% for m, m_label in months %}
<option value="{{ m }}" {% if filters.month == m|stringformat:'s' %}selected{% endif %}> <option value="{{ m }}" {% if filters.month == m|stringformat:'s' %}selected{% endif %}>
{{ m_label }} {{ m_label }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<button type="submit" class="px-4 py-1.5 bg-navy text-white rounded-lg text-xs font-bold">{% trans "Apply" %}</button> <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> <a href="?" class="px-4 py-1.5 border rounded-lg text-xs font-semibold text-slate hover:bg-white">{% trans "Clear" %}</a>
</form> </form>
</div>
</div> </div>
<!-- Reports Grid --> <!-- Reports Grid -->
{% if reports %} {% if reports %}
<div class="bg-white rounded-b-2xl shadow-sm border p-6"> <div class="section-card">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="p-6">
{% for report in reports %} <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<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" {% for report in reports %}
onclick="window.location.href='{% url 'analytics:kpi_report_detail' report.id %}'"> <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"
<!-- Header --> onclick="window.location.href='{% url 'analytics:kpi_report_detail' report.id %}'">
<div class="flex items-start justify-between mb-3"> <!-- Header -->
<div class="flex items-center gap-2"> <div class="flex items-start justify-between mb-3">
<span class="px-2 py-1 text-xs font-bold rounded bg-navy text-white"> <div class="flex items-center gap-2">
{{ report.kpi_id }} <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>
<span class="text-xs text-slate">{{ report.report_period_display }}</span>
</div> </div>
<span class="px-2 py-0.5 text-xs rounded-full font-semibold uppercase
{% if report.status == 'completed' %}status-completed <!-- Title -->
{% elif report.status == 'failed' %}status-failed <h3 class="font-semibold text-navy mb-2 line-clamp-2 group-hover:text-blue transition-colors">{{ report.indicator_title }}</h3>
{% elif report.status == 'generating' %}status-generating
{% else %}status-pending{% endif %}"> <!-- Hospital -->
{{ report.get_status_display }} <p class="text-sm text-slate mb-4 flex items-center gap-1">
</span> <i data-lucide="building-2" class="w-3 h-3 inline"></i>
</div> {{ report.hospital.name }}
</p>
<!-- Title -->
<h3 class="font-semibold text-navy mb-2 line-clamp-2 group-hover:text-blue transition-colors">{{ report.indicator_title }}</h3> <!-- Results -->
<div class="grid grid-cols-3 gap-2 mb-4">
<!-- Hospital --> <div class="bg-light rounded-lg p-3 text-center">
<p class="text-sm text-slate mb-4 flex items-center gap-1"> <div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Target" %}</div>
<i data-lucide="building-2" class="w-3 h-3 inline"></i> <div class="text-lg font-black text-navy">{{ report.target_percentage }}%</div>
{{ report.hospital.name }} </div>
</p> <div class="bg-light rounded-lg p-3 text-center">
<div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Result" %}</div>
<!-- Results --> <div class="text-lg font-black {% if report.overall_result >= report.target_percentage %}text-green-600{% else %}text-red-600{% endif %}">
<div class="grid grid-cols-3 gap-2 mb-4"> {{ report.overall_result }}%
<div class="bg-light rounded-lg p-3 text-center"> </div>
<div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Target" %}</div> </div>
<div class="text-lg font-black text-navy">{{ report.target_percentage }}%</div> <div class="bg-light rounded-lg p-3 text-center">
</div> <div class="text-[10px] font-bold text-slate uppercase mb-1">{% trans "Cases" %}</div>
<div class="bg-light rounded-lg p-3 text-center"> <div class="text-lg font-black text-navy">{{ report.total_denominator }}</div>
<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> </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> <!-- Actions -->
<div class="text-lg font-black text-navy">{{ report.total_denominator }}</div> <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>
</div> </div>
{% endfor %}
<!-- Actions --> </div>
<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 %}'" <!-- Pagination -->
class="flex-1 btn-primary text-center text-sm flex items-center justify-center gap-2"> {% if page_obj.has_other_pages %}
<i data-lucide="eye" class="w-4 h-4"></i> {% trans "View" %} <div class="bg-slate-50 px-8 py-4 flex items-center justify-between border-t mt-6 rounded-lg">
</button> <div class="flex items-center gap-4">
<button onclick="event.stopPropagation(); window.location.href='{% url 'analytics:kpi_report_pdf' report.id %}'" <span class="text-xs text-slate font-medium">
class="btn-secondary px-3" title="{% trans 'Download PDF' %}"> {% 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" %}
<i data-lucide="file-down" class="w-4 h-4"></i> </span>
</button> <!-- Page Size Selector -->
{% if report.status == 'failed' or report.status == 'pending' %} <form method="get" class="flex items-center gap-2" id="pageSizeForm">
<form method="post" action="{% url 'analytics:kpi_report_regenerate' report.id %}" {% for key, value in request.GET.items %}
class="inline" onclick="event.stopPropagation(); return confirm('{% trans "Regenerate this report?" %}')"> {% if key != 'page_size' and key != 'page' %}
{% csrf_token %} <input type="hidden" name="{{ key }}" value="{{ value }}">
<button type="submit" class="btn-secondary px-3" title="{% trans 'Regenerate' %}"> {% endif %}
<i data-lucide="refresh-cw" class="w-4 h-4"></i> {% endfor %}
</button> <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> </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 %} {% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endif %}
</div> </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> </div>
{% else %} {% 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> <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> <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> <p class="text-slate mb-6">{% trans "Generate your first KPI report to get started." %}</p>
@ -311,7 +358,14 @@
<script> <script>
function toggleFilters() { function toggleFilters() {
const filters = document.getElementById('advancedFilters'); const filters = document.getElementById('advancedFilters');
const icon = document.getElementById('filterToggleIcon');
filters.classList.toggle('hidden'); 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 // Search functionality
@ -324,4 +378,4 @@ document.getElementById('searchInput')?.addEventListener('keypress', function(e)
} }
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -3,29 +3,73 @@
{% block title %}{% trans "Appreciation" %} - PX360{% endblock %} {% 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 %} {% block content %}
<!-- Header --> <!-- Page Header Gradient -->
<header class="mb-6"> <div class="page-header-gradient">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <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="heart" class="w-7 h-7 text-rose-500"></i> <i data-lucide="heart" class="w-7 h-7"></i>
{% trans "Appreciation" %} {% trans "Appreciation" %}
</h1> </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> </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" %} <i data-lucide="send" class="w-4 h-4"></i> {% trans "Send Appreciation" %}
</a> </a>
</div> </div>
</header> </div>
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <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="flex items-center gap-4">
<div class="p-3 bg-blue-50 rounded-xl"> <div class="section-icon bg-blue-100">
<i data-lucide="inbox" class="w-6 h-6 text-blue-500"></i> <i data-lucide="inbox" class="w-6 h-6 text-blue-600"></i>
</div> </div>
<div> <div>
<p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Received" %}</p> <p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Received" %}</p>
@ -33,10 +77,10 @@
</div> </div>
</div> </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="flex items-center gap-4">
<div class="p-3 bg-green-50 rounded-xl"> <div class="section-icon bg-green-100">
<i data-lucide="send" class="w-6 h-6 text-green-500"></i> <i data-lucide="send" class="w-6 h-6 text-green-600"></i>
</div> </div>
<div> <div>
<p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Sent" %}</p> <p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Sent" %}</p>
@ -44,10 +88,10 @@
</div> </div>
</div> </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="flex items-center gap-4">
<div class="p-3 bg-amber-50 rounded-xl"> <div class="section-icon bg-amber-100">
<i data-lucide="award" class="w-6 h-6 text-amber-500"></i> <i data-lucide="award" class="w-6 h-6 text-amber-600"></i>
</div> </div>
<div> <div>
<p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Badges Earned" %}</p> <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>
</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="flex items-center gap-4">
<div class="p-3 bg-purple-50 rounded-xl"> <div class="section-icon bg-purple-100">
<i data-lucide="trophy" class="w-6 h-6 text-purple-500"></i> <i data-lucide="trophy" class="w-6 h-6 text-purple-600"></i>
</div> </div>
<div> <div>
<p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Leaderboard" %}</p> <p class="text-xs font-bold text-slate uppercase tracking-wider mb-1">{% trans "Leaderboard" %}</p>
@ -73,10 +117,10 @@
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<a href="{% url 'appreciation:leaderboard_view' %}" class="group"> <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="flex items-center gap-4">
<div class="p-3 bg-amber-50 rounded-xl group-hover:bg-amber-100 transition"> <div class="section-icon bg-amber-100 group-hover:bg-amber-200 transition">
<i data-lucide="trophy" class="w-6 h-6 text-amber-500"></i> <i data-lucide="trophy" class="w-6 h-6 text-amber-600"></i>
</div> </div>
<div> <div>
<h3 class="font-bold text-navy">{% trans "Leaderboard" %}</h3> <h3 class="font-bold text-navy">{% trans "Leaderboard" %}</h3>
@ -86,10 +130,10 @@
</div> </div>
</a> </a>
<a href="{% url 'appreciation:my_badges_view' %}" class="group"> <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="flex items-center gap-4">
<div class="p-3 bg-blue-50 rounded-xl group-hover:bg-blue-100 transition"> <div class="section-icon bg-blue-100 group-hover:bg-blue-200 transition">
<i data-lucide="award" class="w-6 h-6 text-blue-500"></i> <i data-lucide="award" class="w-6 h-6 text-blue-600"></i>
</div> </div>
<div> <div>
<h3 class="font-bold text-navy">{% trans "My Badges" %}</h3> <h3 class="font-bold text-navy">{% trans "My Badges" %}</h3>
@ -99,10 +143,10 @@
</div> </div>
</a> </a>
<a href="{% url 'appreciation:appreciation_send' %}" class="group"> <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="flex items-center gap-4">
<div class="p-3 bg-green-50 rounded-xl group-hover:bg-green-100 transition"> <div class="section-icon bg-green-100 group-hover:bg-green-200 transition">
<i data-lucide="send" class="w-6 h-6 text-green-500"></i> <i data-lucide="send" class="w-6 h-6 text-green-600"></i>
</div> </div>
<div> <div>
<h3 class="font-bold text-navy">{% trans "Send Appreciation" %}</h3> <h3 class="font-bold text-navy">{% trans "Send Appreciation" %}</h3>
@ -114,12 +158,12 @@
</div> </div>
<!-- Filter Bar --> <!-- Filter Bar -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 mb-6"> <div class="section-card 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"> <div class="section-header">
<h3 class="font-bold text-navy flex items-center gap-2"> <div class="section-icon bg-navy/10">
<i data-lucide="filter" class="w-5 h-5 text-navy"></i> <i data-lucide="filter" class="w-5 h-5 text-navy"></i>
{% trans "Filters" %} </div>
</h3> <h3 class="font-bold text-navy">{% trans "Filters" %}</h3>
</div> </div>
<div class="p-5"> <div class="p-5">
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4"> <form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
@ -180,12 +224,12 @@
</div> </div>
<!-- Appreciations List --> <!-- Appreciations List -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 overflow-hidden"> <div class="section-card">
<div class="px-6 py-4 border-b-2 border-slate-200 bg-gradient-to-r from-slate-50 to-slate-100"> <div class="section-header">
<h3 class="font-bold text-navy flex items-center gap-2"> <div class="section-icon bg-rose-100">
<i data-lucide="heart" class="w-5 h-5 text-rose-500"></i> <i data-lucide="heart" class="w-5 h-5 text-rose-600"></i>
{% trans "Appreciations" %} </div>
</h3> <h3 class="font-bold text-navy">{% trans "Appreciations" %}</h3>
</div> </div>
<div class="p-6"> <div class="p-6">
{% if page_obj %} {% if page_obj %}

View File

@ -3,227 +3,304 @@
{% block title %}{% trans "Send Appreciation" %} - {% endblock %} {% 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 %} {% block content %}
<div class="container-fluid py-4"> <div class="p-4">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4"> <nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb"> <ol class="flex flex-wrap items-center gap-2 text-sm">
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li> <li><a href="{% url 'appreciation:appreciation_list' %}" class="text-blue-600 hover:text-blue-800">{% trans "Appreciation" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% trans "Send Appreciation" %}</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> </ol>
</nav> </nav>
<!-- Send Appreciation Form --> <!-- Header -->
<div class="row"> <div class="page-header-gradient">
<div class="col-lg-8"> <h1 class="text-2xl font-bold flex items-center gap-3">
<div class="card shadow-sm"> <i data-lucide="send" class="w-6 h-6"></i>
<div class="card-header bg-primary text-white"> {% trans "Send Appreciation" %}
<h4 class="mb-0"> </h1>
<i class="bi-send me-2"></i> </div>
{% 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">
<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>
<!-- Recipient --> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="mb-3"> <!-- Send Appreciation Form -->
<label for="recipient_id" class="form-label"> <div class="lg:col-span-2">
{% trans "Recipient" %} <span class="text-danger">*</span> <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> </label>
<select class="form-select" id="recipient_id" name="recipient_id" required disabled> <select class="form-select" id="recipient_type" name="recipient_type" required>
<option value="">-- {% trans "Select Recipient" %} --</option> <option value="user">{% trans "User" %}</option>
</select> <option value="physician">{% trans "Physician" %}</option>
<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> </select>
</div> </div>
<div>
<!-- Message (English) --> <input type="hidden" name="hospital_id" value="{{ current_hospital.id }}">
<div class="mb-3"> <label class="form-label">{% trans "Hospital" %}</label>
<label for="message_en" class="form-label"> <input type="text" class="form-control" value="{{ current_hospital.name }}" readonly>
{% 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> </div>
</div>
<!-- Message (Arabic) --> <!-- Recipient -->
<div class="mb-3"> <div class="mb-4">
<label for="message_ar" class="form-label">{% trans "Message (Arabic)" %}</label> <label for="recipient_id" class="form-label">
<textarea {% trans "Recipient" %} <span class="text-red-500">*</span>
class="form-control" </label>
id="message_ar" <select class="form-select" id="recipient_id" name="recipient_id" required disabled>
name="message_ar" <option value="">-- {% trans "Select Recipient" %} --</option>
rows="4" </select>
dir="rtl" <p class="text-sm text-gray-500 mt-1" id="recipientHelp">{% trans "Select a hospital first" %}</p>
placeholder="{% trans 'اكتب رسالة التقدير هنا...' %}" </div>
></textarea>
<div class="form-text">{% trans "Optional: Appreciation message in Arabic" %}</div>
</div>
<!-- Visibility --> <!-- Department (Optional) -->
<div class="mb-3"> <div class="mb-4">
<label for="visibility" class="form-label">{% trans "Visibility" %}</label> <label for="department_id" class="form-label">{% trans "Department" %}</label>
<select class="form-select" id="visibility" name="visibility"> <select class="form-select" id="department_id" name="department_id">
{% for choice in visibility_choices %} <option value="">-- {% trans "Select Department" %} --</option>
<option value="{{ choice.0 }}" {% if choice.0 == 'private' %}selected{% endif %}> </select>
{{ choice.1 }} <p class="text-sm text-gray-500 mt-1">{% trans "Optional: Select if related to a specific department" %}</p>
</option> </div>
{% endfor %}
</select>
</div>
<!-- Anonymous --> <!-- Category -->
<div class="mb-4"> <div class="mb-4">
<div class="form-check"> <label for="category_id" class="form-label">{% trans "Category" %}</label>
<input <select class="form-select" id="category_id" name="category_id">
class="form-check-input" <option value="">-- {% trans "Select Category" %} --</option>
type="checkbox" {% for category in categories %}
id="is_anonymous" <option value="{{ category.id }}">
name="is_anonymous" <i class="{{ category.icon }}"></i> {{ category.name_en }}
> </option>
<label class="form-check-label" for="is_anonymous"> {% 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" %} {% trans "Send anonymously" %}
</label> </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> </div>
</div>
<!-- Submit Button --> <!-- Submit Button -->
<div class="d-grid gap-2 d-md-flex justify-content-md-end"> <div class="flex flex-wrap justify-end gap-3">
<a href="{% url 'appreciation:appreciation_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'appreciation:appreciation_list' %}" class="btn-secondary">
<i class="bi-x me-2"></i> <i data-lucide="x" class="w-4 h-4"></i>
{% trans "Cancel" %} {% trans "Cancel" %}
</a> </a>
<button type="submit" class="btn btn-success"> <button type="submit" class="btn-primary">
<i class="bi-send me-2"></i> <i data-lucide="send" class="w-4 h-4"></i>
{% trans "Send Appreciation" %} {% trans "Send Appreciation" %}
</button> </button>
</div> </div>
</form> </form>
</div>
</div> </div>
</div> </div>
<!-- Sidebar --> <!-- Sidebar -->
<div class="col-lg-4"> <div class="lg:col-span-1">
<!-- Tips --> <!-- Tips -->
<div class="card shadow-sm mb-4"> <div class="form-section">
<div class="card-header bg-light"> <h6 class="font-semibold text-gray-800 mb-4 flex items-center gap-2">
<h6 class="card-title mb-0"> <i data-lucide="lightbulb" class="w-4 h-4 text-yellow-500"></i>
<i class="bi-lightbulb me-2"></i> {% trans "Tips for Writing Appreciation" %}
{% trans "Tips for Writing Appreciation" %} </h6>
</h6> <ul class="space-y-2 text-sm">
</div> <li class="flex items-start gap-2">
<div class="card-body"> <i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
<ul class="list-unstyled mb-0"> {% trans "Be specific about what you appreciate" %}
<li class="mb-2"> </li>
<i class="bi-check text-success me-2"></i> <li class="flex items-start gap-2">
{% trans "Be specific about what you appreciate" %} <i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
</li> {% trans "Use the person's name when addressing them" %}
<li class="mb-2"> </li>
<i class="bi-check text-success me-2"></i> <li class="flex items-start gap-2">
{% trans "Use the person's name when addressing them" %} <i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
</li> {% trans "Mention the impact of their actions" %}
<li class="mb-2"> </li>
<i class="bi-check text-success me-2"></i> <li class="flex items-start gap-2">
{% trans "Mention the impact of their actions" %} <i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
</li> {% trans "Be sincere and authentic" %}
<li class="mb-2"> </li>
<i class="bi-check text-success me-2"></i> <li class="flex items-start gap-2">
{% trans "Be sincere and authentic" %} <i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
</li> {% trans "Keep it positive and uplifting" %}
<li> </li>
<i class="bi-check text-success me-2"></i> </ul>
{% trans "Keep it positive and uplifting" %}
</li>
</ul>
</div>
</div> </div>
<!-- Visibility Guide --> <!-- Visibility Guide -->
<div class="card shadow-sm"> <div class="form-section">
<div class="card-header bg-light"> <h6 class="font-semibold text-gray-800 mb-4 flex items-center gap-2">
<h6 class="card-title mb-0"> <i data-lucide="info" class="w-4 h-4 text-blue-500"></i>
<i class="bi-info-circle me-2"></i> {% trans "Visibility Levels" %}
{% trans "Visibility Levels" %} </h6>
</h6> <ul class="space-y-3 text-sm">
</div> <li>
<div class="card-body"> <strong class="text-gray-800">{% trans "Private:" %}</strong>
<ul class="list-unstyled mb-0"> <p class="text-gray-500 text-xs">
<li class="mb-2"> {% trans "Only you and the recipient can see this appreciation" %}
<strong>{% trans "Private:" %}</strong> </p>
<p class="small text-muted mb-0"> </li>
{% trans "Only you and the recipient can see this appreciation" %} <li>
</p> <strong class="text-gray-800">{% trans "Department:" %}</strong>
</li> <p class="text-gray-500 text-xs">
<li class="mb-2"> {% trans "Visible to everyone in the selected department" %}
<strong>{% trans "Department:" %}</strong> </p>
<p class="small text-muted mb-0"> </li>
{% trans "Visible to everyone in the selected department" %} <li>
</p> <strong class="text-gray-800">{% trans "Hospital:" %}</strong>
</li> <p class="text-gray-500 text-xs">
<li class="mb-2"> {% trans "Visible to everyone in the selected hospital" %}
<strong>{% trans "Hospital:" %}</strong> </p>
<p class="small text-muted mb-0"> </li>
{% trans "Visible to everyone in the selected hospital" %} <li>
</p> <strong class="text-gray-800">{% trans "Public:" %}</strong>
</li> <p class="text-gray-500 text-xs">
<li> {% trans "Visible to all PX360 users" %}
<strong>{% trans "Public:" %}</strong> </p>
<p class="small text-muted mb-0"> </li>
{% trans "Visible to all PX360 users" %} </ul>
</p>
</li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,209 +3,285 @@
{% block title %}{% if form.instance.pk %}{% trans "Edit Badge" %}{% else %}{% trans "Add Badge" %}{% endif %} - {% endblock %} {% 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 %} {% block content %}
<div class="container-fluid py-4"> <div class="p-4">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4"> <nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb"> <ol class="flex flex-wrap items-center gap-2 text-sm">
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li> <li><a href="{% url 'appreciation:appreciation_list' %}" class="text-blue-600 hover:text-blue-800">{% trans "Appreciation" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'appreciation:badge_list' %}">{% trans "Badges" %}</a></li> <li class="text-gray-400"><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li class="breadcrumb-item active" aria-current="page">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Add" %}{% endif %}</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> </ol>
</nav> </nav>
<!-- Form --> <!-- Header -->
<div class="row"> <div class="page-header-gradient">
<div class="col-lg-8"> <h1 class="text-2xl font-bold flex items-center gap-3">
<div class="card shadow-sm"> <i data-lucide="trophy" class="w-6 h-6"></i>
<div class="card-header bg-warning text-dark"> {% if form.instance.pk %}{% trans "Edit Badge" %}{% else %}{% trans "Add Badge" %}{% endif %}
<h4 class="mb-0"> </h1>
<i class="bi bi-trophy me-2"></i> </div>
{% if form.instance.pk %}{% trans "Edit Badge" %}{% else %}{% trans "Add Badge" %}{% endif %}
</h4> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
</div> <!-- Form -->
<div class="card-body"> <div class="lg:col-span-2">
<form method="post"> <div class="form-section">
{% csrf_token %} <form method="post">
{% csrf_token %}
<!-- Name (English) -->
<div class="mb-3"> <!-- Name (English) -->
<label for="id_name_en" class="form-label"> <div class="mb-4">
{% trans "Name (English)" %} <span class="text-danger">*</span> <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> </label>
<input
type="text"
class="form-control"
id="id_name_en"
name="name_en"
value="{{ form.name_en.value|default:'' }}"
required
>
</div> </div>
</div>
<!-- Name (Arabic) --> <!-- Buttons -->
<div class="mb-3"> <div class="flex flex-wrap gap-3">
<label for="id_name_ar" class="form-label"> <a href="{% url 'appreciation:badge_list' %}" class="btn-secondary">
{% trans "Name (Arabic)" %} <span class="text-danger">*</span> <i data-lucide="x" class="w-4 h-4"></i>
</label> {% trans "Cancel" %}
<input </a>
type="text" <button type="submit" class="btn-primary">
class="form-control" <i data-lucide="save" class="w-4 h-4"></i>
id="id_name_ar" {% if form.instance.pk %}{% trans "Update" %}{% else %}{% trans "Create" %}{% endif %}
name="name_ar" </button>
value="{{ form.name_ar.value|default:'' }}" </div>
dir="rtl" </form>
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>
</div> </div>
</div> </div>
<!-- Sidebar --> <!-- Sidebar -->
<div class="col-lg-4"> <div class="lg:col-span-1">
<!-- Icon Preview --> <!-- Icon Preview -->
<div class="card shadow-sm mb-4"> <div class="form-section text-center">
<div class="card-header bg-light"> <h6 class="font-semibold text-gray-800 mb-4">{% trans "Badge Preview" %}</h6>
<h6 class="card-title mb-0">{% trans "Badge Preview" %}</h6> <div class="mb-4">
</div> <i id="icon-preview" class="{{ form.icon.value|default:'bi-trophy' }} fa-4x text-yellow-500"></i>
<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> </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> </div>
<!-- Criteria Information --> <!-- Criteria Information -->
<div class="card shadow-sm"> <div class="form-section">
<div class="card-header bg-light"> <h6 class="font-semibold text-gray-800 mb-4 flex items-center gap-2">
<h6 class="card-title mb-0"> <i data-lucide="info" class="w-4 h-4 text-blue-500"></i>
<i class="bi bi-info-circle me-2"></i> {% trans "About Badge Criteria" %}
{% trans "About Badge Criteria" %} </h6>
</h6> <ul class="space-y-3 text-sm">
</div> <li>
<div class="card-body"> <strong class="text-gray-800">{% trans "Count:" %}</strong>
<ul class="list-unstyled mb-0 small"> <p class="text-gray-500 text-xs">
<li class="mb-2"> {% trans "Badge is earned after receiving the specified number of appreciations" %}
<strong>{% trans "Count:" %}</strong> </p>
<p class="mb-0 text-muted"> </li>
{% trans "Badge is earned after receiving the specified number of appreciations" %} <li>
</p> <strong class="text-gray-800">{% trans "Tips:" %}</strong>
</li> <ul class="text-gray-500 text-xs mt-1 space-y-1">
<li> <li>{% trans "Set achievable criteria to encourage participation" %}</li>
<strong>{% trans "Tips:" %}</strong> <li>{% trans "Use descriptive names and icons" %}</li>
<ul class="mb-0 ps-3 text-muted"> <li>{% trans "Create badges for different achievement levels" %}</li>
<li>{% trans "Set achievable criteria to encourage participation" %}</li> <li>{% trans "Deactivate badges instead of deleting to preserve history" %}</li>
<li>{% trans "Use descriptive names and icons" %}</li> </ul>
<li>{% trans "Create badges for different achievement levels" %}</li> </li>
<li>{% trans "Deactivate badges instead of deleting to preserve history" %}</li> </ul>
</ul>
</li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,137 +1,202 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% load i18n static %} {% 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 %} {% block content %}
<div class="container-fluid py-4"> <!-- Page Header Gradient -->
<!-- Breadcrumb --> <div class="page-header-gradient">
<nav aria-label="breadcrumb" class="mb-4"> <div class="flex justify-between items-start">
<ol class="breadcrumb"> <div>
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li> <h1 class="text-2xl font-bold flex items-center gap-3">
<li class="breadcrumb-item active" aria-current="page">{% trans "Badges" %}</li> <i data-lucide="award" class="w-7 h-7"></i>
</ol> {% trans "Appreciation Badges" %}
</nav> </h1>
<p class="text-sm opacity-90 mt-1">{% trans "Create and manage badges to recognize achievements" %}</p>
<!-- Page Header --> </div>
<div class="d-flex justify-content-between align-items-center mb-4"> <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">
<h2> <i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add Badge" %}
<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" %}
</a> </a>
</div> </div>
</div>
<!-- Badges List --> <!-- Breadcrumb -->
<div class="card shadow-sm"> <nav class="mb-6 text-sm">
<div class="card-body"> <ol class="flex items-center gap-2 text-slate">
{% if badges %} <li><a href="{% url 'appreciation:appreciation_list' %}" class="hover:text-navy transition">{% trans "Appreciation" %}</a></li>
<div class="row"> <li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
{% for badge in badges %} <li class="text-navy font-medium">{% trans "Badges" %}</li>
<div class="col-md-6 col-lg-4 mb-4"> </nav>
<div class="card h-100 border-2 border-warning"> </nav>
<div class="card-body">
<div class="text-center mb-3"> <!-- Badges List -->
<i class="{{ badge.icon }} fa-4x text-warning"></i> <div class="section-card">
</div> <div class="section-header">
<h5 class="card-title text-center">{{ badge.name_en }}</h5> <div class="section-icon bg-amber-100">
<p class="card-text small text-muted text-center"> <i data-lucide="award" class="w-5 h-5 text-amber-600"></i>
{{ badge.description_en|truncatewords:10 }} </div>
</p> <h3 class="font-bold text-navy">{% trans "All Badges" %}</h3>
<hr> </div>
<ul class="list-unstyled small mb-3"> <div class="p-6">
<li class="mb-2"> {% if badges %}
<strong>{% trans "Type:" %}</strong> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{{ badge.get_criteria_type_display }} {% for badge in badges %}
</li> <div class="badge-card h-full flex flex-col">
<li class="mb-2"> <div class="p-6 flex-1">
<strong>{% trans "Value:" %}</strong> <div class="text-center mb-4">
{{ badge.criteria_value }} <div class="w-20 h-20 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
</li> <i data-lucide="{{ badge.icon|default:'award' }}" class="w-10 h-10 text-amber-600"></i>
<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>
</div> </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> </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> </div>
{% endfor %} <div class="p-4 border-t border-slate-200 bg-slate-50 flex gap-2">
</div> <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 --> <!-- Pagination -->
{% if page_obj.has_other_pages %} {% if page_obj.has_other_pages %}
<nav aria-label="Page navigation" class="mt-4"> <div class="mt-8 pt-6 border-t border-slate-100 flex justify-center">
<ul class="pagination justify-content-center"> <div class="flex items-center gap-2">
{% if page_obj.has_previous %} {% if page_obj.has_previous %}
<li class="page-item"> <a href="?page={{ page_obj.previous_page_number }}"
<a class="page-link" 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" %} {% trans "Previous" %}
</a> </a>
</li> {% else %}
{% else %} <span class="px-3 py-2 rounded-lg border border-slate-200 text-gray-300 cursor-not-allowed">
<li class="page-item disabled"> {% trans "Previous" %}
<span class="page-link">{% trans "Previous" %}</span> </span>
</li> {% 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 %} {% endif %}
{% endfor %}
{% for num in page_obj.paginator.page_range %} {% if page_obj.has_next %}
{% if page_obj.number == num %} <a href="?page={{ page_obj.next_page_number }}"
<li class="page-item active"> class="px-3 py-2 rounded-lg border border-slate-200 text-slate hover:bg-light transition">
<span class="page-link">{{ num }}</span> {% trans "Next" %}
</li> </a>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %} {% else %}
<li class="page-item"> <span class="px-3 py-2 rounded-lg border border-slate-200 text-gray-300 cursor-not-allowed">
<a class="page-link" href="?page={{ num }}">{{ num }}</a> {% trans "Next" %}
</li> </span>
{% endif %} {% endif %}
{% endfor %} </div>
{% 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>
</div> </div>
{% endif %} {% 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>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -3,186 +3,262 @@
{% block title %}{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "Add Category" %}{% endif %} - {% endblock %} {% 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 %} {% block content %}
<div class="container-fluid py-4"> <div class="p-4">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4"> <nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb"> <ol class="flex flex-wrap items-center gap-2 text-sm">
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li> <li><a href="{% url 'appreciation:appreciation_list' %}" class="text-blue-600 hover:text-blue-800">{% trans "Appreciation" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'appreciation:category_list' %}">{% trans "Categories" %}</a></li> <li class="text-gray-400"><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<li class="breadcrumb-item active" aria-current="page">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Add" %}{% endif %}</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> </ol>
</nav> </nav>
<!-- Form --> <!-- Header -->
<div class="row"> <div class="page-header-gradient">
<div class="col-lg-8"> <h1 class="text-2xl font-bold flex items-center gap-3">
<div class="card shadow-sm"> <i data-lucide="tag" class="w-6 h-6"></i>
<div class="card-header bg-primary text-white"> {% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "Add Category" %}{% endif %}
<h4 class="mb-0"> </h1>
<i class="bi bi-tag me-2"></i> </div>
{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "Add Category" %}{% endif %}
</h4> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
</div> <!-- Form -->
<div class="card-body"> <div class="lg:col-span-2">
<form method="post"> <div class="form-section">
{% csrf_token %} <form method="post">
{% csrf_token %}
<!-- Name (English) -->
<div class="mb-3"> <!-- Name (English) -->
<label for="id_name_en" class="form-label"> <div class="mb-4">
{% trans "Name (English)" %} <span class="text-danger">*</span> <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> </label>
<input
type="text"
class="form-control"
id="id_name_en"
name="name_en"
value="{{ form.name_en.value|default:'' }}"
required
>
</div> </div>
</div>
<!-- Name (Arabic) --> <!-- Buttons -->
<div class="mb-3"> <div class="flex flex-wrap gap-3">
<label for="id_name_ar" class="form-label"> <a href="{% url 'appreciation:category_list' %}" class="btn-secondary">
{% trans "Name (Arabic)" %} <span class="text-danger">*</span> <i data-lucide="x" class="w-4 h-4"></i>
</label> {% trans "Cancel" %}
<input </a>
type="text" <button type="submit" class="btn-primary">
class="form-control" <i data-lucide="save" class="w-4 h-4"></i>
id="id_name_ar" {% if form.instance.pk %}{% trans "Update" %}{% else %}{% trans "Create" %}{% endif %}
name="name_ar" </button>
value="{{ form.name_ar.value|default:'' }}" </div>
dir="rtl" </form>
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>
</div> </div>
</div> </div>
<!-- Sidebar --> <!-- Sidebar -->
<div class="col-lg-4"> <div class="lg:col-span-1">
<!-- Icon Preview --> <!-- Icon Preview -->
<div class="card shadow-sm mb-4"> <div class="form-section text-center">
<div class="card-header bg-light"> <h6 class="font-semibold text-gray-800 mb-4">{% trans "Icon Preview" %}</h6>
<h6 class="card-title mb-0">{% trans "Icon Preview" %}</h6> <i id="icon-preview" class="{{ form.icon.value|default:'bi-heart' }} fa-4x text-blue-600 mb-4"></i>
</div> <p id="name-preview" class="text-lg font-semibold">{{ form.name_en.value|default:'Category Name' }}</p>
<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> </div>
<!-- Tips --> <!-- Tips -->
<div class="card shadow-sm"> <div class="form-section">
<div class="card-header bg-light"> <h6 class="font-semibold text-gray-800 mb-4 flex items-center gap-2">
<h6 class="card-title mb-0"> <i data-lucide="lightbulb" class="w-4 h-4 text-yellow-500"></i>
<i class="bi bi-lightbulb me-2"></i> {% trans "Tips" %}
{% trans "Tips" %} </h6>
</h6> <ul class="space-y-2 text-sm">
</div> <li class="flex items-start gap-2">
<div class="card-body"> <i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
<ul class="list-unstyled mb-0 small"> {% trans "Use descriptive names for categories" %}
<li class="mb-2"> </li>
<i class="bi bi-check text-success me-2"></i> <li class="flex items-start gap-2">
{% trans "Use descriptive names for categories" %} <i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
</li> {% trans "Choose appropriate icons for each category" %}
<li class="mb-2"> </li>
<i class="bi bi-check text-success me-2"></i> <li class="flex items-start gap-2">
{% trans "Choose appropriate icons for each category" %} <i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
</li> {% trans "Colors help users quickly identify categories" %}
<li class="mb-2"> </li>
<i class="bi bi-check text-success me-2"></i> <li class="flex items-start gap-2">
{% trans "Colors help users quickly identify categories" %} <i data-lucide="check" class="w-4 h-4 text-green-500 mt-0.5"></i>
</li> {% trans "Deactivate unused categories instead of deleting" %}
<li> </li>
<i class="bi bi-check text-success me-2"></i> </ul>
{% trans "Deactivate unused categories instead of deleting" %}
</li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,85 +1,179 @@
{% extends "layouts/base.html" %} {% extends "layouts/base.html" %}
{% load i18n static %} {% 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 %} {% block content %}
<div class="container-fluid py-4"> <!-- Page Header Gradient -->
<!-- Breadcrumb --> <div class="page-header-gradient">
<nav aria-label="breadcrumb" class="mb-4"> <div class="flex justify-between items-start">
<ol class="breadcrumb"> <div>
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li> <h1 class="text-2xl font-bold flex items-center gap-3">
<li class="breadcrumb-item active" aria-current="page">{% trans "Categories" %}</li> <i data-lucide="tags" class="w-7 h-7"></i>
</ol> {% trans "Appreciation Categories" %}
</nav> </h1>
<p class="text-sm opacity-90 mt-1">{% trans "Manage categories to organize appreciations" %}</p>
<!-- Page Header --> </div>
<div class="d-flex justify-content-between align-items-center mb-4"> <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">
<h2> <i data-lucide="plus" class="w-4 h-4"></i> {% trans "Add Category" %}
<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" %}
</a> </a>
</div> </div>
</div>
<!-- Categories List --> <!-- Breadcrumb -->
<div class="card shadow-sm"> <nav class="mb-6 text-sm">
<div class="card-body"> <ol class="flex items-center gap-2 text-slate">
{% if categories %} <li><a href="{% url 'appreciation:appreciation_list' %}" class="hover:text-navy transition">{% trans "Appreciation" %}</a></li>
<div class="table-responsive"> <li><i data-lucide="chevron-right" class="w-4 h-4"></i></li>
<table class="table table-hover"> <li class="text-navy font-medium">{% trans "Categories" %}</li>
<thead class="table-light"> </nav>
<tr> </nav>
<th>{% trans "Icon" %}</th>
<th>{% trans "Name (English)" %}</th> <!-- Categories List -->
<th>{% trans "Name (Arabic)" %}</th> <div class="section-card">
<th>{% trans "Color" %}</th> <div class="section-header">
<th>{% trans "Count" %}</th> <div class="section-icon bg-blue-100">
<th class="text-center">{% trans "Actions" %}</th> <i data-lucide="tags" class="w-5 h-5 text-blue-600"></i>
</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 %}
</div> </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>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -4,29 +4,80 @@
{% block title %}{% trans "Call Records" %} - PX360{% endblock %} {% 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 %} {% block content %}
<!-- Header --> <!-- Page Header -->
<div class="flex items-center justify-between mb-6"> <div class="page-header-gradient">
<div> <div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-navy">{% trans "Call Records" %}</h1> <div class="flex items-center gap-3">
<p class="text-slate text-sm mt-1">{% trans "Manage and analyze imported call center recordings" %}</p> <div class="section-icon bg-white/20">
</div> <i data-lucide="phone-call" class="w-6 h-6"></i>
<div class="flex items-center gap-3"> </div>
<a href="{% url 'callcenter:export_call_records_template' %}" <div>
class="px-4 py-2 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-light transition flex items-center gap-2"> <h1 class="text-2xl font-bold mb-1">{% trans "Call Records" %}</h1>
<i data-lucide="download" class="w-4 h-4"></i> <p class="opacity-90 mb-0">{% trans "Manage and analyze imported call center recordings" %}</p>
{% trans "Download Template" %} </div>
</a> </div>
<a href="{% url 'callcenter:import_call_records' %}" <div class="flex items-center gap-3">
class="px-4 py-2 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2"> <a href="{% url 'callcenter:export_call_records_template' %}"
<i data-lucide="upload" class="w-4 h-4"></i> 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">
{% trans "Import CSV" %} <i data-lucide="download" class="w-4 h-4"></i>
</a> {% 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>
</div> </div>
<!-- Stats Cards --> <!-- 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 --> <!-- Total Calls -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
@ -89,60 +140,74 @@
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 mb-6"> <div class="section-card mb-6">
<form method="get" class="flex flex-wrap gap-4"> <div class="section-header">
<div class="flex-1 min-w-48"> <div class="section-icon bg-blue-100">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Search" %}</label> <i data-lucide="filter" class="w-5 h-5 text-blue-600"></i>
<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>
<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"> <div class="w-48">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Call Type" %}</label> <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"> <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="">{% trans "All Calls" %}</option>
<option value="inbound" {% if filters.call_type == 'inbound' %}selected{% endif %}>{% trans "Inbound" %}</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> <option value="outbound" {% if filters.call_type == 'outbound' %}selected{% endif %}>{% trans "Outbound" %}</option>
</select> </select>
</div> </div>
<div class="w-48"> <div class="w-48">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "Evaluated" %}</label> <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"> <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="">{% trans "All" %}</option>
<option value="true" {% if filters.evaluated == 'true' %}selected{% endif %}>{% trans "Yes" %}</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> <option value="false" {% if filters.evaluated == 'false' %}selected{% endif %}>{% trans "No" %}</option>
</select> </select>
</div> </div>
<div class="w-40"> <div class="w-40">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "From Date" %}</label> <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 }}" <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"> 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>
<div class="w-40"> <div class="w-40">
<label class="block text-xs font-bold text-slate uppercase mb-1.5">{% trans "To Date" %}</label> <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 }}" <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"> 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>
<div class="flex items-end"> <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"> <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> <i data-lucide="search" class="w-4 h-4"></i>
{% trans "Filter" %} {% trans "Filter" %}
</button> </button>
<a href="{% url 'callcenter:call_records_list' %}" <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"> 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> <i data-lucide="x" class="w-4 h-4"></i>
</a> </a>
</div> </div>
</form> </form>
</div>
</div> </div>
<!-- Call Records Table --> <!-- 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"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">
<thead class="bg-slate-50 border-b border-slate-200"> <thead class="bg-slate-50 border-b border-slate-200">
@ -256,9 +321,3 @@
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>

View File

@ -6,6 +6,46 @@
{% block extra_css %} {% block extra_css %}
<style> <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-open { background: #e0f2fe; color: #075985; }
.badge-status-in_progress { background: #fef9c3; color: #854d0e; } .badge-status-in_progress { background: #fef9c3; color: #854d0e; }
.badge-status-resolved { background: #dcfce7; color: #166534; } .badge-status-resolved { background: #dcfce7; color: #166534; }
@ -18,21 +58,23 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Header --> <!-- Page Header -->
<header class="mb-6"> <div class="page-header-gradient">
<div class="flex justify-between items-start"> <div class="flex items-center justify-between">
<div> <div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-navy flex items-center gap-3"> <div class="section-icon bg-white/20">
<i data-lucide="phone" class="w-7 h-7 text-blue"></i> <i data-lucide="phone" class="w-6 h-6"></i>
{% trans "Call Center Complaints" %} </div>
</h1> <div>
<p class="text-sm text-slate mt-1">{% trans "Complaints created via call center" %}</p> <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> </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" %} <i data-lucide="plus" class="w-4 h-4"></i> {% trans "Create Complaint" %}
</a> </a>
</div> </div>
</header> </div>
<!-- Statistics --> <!-- Statistics -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
@ -75,57 +117,57 @@
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 mb-6"> <div class="section-card 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"> <div class="section-header">
<h3 class="font-bold text-navy flex items-center gap-2"> <div class="section-icon bg-blue-100">
<i data-lucide="filter" class="w-5 h-5 text-navy"></i> <i data-lucide="filter" class="w-5 h-5 text-blue-600"></i>
{% trans "Filters" %} </div>
</h3> <h3 class="font-bold text-navy">{% trans "Filters" %}</h3>
</div> </div>
<div class="p-5"> <div class="p-5">
<form method="get" class="grid grid-cols-1 md:grid-cols-5 gap-4"> <form method="get" class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div class="md:col-span-1"> <div class="md:col-span-1">
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Search" %}</label> <label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Search" %}</label>
<input type="text" name="search" <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" 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 }}"> placeholder="{% trans 'Search...' %}" value="{{ filters.search }}">
</div> </div>
<div> <div>
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Status" %}</label> <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"> <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="">{% trans "All" %}</option>
<option value="open" {% if filters.status == 'open' %}selected{% endif %}>{% trans "Open" %}</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="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="resolved" {% if filters.status == 'resolved' %}selected{% endif %}>{% trans "Resolved" %}</option>
<option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>{% trans "Closed" %}</option> <option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>{% trans "Closed" %}</option>
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Severity" %}</label> <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"> <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="">{% trans "All" %}</option>
<option value="critical" {% if filters.severity == 'critical' %}selected{% endif %}>{% trans "Critical" %}</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="high" {% if filters.severity == 'high' %}selected{% endif %}>{% trans "High" %}</option>
<option value="medium" {% if filters.severity == 'medium' %}selected{% endif %}>{% trans "Medium" %}</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> <option value="low" {% if filters.severity == 'low' %}selected{% endif %}>{% trans "Low" %}</option>
</select> </select>
</div> </div>
<div class="flex items-end"> <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"> <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" %} <i data-lucide="filter" class="w-4 h-4"></i> {% trans "Filter" %}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<!-- Complaints Table --> <!-- Complaints Table -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 overflow-hidden"> <div class="section-card">
<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"> <div class="section-header">
<h3 class="font-bold text-navy flex items-center gap-2"> <div class="section-icon bg-red-100">
<i data-lucide="message-square-warning" class="w-5 h-5 text-red-500"></i> <i data-lucide="message-square-warning" class="w-5 h-5 text-red-600"></i>
{% trans "Complaints" %} </div>
</h3> <h3 class="font-bold text-navy">{% trans "Complaints" %}</h3>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">

View File

@ -6,6 +6,46 @@
{% block extra_css %} {% block extra_css %}
<style> <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-open { background: #e0f2fe; color: #075985; }
.badge-status-in_progress { background: #fef9c3; color: #854d0e; } .badge-status-in_progress { background: #fef9c3; color: #854d0e; }
.badge-status-resolved { background: #dcfce7; color: #166534; } .badge-status-resolved { background: #dcfce7; color: #166534; }
@ -14,21 +54,23 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Header --> <!-- Page Header -->
<header class="mb-6"> <div class="page-header-gradient">
<div class="flex justify-between items-start"> <div class="flex items-center justify-between">
<div> <div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-navy flex items-center gap-3"> <div class="section-icon bg-white/20">
<i data-lucide="phone" class="w-7 h-7 text-cyan-500"></i> <i data-lucide="phone" class="w-6 h-6"></i>
{% trans "Call Center Inquiries" %} </div>
</h1> <div>
<p class="text-sm text-slate mt-1">{% trans "Inquiries created via call center" %}</p> <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> </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" %} <i data-lucide="plus" class="w-4 h-4"></i> {% trans "Create Inquiry" %}
</a> </a>
</div> </div>
</header> </div>
<!-- Statistics --> <!-- Statistics -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
@ -71,58 +113,58 @@
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 mb-6"> <div class="section-card 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"> <div class="section-header">
<h3 class="font-bold text-navy flex items-center gap-2"> <div class="section-icon bg-blue-100">
<i data-lucide="filter" class="w-5 h-5 text-navy"></i> <i data-lucide="filter" class="w-5 h-5 text-blue-600"></i>
{% trans "Filters" %} </div>
</h3> <h3 class="font-bold text-navy">{% trans "Filters" %}</h3>
</div> </div>
<div class="p-5"> <div class="p-5">
<form method="get" class="grid grid-cols-1 md:grid-cols-5 gap-4"> <form method="get" class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div class="md:col-span-2"> <div class="md:col-span-2">
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Search" %}</label> <label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Search" %}</label>
<input type="text" name="search" <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" 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 }}"> placeholder="{% trans 'Search...' %}" value="{{ filters.search }}">
</div> </div>
<div> <div>
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Status" %}</label> <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"> <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="">{% trans "All" %}</option>
<option value="open" {% if filters.status == 'open' %}selected{% endif %}>{% trans "Open" %}</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="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="resolved" {% if filters.status == 'resolved' %}selected{% endif %}>{% trans "Resolved" %}</option>
<option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>{% trans "Closed" %}</option> <option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>{% trans "Closed" %}</option>
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs font-bold text-slate uppercase mb-2">{% trans "Category" %}</label> <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"> <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="">{% trans "All" %}</option>
<option value="appointment" {% if filters.category == 'appointment' %}selected{% endif %}>{% trans "Appointment" %}</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="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="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="general" {% if filters.category == 'general' %}selected{% endif %}>{% trans "General" %}</option>
<option value="other" {% if filters.category == 'other' %}selected{% endif %}>{% trans "Other" %}</option> <option value="other" {% if filters.category == 'other' %}selected{% endif %}>{% trans "Other" %}</option>
</select> </select>
</div> </div>
<div class="md:col-span-5 flex justify-end"> <div class="flex items-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"> <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" %} <i data-lucide="filter" class="w-4 h-4"></i> {% trans "Filter" %}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<!-- Inquiries Table --> <!-- Inquiries Table -->
<div class="bg-white rounded-2xl shadow-sm border-2 border-slate-200 overflow-hidden"> <div class="section-card">
<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"> <div class="section-header">
<h3 class="font-bold text-navy flex items-center gap-2"> <div class="section-icon bg-cyan-100">
<i data-lucide="help-circle" class="w-5 h-5 text-cyan-500"></i> <i data-lucide="help-circle" class="w-5 h-5 text-cyan-600"></i>
{% trans "Inquiries" %} </div>
</h3> <h3 class="font-bold text-navy">{% trans "Inquiries" %}</h3>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">
@ -200,7 +242,7 @@
</a> </a>
{% endif %} {% 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 }} {% trans "Page" %} {{ page_obj.number }} {% trans "of" %} {{ page_obj.paginator.num_pages }}
</span> </span>

View File

@ -4,15 +4,62 @@
{% block title %}Call Center - PX360{% endblock %} {% 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 %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4"> <!-- Page Header -->
<div> <div class="page-header-gradient">
<h2 class="mb-1"> <div class="flex items-center gap-3">
<i class="bi bi-telephone text-success me-2"></i> <div class="section-icon bg-white/20">
Call Center <i data-lucide="phone" class="w-6 h-6"></i>
</h2> </div>
<p class="text-muted mb-0">Monitor call center interactions and satisfaction</p> <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>
</div> </div>
@ -45,8 +92,14 @@
</div> </div>
<!-- Interactions Table --> <!-- Interactions Table -->
<div class="card"> <div class="section-card">
<div class="card-body p-0"> <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"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
@ -102,15 +155,15 @@
<td onclick="event.stopPropagation();"> <td onclick="event.stopPropagation();">
<a href="{% url 'callcenter:interaction_detail' interaction.id %}" <a href="{% url 'callcenter:interaction_detail' interaction.id %}"
class="btn btn-sm btn-outline-primary"> class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> <i data-lucide="eye" class="w-4 h-4"></i>
</a> </a>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="8" class="text-center py-5"> <td colspan="8" class="text-center py-5">
<i class="bi bi-telephone" style="font-size: 3rem; color: #ccc;"></i> <i data-lucide="phone" class="w-12 h-12 mx-auto mb-3 text-gray-300"></i>
<p class="text-muted mt-3">No interactions found</p> <p class="text-muted mt-3">{% trans "No interactions found" %}</p>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -127,7 +180,7 @@
{% if page_obj.has_previous %} {% if page_obj.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"> <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> </a>
</li> </li>
{% endif %} {% endif %}
@ -147,7 +200,7 @@
{% if page_obj.has_next %} {% if page_obj.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"> <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> </a>
</li> </li>
{% endif %} {% 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 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 %} {% block content %}
<div class="p-6 max-w-4xl mx-auto"> <div class="p-6 max-w-4xl mx-auto">
<!-- Header --> <!-- Gradient Header -->
<div class="mb-6"> <div class="page-header-gradient">
<div class="flex items-center gap-4"> <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> <i data-lucide="arrow-left" class="w-6 h-6"></i>
</a> </a>
<div> <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 %} {% if adverse_action %}{% trans "Edit Adverse Action" %}{% else %}{% trans "Report Adverse Action" %}{% endif %}
</h1> </h1>
<p class="text-[#64748b] mt-1"> <p class="text-white/80 mt-1">
{% trans "Complaint" %}: {{ complaint.reference_number }} {% trans "Complaint" %}: {{ complaint.reference_number }}
</p> </p>
</div> </div>
@ -23,32 +105,31 @@
</div> </div>
<!-- Form --> <!-- Form -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"> <div class="form-section">
<form method="post" class="p-6"> <form method="post">
{% csrf_token %} {% csrf_token %}
<!-- Action Type --> <!-- Action Type -->
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2"> <label class="form-label">
{% trans "Action Type" %} {% trans "Action Type" %}
<span class="text-red-500">*</span> <span class="text-red-500">*</span>
</label> </label>
<select name="action_type" required <select name="action_type" required class="form-select">
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">
{% for value, label in action_type_choices %} {% for value, label in action_type_choices %}
<option value="{{ value }}" {% if adverse_action and adverse_action.action_type == value %}selected{% endif %}> <option value="{{ value }}" {% if adverse_action and adverse_action.action_type == value %}selected{% endif %}>
{{ label }} {{ label }}
</option> </option>
{% endfor %} {% endfor %}
</select> </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." %} {% trans "Select the type of adverse action that occurred." %}
</p> </p>
</div> </div>
<!-- Severity --> <!-- Severity -->
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2"> <label class="form-label">
{% trans "Severity Level" %} {% trans "Severity Level" %}
<span class="text-red-500">*</span> <span class="text-red-500">*</span>
</label> </label>
@ -77,54 +158,54 @@
<!-- Incident Date --> <!-- Incident Date -->
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2"> <label class="form-label">
{% trans "Incident Date & Time" %} {% trans "Incident Date & Time" %}
<span class="text-red-500">*</span> <span class="text-red-500">*</span>
</label> </label>
<input type="datetime-local" name="incident_date" required <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 %}" 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> </div>
<!-- Location --> <!-- Location -->
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2"> <label class="form-label">
{% trans "Location" %} {% trans "Location" %}
</label> </label>
<input type="text" name="location" <input type="text" name="location"
value="{{ adverse_action.location|default:'' }}" 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' %}"> placeholder="{% trans 'e.g., Emergency Room, Clinic B, Main Reception' %}">
</div> </div>
<!-- Description --> <!-- Description -->
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2"> <label class="form-label">
{% trans "Description" %} {% trans "Description" %}
<span class="text-red-500">*</span> <span class="text-red-500">*</span>
</label> </label>
<textarea name="description" rows="4" required <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> placeholder="{% trans 'Describe what happened to the patient...' %}">{{ adverse_action.description|default:'' }}</textarea>
</div> </div>
<!-- Patient Impact --> <!-- Patient Impact -->
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2"> <label class="form-label">
{% trans "Impact on Patient" %} {% trans "Impact on Patient" %}
</label> </label>
<textarea name="patient_impact" rows="3" <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> placeholder="{% trans 'Describe the physical, emotional, or financial impact on the patient...' %}">{{ adverse_action.patient_impact|default:'' }}</textarea>
</div> </div>
<!-- Involved Staff --> <!-- Involved Staff -->
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2"> <label class="form-label">
{% trans "Involved Staff" %} {% trans "Involved Staff" %}
</label> </label>
<select name="involved_staff" multiple <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"> size="5">
{% for staff in staff_list %} {% for staff in staff_list %}
<option value="{{ staff.id }}" {% if staff.id in selected_staff %}selected{% endif %}> <option value="{{ staff.id }}" {% if staff.id in selected_staff %}selected{% endif %}>
@ -132,7 +213,7 @@
</option> </option>
{% endfor %} {% endfor %}
</select> </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." %} {% trans "Hold Ctrl/Cmd to select multiple staff members." %}
</p> </p>
</div> </div>
@ -152,11 +233,12 @@
<!-- Actions --> <!-- Actions -->
<div class="flex items-center justify-end gap-3 pt-6 border-t border-gray-100"> <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" %} {% trans "Cancel" %}
</a> </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"> <button type="submit" class="btn-primary">
<i data-lucide="{% if adverse_action %}save{% else %}plus{% endif %}" class="w-5 h-5"></i> <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 %} {% if adverse_action %}{% trans "Save Changes" %}{% else %}{% trans "Report Adverse Action" %}{% endif %}
</button> </button>
</div> </div>

View File

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

View File

@ -747,7 +747,10 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons(); lucide.createIcons();
// Hospital field - could be select or hidden input depending on user role
const hospitalSelect = document.getElementById('hospitalSelect'); const hospitalSelect = document.getElementById('hospitalSelect');
const hospitalField = hospitalSelect || document.querySelector('input[name="hospital"]');
const departmentSelect = document.getElementById('departmentSelect'); const departmentSelect = document.getElementById('departmentSelect');
const staffSelect = document.getElementById('staffSelect'); const staffSelect = document.getElementById('staffSelect');
const locationSelect = document.getElementById('locationSelect'); const locationSelect = document.getElementById('locationSelect');
@ -756,6 +759,36 @@ document.addEventListener('DOMContentLoaded', function() {
const complaintTypeCards = document.querySelectorAll('.type-card'); const complaintTypeCards = document.querySelectorAll('.type-card');
const complaintTypeInput = document.getElementById('complaintTypeInput'); 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 // Complaint type selection
complaintTypeCards.forEach(card => { complaintTypeCards.forEach(card => {
card.addEventListener('click', function() { card.addEventListener('click', function() {
@ -767,7 +800,7 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
// Hospital change handler // Hospital change handler (only for select, not hidden fields)
if (hospitalSelect) { if (hospitalSelect) {
hospitalSelect.addEventListener('change', function() { hospitalSelect.addEventListener('change', function() {
const hospitalId = this.value; const hospitalId = this.value;
@ -779,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 // Location change handler
if (locationSelect) { if (locationSelect) {
locationSelect.addEventListener('change', function() { locationSelect.addEventListener('change', function() {

View File

@ -5,6 +5,63 @@
{% block extra_css %} {% block extra_css %}
<style> <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-resolved { background-color: #dcfce7; color: #166534; }
.status-pending { background-color: #fef9c3; color: #854d0e; } .status-pending { background-color: #fef9c3; color: #854d0e; }
.status-investigation { background-color: #e0f2fe; color: #075985; } .status-investigation { background-color: #e0f2fe; color: #075985; }
@ -16,81 +73,89 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Header --> <!-- Page Header with Gradient -->
<header class="mb-6"> <div class="page-header-gradient">
<div class="flex justify-between items-start"> <div class="flex justify-between items-center">
<div> <div>
<h1 class="text-2xl font-bold text-navy flex items-center gap-3"> <h1 class="text-2xl font-bold mb-1">{% trans "Complaints Registry" %}</h1>
<i data-lucide="message-square-warning" class="w-7 h-7 text-red-500"></i> <p class="text-blue-100 text-sm">{% trans "Manage and monitor patient feedback in real-time" %}</p>
{% trans "Complaints Registry" %}
</h1>
<p class="text-sm text-slate mt-1">{% trans "Manage and monitor patient feedback in real-time" %}</p>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="relative group"> <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...' %}" <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> </div>
{% if user.is_px_admin or user.is_hospital_admin %} {% 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"> <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="plus" class="w-4 h-4"></i> {% trans "New Case" %} <i data-lucide="external-link" class="w-4 h-4 me-2"></i>{% trans "Public Form" %}
</a> </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"> <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="external-link" class="w-4 h-4"></i> {% trans "Public Form" %} <i data-lucide="plus" class="w-4 h-4 me-2"></i>{% trans "New Case" %}
</a> </a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</header> </div>
<!-- Statistics Cards --> <!-- Statistics Cards -->
<div class="grid grid-cols-4 gap-6 mb-6"> <div class="grid grid-cols-4 gap-6 mb-6">
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4"> <div class="stat-card bg-white rounded-2xl border-2 border-slate-200 p-6 flex items-center gap-4">
<div class="p-3 bg-blue/10 rounded-xl"> <div class="flex-shrink-0">
<i data-lucide="layers" class="text-blue w-5 h-5"></i> <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>
<div> <div>
<p class="text-[10px] font-bold text-slate uppercase tracking-wider">{% trans "Total Received" %}</p> <h6 class="text-slate-500 text-sm font-medium mb-1">{% trans "Total Received" %}</h6>
<p class="text-xl font-black text-navy leading-tight">{{ stats.total }}</p> <h2 class="text-3xl font-bold text-gray-800">{{ stats.total }}</h2>
</div> </div>
</div> </div>
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4"> <div class="stat-card bg-white rounded-2xl border-2 border-slate-200 p-6 flex items-center gap-4">
<div class="p-3 bg-green-50 rounded-xl"> <div class="flex-shrink-0">
<i data-lucide="check-circle" class="text-green-600 w-5 h-5"></i> <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>
<div> <div>
<p class="text-[10px] font-bold text-slate uppercase tracking-wider">{% trans "Resolved" %}</p> <h6 class="text-slate-500 text-sm font-medium mb-1">{% trans "Resolved" %}</h6>
<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> <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> </div>
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4"> <div class="stat-card bg-white rounded-2xl border-2 border-slate-200 p-6 flex items-center gap-4">
<div class="p-3 bg-yellow-50 rounded-xl"> <div class="flex-shrink-0">
<i data-lucide="clock" class="text-yellow-600 w-5 h-5"></i> <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>
<div> <div>
<p class="text-[10px] font-bold text-slate uppercase tracking-wider">{% trans "Pending" %}</p> <h6 class="text-slate-500 text-sm font-medium mb-1">{% trans "Pending" %}</h6>
<p class="text-xl font-black text-navy leading-tight">{{ stats.pending }}</p> <h2 class="text-3xl font-bold text-gray-800">{{ stats.pending }}</h2>
</div> </div>
</div> </div>
<div class="bg-white p-4 rounded-2xl border shadow-sm flex items-center gap-4"> <div class="stat-card bg-white rounded-2xl border-2 border-slate-200 p-6 flex items-center gap-4">
<div class="p-3 bg-red-50 rounded-xl"> <div class="flex-shrink-0">
<i data-lucide="alert-triangle" class="text-red-500 w-5 h-5"></i> <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>
<div> <div>
<p class="text-[10px] font-bold text-slate uppercase tracking-wider">{% trans "TAT Alert" %}</p> <h6 class="text-slate-500 text-sm font-medium mb-1">{% trans "TAT Alert" %}</h6>
<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> <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> </div>
</div> </div>
<!-- Filter Tabs --> <!-- Complaints Section Card -->
<div class="bg-white rounded-t-2xl shadow-sm border-2 border-slate-200"> <div class="section-card">
<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"> <!-- Section Header with Filters -->
<h3 class="font-bold text-navy flex items-center gap-2"> <div class="section-header flex items-center justify-between">
<i data-lucide="filter" class="w-5 h-5 text-navy"></i> <div class="flex items-center gap-3">
{% trans "Filters" %} <div class="section-icon bg-red-500/10">
</h3> <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"> <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 %}"> <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" %} {% trans "All Cases" %}
@ -110,7 +175,7 @@
</button> </button>
</div> </div>
</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> {% 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> </p>
@ -145,13 +210,7 @@
</div> </div>
<!-- Complaints Table --> <!-- Complaints Table -->
<div class="bg-white rounded-b-2xl shadow-sm border-2 border-slate-200 overflow-hidden border-t-0"> <div class="p-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>
<table class="w-full text-left border-collapse"> <table class="w-full text-left border-collapse">
<thead class="bg-slate-50 border-b uppercase text-[10px] font-bold text-slate tracking-wider"> <thead class="bg-slate-50 border-b uppercase text-[10px] font-bold text-slate tracking-wider">
<tr> <tr>
@ -307,6 +366,7 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div>
<!-- Description Modal --> <!-- Description Modal -->
<div id="descriptionModal" class="fixed inset-0 z-50 hidden items-center justify-center p-4" onclick="closeDescriptionModal(event)"> <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 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 %} {% block content %}
<div class="page-header"> <!-- Gradient Header -->
<div class="page-header-content"> <div class="page-header-gradient">
<div> <div class="flex items-center justify-between">
<h1 class="page-title"> <div class="flex items-center gap-4">
<i class="fas fa-chart-line"></i> <div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
{{ title }} <i data-lucide="gauge" class="w-6 h-6"></i>
</h1> </div>
<p class="page-description"> <div>
{% if threshold %} <h1 class="text-2xl font-bold">{{ title }}</h1>
{% translate "Edit complaint threshold" %} <p class="text-white/80">
{% else %} {% if threshold %}{% translate "Edit complaint threshold" %}{% else %}{% translate "Create new complaint threshold" %}{% endif %}
{% translate "Create new complaint threshold" %} </p>
{% endif %} </div>
</p>
</div> </div>
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'complaints:complaint_threshold_list' %}" class="btn-secondary">
<i class="fas fa-arrow-left"></i> <i data-lucide="arrow-left" class="w-4 h-4"></i>
{% translate "Back to List" %} {% translate "Back to List" %}
</a> </a>
</div> </div>
</div> </div>
<div class="page-content"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="row"> <div class="lg:col-span-2">
<div class="col-lg-8"> <div class="form-section">
<div class="card"> <form method="post" class="space-y-6">
<div class="card-body"> {% csrf_token %}
<form method="post" class="row g-3">
{% csrf_token %} {% if not form.hospital.is_hidden %}
<div>
{% if not form.hospital.is_hidden %} <label for="id_hospital" class="form-label">
<div class="col-md-12"> {% translate "Hospital" %} <span class="text-red-500">*</span>
<label for="id_hospital" class="form-label"> </label>
{% translate "Hospital" %} <span class="text-danger">*</span> <select name="hospital" id="id_hospital" class="form-select" required>
</label> <option value="">{% translate "Select Hospital" %}</option>
<select name="hospital" id="id_hospital" class="form-select" required> {% for hospital in form.hospital.field.queryset %}
<option value="">{% translate "Select Hospital" %}</option> <option value="{{ hospital.id }}"
{% for hospital in form.hospital.field.queryset %} {% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}>
<option value="{{ hospital.id }}" {{ hospital.name }}
{% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}> </option>
{{ hospital.name }} {% endfor %}
</option> </select>
{% endfor %} {% if form.hospital.errors %}
</select> <div class="invalid-feedback">
{% if form.hospital.errors %} <i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
<div class="invalid-feedback d-block"> {{ form.hospital.errors.0 }}
{{ form.hospital.errors.0 }} </div>
</div> {% endif %}
{% 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> </div>
{% endif %} {% endif %}
</div>
<div class="col-md-12">
<label for="id_name" class="form-label"> <div>
{% translate "Threshold Name" %} <span class="text-danger">*</span> <label for="id_metric_type" class="form-label">
</label> {% translate "Metric Type" %} <span class="text-red-500">*</span>
<input type="text" </label>
name="name" <select name="metric_type" id="id_metric_type" class="form-select" required>
id="id_name" <option value="">{% translate "Select Metric" %}</option>
class="form-control" {% for value, label in form.metric_type.field.choices %}
value="{{ form.name.value|default:'' }}" <option value="{{ value }}"
required {% if form.metric_type.value == value %}selected{% endif %}>
placeholder="{% translate 'e.g., Daily Complaint Limit' %}"> {{ label }}
{% if form.name.errors %} </option>
<div class="invalid-feedback d-block"> {% endfor %}
{{ form.name.errors.0 }} </select>
</div> {% if form.metric_type.errors %}
{% endif %} <div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
{{ form.metric_type.errors.0 }}
</div> </div>
{% endif %}
<div class="col-md-6"> </div>
<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>
</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>
</div>
<div class="col-lg-4">
<div class="card bg-light"> <div class="lg:col-span-1">
<div class="card-body"> <div class="help-card">
<h5 class="card-title"> <h5 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
<i class="fas fa-info-circle"></i> <i data-lucide="info" class="w-5 h-5 text-[#005696]"></i>
{% translate "Help" %} {% translate "Help" %}
</h5> </h5>
<h6 class="card-subtitle mb-3 text-muted"> <h6 class="text-sm font-semibold text-gray-500 mb-3">
{% translate "Understanding Complaint Thresholds" %} {% translate "Understanding Complaint Thresholds" %}
</h6> </h6>
<p class="card-text"> <p class="text-sm text-gray-600 mb-4">
{% translate "Thresholds monitor complaint metrics and trigger actions when limits are exceeded." %} {% translate "Thresholds monitor complaint metrics and trigger actions when limits are exceeded." %}
</p> </p>
<h6 class="card-subtitle mb-2 text-muted"> <h6 class="text-sm font-semibold text-gray-500 mb-3">
{% translate "Threshold Types" %} {% translate "Threshold Types" %}
</h6> </h6>
<ul class="list-unstyled"> <ul class="space-y-2 text-sm text-gray-600">
<li class="mb-2"> <li class="flex items-center gap-2">
<i class="fas fa-calendar-day text-primary me-2"></i> <i data-lucide="calendar" class="w-4 h-4 text-[#005696]"></i>
<strong>{% translate "Daily" %}</strong> - {% translate "Monitor daily complaint volume" %} <strong>{% translate "Daily" %}</strong> - {% translate "Monitor daily complaint volume" %}
</li> </li>
<li class="mb-2"> <li class="flex items-center gap-2">
<i class="fas fa-calendar-week text-success me-2"></i> <i data-lucide="calendar-days" class="w-4 h-4 text-green-500"></i>
<strong>{% translate "Weekly" %}</strong> - {% translate "Monitor weekly complaint volume" %} <strong>{% translate "Weekly" %}</strong> - {% translate "Monitor weekly complaint volume" %}
</li> </li>
<li class="mb-2"> <li class="flex items-center gap-2">
<i class="fas fa-calendar text-warning me-2"></i> <i data-lucide="calendar-range" class="w-4 h-4 text-orange-500"></i>
<strong>{% translate "Monthly" %}</strong> - {% translate "Monitor monthly complaint volume" %} <strong>{% translate "Monthly" %}</strong> - {% translate "Monitor monthly complaint volume" %}
</li> </li>
<li class="mb-2"> <li class="flex items-center gap-2">
<i class="fas fa-tags text-info me-2"></i> <i data-lucide="tags" class="w-4 h-4 text-purple-500"></i>
<strong>{% translate "By Category" %}</strong> - {% translate "Monitor specific complaint categories" %} <strong>{% translate "By Category" %}</strong> - {% translate "Monitor specific complaint categories" %}
</li> </li>
</ul> </ul>
<hr> <hr class="my-4 border-gray-200">
<h6 class="card-subtitle mb-2 text-muted"> <h6 class="text-sm font-semibold text-gray-500 mb-3">
{% translate "Metric Types" %} {% translate "Metric Types" %}
</h6> </h6>
<ul class="list-unstyled"> <ul class="space-y-2 text-sm text-gray-600">
<li class="mb-2"> <li class="flex items-center gap-2">
<i class="fas fa-list-ol text-secondary me-2"></i> <i data-lucide="list-ordered" class="w-4 h-4 text-gray-500"></i>
{% translate "Count" %} - {% translate "Number of complaints" %} {% translate "Count" %} - {% translate "Number of complaints" %}
</li> </li>
<li class="mb-2"> <li class="flex items-center gap-2">
<i class="fas fa-percentage text-secondary me-2"></i> <i data-lucide="percent" class="w-4 h-4 text-gray-500"></i>
{% translate "Percentage" %} - {% translate "Percentage of total complaints" %} {% translate "Percentage" %} - {% translate "Percentage of total complaints" %}
</li> </li>
</ul> </ul>
<hr> <hr class="my-4 border-gray-200">
<h6 class="card-subtitle mb-2 text-muted"> <h6 class="text-sm font-semibold text-gray-500 mb-3">
{% translate "Actions" %} {% translate "Actions" %}
</h6> </h6>
<ul class="list-unstyled"> <ul class="space-y-2 text-sm text-gray-600">
<li class="mb-2"> <li class="flex items-center gap-2">
<i class="fas fa-bell text-warning me-2"></i> <i data-lucide="bell" class="w-4 h-4 text-orange-500"></i>
{% translate "Send Alert" %} - {% translate "Notify administrators" %} {% translate "Send Alert" %} - {% translate "Notify administrators" %}
</li> </li>
<li class="mb-2"> <li class="flex items-center gap-2">
<i class="fas fa-envelope text-info me-2"></i> <i data-lucide="mail" class="w-4 h-4 text-blue-500"></i>
{% translate "Send Email" %} - {% translate "Send email notifications" %} {% translate "Send Email" %} - {% translate "Send email notifications" %}
</li> </li>
<li class="mb-2"> <li class="flex items-center gap-2">
<i class="fas fa-file-alt text-success me-2"></i> <i data-lucide="file-text" class="w-4 h-4 text-green-500"></i>
{% translate "Generate Report" %} - {% translate "Create detailed report" %} {% translate "Generate Report" %} - {% translate "Create detailed report" %}
</li> </li>
</ul> </ul>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %} {% endblock %}

View File

@ -3,39 +3,209 @@
{% block title %}{% translate "Complaint Thresholds" %} - PX360{% endblock %} {% block title %}{% translate "Complaint Thresholds" %} - PX360{% endblock %}
{% block content %} {% block extra_css %}
<div class="page-header"> <style>
<div class="page-header-content"> :root {
<div> --hh-navy: #005696;
<h1 class="page-title"> --hh-blue: #007bbd;
<i class="fas fa-chart-line"></i> --hh-light: #eef6fb;
{% translate "Complaint Thresholds" %} --hh-slate: #64748b;
</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>
<div class="page-content"> .page-header-gradient {
<!-- Filters --> background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
<div class="card mb-4"> color: white;
<div class="card-header"> padding: 1.5rem 2rem;
<h5 class="card-title"> border-radius: 1rem;
<i class="fas fa-filter"></i> margin-bottom: 1.5rem;
{% translate "Filters" %} box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
</h5> }
.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>
<div class="card-body"> </div>
<form method="get" class="row g-3">
<div class="col-md-4"> <!-- Filters -->
<label class="form-label">{% translate "Threshold Type" %}</label> <div class="section-card mb-6 animate-in">
<select name="threshold_type" class="form-select"> <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="">{% translate "All Types" %}</option>
<option value="daily" {% if filters.threshold_type == "daily" %}selected{% endif %}>{% translate "Daily" %}</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> <option value="weekly" {% if filters.threshold_type == "weekly" %}selected{% endif %}>{% translate "Weekly" %}</option>
@ -43,23 +213,21 @@
<option value="category" {% if filters.threshold_type == "category" %}selected{% endif %}>{% translate "By Category" %}</option> <option value="category" {% if filters.threshold_type == "category" %}selected{% endif %}>{% translate "By Category" %}</option>
</select> </select>
</div> </div>
<div>
<div class="col-md-4"> <label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Status" %}</label>
<label class="form-label">{% 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">
<select name="is_active" class="form-select">
<option value="">{% translate "All" %}</option> <option value="">{% translate "All" %}</option>
<option value="true" {% if filters.is_active == "true" %}selected{% endif %}>{% translate "Active" %}</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> <option value="false" {% if filters.is_active == "false" %}selected{% endif %}>{% translate "Inactive" %}</option>
</select> </select>
</div> </div>
<div class="flex items-end gap-2">
<div class="col-12 text-end"> <button type="submit" class="btn-primary h-[46px]">
<button type="submit" class="btn btn-primary"> <i data-lucide="search" class="w-4 h-4"></i>
<i class="fas fa-search"></i>
{% translate "Apply Filters" %} {% translate "Apply Filters" %}
</button> </button>
<a href="{% url 'complaints:complaint_threshold_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'complaints:complaint_threshold_list' %}" class="btn-secondary h-[46px]">
<i class="fas fa-times"></i> <i data-lucide="x" class="w-4 h-4"></i>
{% translate "Clear" %} {% translate "Clear" %}
</a> </a>
</div> </div>
@ -68,11 +236,17 @@
</div> </div>
<!-- Thresholds Table --> <!-- Thresholds Table -->
<div class="card"> <div class="section-card animate-in">
<div class="card-body"> <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 %} {% if thresholds %}
<div class="table-responsive"> <div class="overflow-x-auto">
<table class="table table-hover"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th>{% translate "Hospital" %}</th> <th>{% translate "Hospital" %}</th>
@ -83,7 +257,7 @@
<th>{% translate "Category" %}</th> <th>{% translate "Category" %}</th>
<th>{% translate "Action" %}</th> <th>{% translate "Action" %}</th>
<th>{% translate "Status" %}</th> <th>{% translate "Status" %}</th>
<th>{% translate "Actions" %}</th> <th class="text-right">{% translate "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -94,10 +268,10 @@
</td> </td>
<td>{{ threshold.name }}</td> <td>{{ threshold.name }}</td>
<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>
<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>
<td> <td>
<strong>{{ threshold.threshold_value }}</strong> <strong>{{ threshold.threshold_value }}</strong>
@ -106,25 +280,31 @@
{% if threshold.complaint_category %} {% if threshold.complaint_category %}
{{ threshold.complaint_category.name }} {{ threshold.complaint_category.name }}
{% else %} {% else %}
<span class="text-muted">{% translate "All Categories" %}</span> <span class="text-slate-400">{% translate "All Categories" %}</span>
{% endif %} {% endif %}
</td> </td>
<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>
<td> <td>
{% if threshold.is_active %} {% 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 %} {% 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 %} {% endif %}
</td> </td>
<td> <td class="text-right">
<div class="btn-group"> <div class="flex items-center justify-end gap-2">
<a href="{% url 'complaints:complaint_threshold_edit' threshold.id %}" <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' %}"> title="{% translate 'Edit' %}">
<i class="fas fa-edit"></i> <i data-lucide="edit" class="w-4 h-4"></i>
</a> </a>
<form method="post" <form method="post"
action="{% url 'complaints:complaint_threshold_delete' threshold.id %}" action="{% url 'complaints:complaint_threshold_delete' threshold.id %}"
@ -132,9 +312,9 @@
onsubmit="return confirm('{% translate "Are you sure you want to delete this threshold?" %}')"> onsubmit="return confirm('{% translate "Are you sure you want to delete this threshold?" %}')">
{% csrf_token %} {% csrf_token %}
<button type="submit" <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' %}"> title="{% translate 'Delete' %}">
<i class="fas fa-trash"></i> <i data-lucide="trash-2" class="w-4 h-4"></i>
</button> </button>
</form> </form>
</div> </div>
@ -147,59 +327,42 @@
<!-- Pagination --> <!-- Pagination -->
{% if page_obj.has_other_pages %} {% if page_obj.has_other_pages %}
<nav class="mt-4"> <div class="p-4 border-t border-slate-200">
<ul class="pagination justify-content-center"> <div class="flex items-center justify-between">
{% if page_obj.has_previous %} <p class="text-sm text-slate">
<li class="page-item"> {% blocktrans with start=page_obj.start_index end=page_obj.end_index total=page_obj.paginator.count %}
<a class="page-link" href="?page=1{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"> Showing {{ start }} to {{ end }} of {{ total }} thresholds
<i class="fas fa-angle-double-left"></i> {% 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> </a>
</li> {% endif %}
<li class="page-item"> {% if page_obj.has_next %}
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"> <a href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
<i class="fas fa-angle-left"></i> 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> </a>
</li> {% endif %}
{% endif %} </div>
</div>
{% for num in page_obj.paginator.page_range %} </div>
{% 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 %} {% endif %}
{% else %} {% else %}
<div class="text-center py-5"> <div class="text-center py-12">
<i class="fas fa-chart-line fa-3x text-muted mb-3"></i> <div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<p class="text-muted"> <i data-lucide="trending-up" class="w-8 h-8 text-slate-400"></i>
{% translate "No thresholds found. Create your first threshold to get started." %} </div>
</p> <p class="text-slate font-medium">{% translate "No thresholds found" %}</p>
<a href="{% url 'complaints:complaint_threshold_create' %}" class="btn btn-primary"> <p class="text-slate text-sm mt-1">{% translate "Create your first threshold to get started" %}</p>
<i class="fas fa-plus"></i> <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" %} {% translate "Create Threshold" %}
</a> </a>
</div> </div>
@ -207,4 +370,10 @@
</div> </div>
</div> </div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %} {% endblock %}

View File

@ -2,13 +2,21 @@
عزيزي {{ recipient.get_full_name }}, عزيزي {{ recipient.get_full_name }},
{% if is_unassigned %}
هام: هذا تذكير بشكوى غير معينة تحتاج إلى اهتمامكم.
لم يتم تعيين هذه الشكوى لأي شخص بعد. يرجى تعيينها لأحد أعضاء الفريق المناسبين في أقرب وقت ممكن لضمان معالجتها قبل موعد انتهاء اتفاقية مستوى الخدمة.
بصفتكم مدير مستشفى أو منسق تجربة المرضى، تتلقون هذا الإشعار لأن الشكوى لا تزال معلقة التعيين.
{% else %}
هذه رسالة تذكير آلية لديك شكوى معينة تقترب من موعد نهائي لاتفاقية مستوى الخدمة. هذه رسالة تذكير آلية لديك شكوى معينة تقترب من موعد نهائي لاتفاقية مستوى الخدمة.
{% endif %}
تفاصيل الشكوى: تفاصيل الشكوى:
- الرقم: #{{ complaint.id|slice:":8" }} - الرقم: #{{ complaint.id|slice:":8" }}
- العنوان: {{ complaint.title }} - العنوان: {{ complaint.title }}
- الخطورة: {% if complaint.severity == 'critical' %}حرجة{% elif complaint.severity == 'high' %}عالية{% elif complaint.severity == 'medium' %}متوسطة{% else %}منخفضة{% endif %} - الخطورة: {% 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.category %}{{ complaint.category.name_ar }}{% else %}غير متوفر{% endif %}
- القسم: {% if complaint.department %}{{ complaint.department.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 %} - المريض: {% 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" }} - تاريخ الاستحقاق: {{ due_date|date:"Y F d H:i" }}
- الوقت المتبقي: {{ hours_remaining }} ساعة - الوقت المتبقي: {{ 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 }}/ يمكنك عرض الشكوى على: {{ 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 }}, 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. This is an automated reminder that you have an assigned complaint approaching its SLA deadline.
{% endif %}
COMPLAINT DETAILS: COMPLAINT DETAILS:
- ID: #{{ complaint.id|slice:":8" }} - ID: #{{ complaint.id|slice:":8" }}
@ -19,11 +27,19 @@ SLA INFORMATION:
- Current Status: {{ complaint.get_status_display }} - Current Status: {{ complaint.get_status_display }}
ACTION REQUIRED: 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. 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 }}/ 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. 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. This is an automated message. Please do not reply directly to this email.

View File

@ -2,22 +2,38 @@
عزيزي/عزيزتي {{ recipient.get_full_name }}, عزيزي/عزيزتي {{ recipient.get_full_name }},
هذه التذكير الثاني والأخير بأن لديك شكوى ستنتهي مواعيدها في اتفاقية مستوى الخدمة قريباً جداً. {% if is_unassigned %}
تنبيه هام: هذا تذكير عاجل بشكوى غير معينة تتطلب اهتماماً فورياً.
لم يتم تعيين هذه الشكوى لأي شخص بعد وهي على وشك تجاوز موعد انتهاء اتفاقية مستوى الخدمة. بصفتكم مدير مستشفى أو منسق تجربة المرضى، يجب عليكم اتخاذ إجراء فوري.
هذا التذكير النهائي قبل التصعيد التلقائي.
الإجراء العاجل المطلوب:
1. تعيين هذه الشكوى لأحد الموظفين المناسبين فوراً
2. التأكد من أن الموظف المُعيَّن على دراية بالموعد النهائي الحرج
3. متابعة التقدم بشكل مستمر حتى يتم الحل
عدم تعيين ومعالجة هذه الشكوى قد يؤدي إلى التصعيد التلقائي للإدارة العليا.
{% else %}
هذا التذكير الثاني والأخير بأن لديك شكوى معينة ستنتهي مواعيدها في اتفاقية مستوى الخدمة قريباً جداً.
{% endif %}
تفاصيل الشكوى: تفاصيل الشكوى:
- المعرف: #{{ complaint.id|slice:":8" }} - المعرف: #{{ complaint.id|slice:":8" }}
- العنوان: {{ complaint.title }} - العنوان: {{ complaint.title }}
- الخطورة: {{ complaint.get_severity_display_ar }} - الخطورة: {{ complaint.get_severity_display }}
- الأولوية: {{ complaint.get_priority_display_ar }} - الأولوية: {{ complaint.get_priority_display }}
- الفئة: {% if complaint.category %}{{ complaint.category.name_ar }}{% else %}غير متوفر{% endif %} - الفئة: {% if complaint.category %}{{ complaint.category.name_ar }}{% else %}غير متوفر{% endif %}
- القسم: {% if complaint.department %}{{ complaint.department.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" }} - تاريخ الاستحقاق: {{ due_date|date:"d F Y H:i" }}
- الوقت المتبقي: {{ hours_remaining }} ساعة - الوقت المتبقي: {{ hours_remaining }} ساعة
- الحالة الحالية: {{ complaint.get_status_display_ar }} - الحالة الحالية: {{ complaint.get_status_display }}
{% if not is_unassigned %}
إجراء عاجل مطلوب: إجراء عاجل مطلوب:
هذه الشكوى تبعد {{ hours_remaining }} ساعة عن تجاوز موعد انتهاء اتفاقية مستوى الخدمة. هذه الشكوى تبعد {{ hours_remaining }} ساعة عن تجاوز موعد انتهاء اتفاقية مستوى الخدمة.
يرجى المراجعة واتخاذ إجراء فوري لتجنب التصعيد وعواقب تجاوز الموعد. يرجى المراجعة واتخاذ إجراء فوري لتجنب التصعيد وعواقب تجاوز الموعد.
@ -26,6 +42,7 @@
1. تحديث حالة الشكوى لتعكس التقدم الحالي 1. تحديث حالة الشكوى لتعكس التقدم الحالي
2. إضافة تحديث على الجدول الزمني يوضح التأخير 2. إضافة تحديث على الجدول الزمني يوضح التأخير
3. التواصل مع مدير القسم إذا كانت هناك حاجة إلى موارد إضافية 3. التواصل مع مدير القسم إذا كانت هناك حاجة إلى موارد إضافية
{% endif %}
يمكنك عرض الشكوى على: {{ site_url }}/complaints/{{ complaint.id }}/ يمكنك عرض الشكوى على: {{ 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 }}, 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: COMPLAINT DETAILS:
- ID: #{{ complaint.id|slice:":8" }} - ID: #{{ complaint.id|slice:":8" }}
@ -11,13 +26,14 @@ COMPLAINT DETAILS:
- Priority: {{ complaint.get_priority_display }} - Priority: {{ complaint.get_priority_display }}
- Category: {% if complaint.category %}{{ complaint.category.name_en }}{% else %}N/A{% endif %} - Category: {% if complaint.category %}{{ complaint.category.name_en }}{% else %}N/A{% endif %}
- Department: {% if complaint.department %}{{ complaint.department.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: SLA INFORMATION:
- Due Date: {{ due_date|date:"F d, Y H:i" }} - Due Date: {{ due_date|date:"F d, Y H:i" }}
- Time Remaining: {{ hours_remaining }} hours - Time Remaining: {{ hours_remaining }} hours
- Current Status: {{ complaint.get_status_display }} - Current Status: {{ complaint.get_status_display }}
{% if not is_unassigned %}
URGENT ACTION REQUIRED: URGENT ACTION REQUIRED:
This complaint is {{ hours_remaining }} hours from breaching its SLA deadline. This complaint is {{ hours_remaining }} hours from breaching its SLA deadline.
Please review and take immediate action to avoid escalation and SLA breach consequences. 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 1. Update the complaint status to reflect current progress
2. Add a timeline update explaining the delay 2. Add a timeline update explaining the delay
3. Contact your department manager if additional resources are needed 3. Contact your department manager if additional resources are needed
{% endif %}
You can view the complaint at: {{ site_url }}/complaints/{{ complaint.id }}/ 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 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 %} {% block content %}
<div class="page-header"> <!-- Gradient Header -->
<div class="page-header-content"> <div class="page-header-gradient">
<div> <div class="flex items-center justify-between">
<h1 class="page-title"> <div class="flex items-center gap-4">
<i class="fas fa-arrow-up"></i> <div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
{{ title }} <i data-lucide="arrow-up-circle" class="w-6 h-6"></i>
</h1> </div>
<p class="page-description"> <div>
{% if escalation_rule %} <h1 class="text-2xl font-bold">{{ title }}</h1>
{% translate "Edit escalation rule" %} <p class="text-white/80">
{% else %} {% if escalation_rule %}{% translate "Edit escalation rule" %}{% else %}{% translate "Create new escalation rule" %}{% endif %}
{% translate "Create new escalation rule" %} </p>
{% endif %} </div>
</p>
</div> </div>
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'complaints:escalation_rule_list' %}" class="btn-secondary">
<i class="fas fa-arrow-left"></i> <i data-lucide="arrow-left" class="w-4 h-4"></i>
{% translate "Back to List" %} {% translate "Back to List" %}
</a> </a>
</div> </div>
</div> </div>
<div class="page-content"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="row"> <div class="lg:col-span-2">
<div class="col-lg-8"> <div class="form-section">
<div class="card"> <form method="post" class="space-y-6">
<div class="card-body"> {% csrf_token %}
<form method="post" class="row g-3">
{% csrf_token %} {% if not form.hospital.is_hidden %}
<div>
{% if not form.hospital.is_hidden %} <label for="id_hospital" class="form-label">
<div class="col-md-12"> {% translate "Hospital" %} <span class="text-red-500">*</span>
<label for="id_hospital" class="form-label"> </label>
{% translate "Hospital" %} <span class="text-danger">*</span> <select name="hospital" id="id_hospital" class="form-select" required>
</label> <option value="">{% translate "Select Hospital" %}</option>
<select name="hospital" id="id_hospital" class="form-select" required> {% for hospital in form.hospital.field.queryset %}
<option value="">{% translate "Select Hospital" %}</option> <option value="{{ hospital.id }}"
{% for hospital in form.hospital.field.queryset %} {% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}>
<option value="{{ hospital.id }}" {{ hospital.name }}
{% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}> </option>
{{ hospital.name }} {% endfor %}
</option> </select>
{% endfor %} {% if form.hospital.errors %}
</select> <div class="invalid-feedback">
{% if form.hospital.errors %} <i data-lucide="alert-circle" class="w-4 h-4 inline"></i>
<div class="invalid-feedback d-block"> {{ form.hospital.errors.0 }}
{{ form.hospital.errors.0 }} </div>
</div> {% endif %}
{% 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> </div>
{% endif %} {% endif %}
</div>
<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>
<hr> <div>
<label for="id_trigger_hours" class="form-label">
<h6 class="card-subtitle mb-2 text-muted"> {% translate "Trigger Hours" %} <span class="text-red-500">*</span>
{% translate "Escalation Flow" %} </label>
</h6> <input type="number"
<div class="alert alert-info"> name="trigger_hours"
<ol class="mb-0"> id="id_trigger_hours"
<li>{% translate "Complaint created" %}</li> class="form-control"
<li>{% translate "Trigger hours pass" %}</li> value="{{ form.trigger_hours.value|default:'' }}"
<li>{% translate "Rule checks severity/priority" %}</li> min="1"
<li>{% translate "Complaint reassigned automatically" %}</li> step="0.5"
<li>{% translate "Notification sent to new assignee" %}</li> required
</ol> 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> </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>
</div> </div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %} {% endblock %}

View File

@ -3,62 +3,265 @@
{% block title %}{% translate "Escalation Rules" %} - PX360{% endblock %} {% block title %}{% translate "Escalation Rules" %} - PX360{% endblock %}
{% block content %} {% block extra_css %}
<div class="page-header"> <style>
<div class="page-header-content"> :root {
<div> --hh-navy: #005696;
<h1 class="page-title"> --hh-blue: #007bbd;
<i class="fas fa-arrow-up"></i> --hh-light: #eef6fb;
{% translate "Escalation Rules" %} --hh-slate: #64748b;
</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>
<div class="page-content"> .page-header-gradient {
<!-- Filters --> background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
<div class="card mb-4"> color: white;
<div class="card-header"> padding: 1.5rem 2rem;
<h5 class="card-title"> border-radius: 1rem;
<i class="fas fa-filter"></i> margin-bottom: 1.5rem;
{% translate "Filters" %} box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
</h5> }
.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>
<div class="card-body"> </div>
<form method="get" class="row g-3">
<div class="col-md-4"> <!-- Filters -->
<label class="form-label">{% translate "Escalation Level" %}</label> <div class="section-card mb-6 animate-in">
<select name="escalation_level" class="form-select"> <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="">{% translate "All Levels" %}</option>
<option value="1" {% if filters.escalation_level == "1" %}selected{% endif %}>1</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="2" {% if filters.escalation_level == "2" %}selected{% endif %}>2</option>
<option value="3" {% if filters.escalation_level == "3" %}selected{% endif %}>3</option> <option value="3" {% if filters.escalation_level == "3" %}selected{% endif %}>3</option>
</select> </select>
</div> </div>
<div>
<div class="col-md-4"> <label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Status" %}</label>
<label class="form-label">{% 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">
<select name="is_active" class="form-select">
<option value="">{% translate "All" %}</option> <option value="">{% translate "All" %}</option>
<option value="true" {% if filters.is_active == "true" %}selected{% endif %}>{% translate "Active" %}</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> <option value="false" {% if filters.is_active == "false" %}selected{% endif %}>{% translate "Inactive" %}</option>
</select> </select>
</div> </div>
<div class="flex items-end gap-2">
<div class="col-12 text-end"> <button type="submit" class="btn-primary h-[46px]">
<button type="submit" class="btn btn-primary"> <i data-lucide="search" class="w-4 h-4"></i>
<i class="fas fa-search"></i>
{% translate "Apply Filters" %} {% translate "Apply Filters" %}
</button> </button>
<a href="{% url 'complaints:escalation_rule_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'complaints:escalation_rule_list' %}" class="btn-secondary h-[46px]">
<i class="fas fa-times"></i> <i data-lucide="x" class="w-4 h-4"></i>
{% translate "Clear" %} {% translate "Clear" %}
</a> </a>
</div> </div>
@ -67,11 +270,17 @@
</div> </div>
<!-- Escalation Rules Table --> <!-- Escalation Rules Table -->
<div class="card"> <div class="section-card animate-in">
<div class="card-body"> <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 %} {% if escalation_rules %}
<div class="table-responsive"> <div class="overflow-x-auto">
<table class="table table-hover"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th>{% translate "Hospital" %}</th> <th>{% translate "Hospital" %}</th>
@ -82,7 +291,7 @@
<th>{% translate "Severity" %}</th> <th>{% translate "Severity" %}</th>
<th>{% translate "Priority" %}</th> <th>{% translate "Priority" %}</th>
<th>{% translate "Status" %}</th> <th>{% translate "Status" %}</th>
<th>{% translate "Actions" %}</th> <th class="text-right">{% translate "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -93,7 +302,7 @@
</td> </td>
<td>{{ rule.name }}</td> <td>{{ rule.name }}</td>
<td> <td>
<span class="badge bg-info"> <span class="badge badge-info">
{% translate "Level" %} {{ rule.escalation_level }} {% translate "Level" %} {{ rule.escalation_level }}
</span> </span>
</td> </td>
@ -102,9 +311,9 @@
{% if rule.escalate_to_user %} {% if rule.escalate_to_user %}
{{ rule.escalate_to_user.get_full_name }} {{ rule.escalate_to_user.get_full_name }}
{% elif rule.escalate_to_role %} {% 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 %} {% else %}
<span class="text-muted">-</span> <span class="text-slate-400">-</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
@ -113,7 +322,7 @@
{{ rule.get_severity_display }} {{ rule.get_severity_display }}
</span> </span>
{% else %} {% else %}
<span class="text-muted">{% translate "All" %}</span> <span class="text-slate-400">{% translate "All" %}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
@ -122,22 +331,28 @@
{{ rule.get_priority_display }} {{ rule.get_priority_display }}
</span> </span>
{% else %} {% else %}
<span class="text-muted">{% translate "All" %}</span> <span class="text-slate-400">{% translate "All" %}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if rule.is_active %} {% 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 %} {% 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 %} {% endif %}
</td> </td>
<td> <td class="text-right">
<div class="btn-group"> <div class="flex items-center justify-end gap-2">
<a href="{% url 'complaints:escalation_rule_edit' rule.id %}" <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' %}"> title="{% translate 'Edit' %}">
<i class="fas fa-edit"></i> <i data-lucide="edit" class="w-4 h-4"></i>
</a> </a>
<form method="post" <form method="post"
action="{% url 'complaints:escalation_rule_delete' rule.id %}" action="{% url 'complaints:escalation_rule_delete' rule.id %}"
@ -145,9 +360,9 @@
onsubmit="return confirm('{% translate "Are you sure you want to delete this escalation rule?" %}')"> onsubmit="return confirm('{% translate "Are you sure you want to delete this escalation rule?" %}')">
{% csrf_token %} {% csrf_token %}
<button type="submit" <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' %}"> title="{% translate 'Delete' %}">
<i class="fas fa-trash"></i> <i data-lucide="trash-2" class="w-4 h-4"></i>
</button> </button>
</form> </form>
</div> </div>
@ -160,59 +375,42 @@
<!-- Pagination --> <!-- Pagination -->
{% if page_obj.has_other_pages %} {% if page_obj.has_other_pages %}
<nav class="mt-4"> <div class="p-4 border-t border-slate-200">
<ul class="pagination justify-content-center"> <div class="flex items-center justify-between">
{% if page_obj.has_previous %} <p class="text-sm text-slate">
<li class="page-item"> {% blocktrans with start=page_obj.start_index end=page_obj.end_index total=page_obj.paginator.count %}
<a class="page-link" href="?page=1{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"> Showing {{ start }} to {{ end }} of {{ total }} rules
<i class="fas fa-angle-double-left"></i> {% 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> </a>
</li> {% endif %}
<li class="page-item"> {% if page_obj.has_next %}
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"> <a href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
<i class="fas fa-angle-left"></i> 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> </a>
</li> {% endif %}
{% endif %} </div>
</div>
{% for num in page_obj.paginator.page_range %} </div>
{% 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 %} {% endif %}
{% else %} {% else %}
<div class="text-center py-5"> <div class="text-center py-12">
<i class="fas fa-arrow-up fa-3x text-muted mb-3"></i> <div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<p class="text-muted"> <i data-lucide="arrow-up-circle" class="w-8 h-8 text-slate-400"></i>
{% translate "No escalation rules found. Create your first rule to get started." %} </div>
</p> <p class="text-slate font-medium">{% translate "No escalation rules found" %}</p>
<a href="{% url 'complaints:escalation_rule_create' %}" class="btn btn-primary"> <p class="text-slate text-sm mt-1">{% translate "Create your first rule to get started" %}</p>
<i class="fas fa-plus"></i> <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" %} {% translate "Create Escalation Rule" %}
</a> </a>
</div> </div>
@ -220,4 +418,10 @@
</div> </div>
</div> </div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %} {% endblock %}

View File

@ -24,23 +24,86 @@
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body { font-family: 'Inter', sans-serif; } 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> </style>
</head> </head>
<body class="bg-light min-h-screen flex items-center py-8 px-4"> <body class="bg-light min-h-screen flex items-center py-8 px-4">
<div class="w-full max-w-3xl mx-auto"> <div class="w-full max-w-3xl mx-auto">
<!-- Logo/Header --> <!-- Gradient Header -->
<div class="text-center mb-8"> <div class="page-header-gradient text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-navy rounded-2xl mb-4"> <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 text-white"></i> <i data-lucide="message-square" class="w-8 h-8"></i>
</div> </div>
<h1 class="text-2xl font-bold text-navy">{% trans "Submit Your Explanation" %}</h1> <h1 class="text-2xl font-bold">{% trans "Submit Your Explanation" %}</h1>
<p class="text-slate mt-1">{% trans "PX360 Complaint Management System" %}</p> <p class="text-white/80 mt-1">{% trans "PX360 Complaint Management System" %}</p>
</div> </div>
<!-- Main Card --> <!-- Main Form Section -->
<div class="bg-white rounded-2xl shadow-lg border border-slate-100 overflow-hidden"> <div class="form-section">
{% if error %} {% 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"> <div class="flex items-center gap-3">
<i data-lucide="alert-circle" class="w-5 h-5 text-red-500 flex-shrink-0"></i> <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> <p class="text-red-700 font-medium">{{ error }}</p>
@ -50,7 +113,7 @@
<!-- Staff Information --> <!-- Staff Information -->
{% if explanation.staff %} {% 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"> <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> <i data-lucide="user" class="w-4 h-4"></i>
{% trans "Requested From" %} {% trans "Requested From" %}
@ -86,7 +149,7 @@
<!-- Original Staff Explanation (shown to manager during escalation) --> <!-- Original Staff Explanation (shown to manager during escalation) -->
{% if original_explanation %} {% 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="flex items-center gap-2 mb-4">
<div class="w-8 h-8 bg-orange-500 rounded-full flex items-center justify-center"> <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> <i data-lucide="arrow-up-circle" class="w-4 h-4 text-white"></i>
@ -123,7 +186,7 @@
{% endif %} {% endif %}
<!-- Complaint Details --> <!-- 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"> <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> <i data-lucide="file-text" class="w-5 h-5 text-blue"></i>
{% trans "Complaint Details" %} {% trans "Complaint Details" %}
@ -137,26 +200,6 @@
<span class="text-slate text-sm">{% trans "Title:" %}</span> <span class="text-slate text-sm">{% trans "Title:" %}</span>
<span class="font-bold text-navy">{{ complaint.title }}</span> <span class="font-bold text-navy">{{ complaint.title }}</span>
</div> </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 %} {% if complaint.patient %}
<div class="md:col-span-2 flex items-center gap-2"> <div class="md:col-span-2 flex items-center gap-2">
<span class="text-slate text-sm">{% trans "Patient:" %}</span> <span class="text-slate text-sm">{% trans "Patient:" %}</span>
@ -174,12 +217,12 @@
</div> </div>
<!-- Form --> <!-- 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 %} {% csrf_token %}
<!-- Explanation Field --> <!-- Explanation Field -->
<div> <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> {% trans "Your Explanation" %} <span class="text-red-500">*</span>
</label> </label>
<p class="text-slate text-sm mb-3"> <p class="text-slate text-sm mb-3">
@ -190,14 +233,14 @@
name="explanation" name="explanation"
rows="6" rows="6"
required 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...' %}" placeholder="{% trans 'Write your explanation here...' %}"
></textarea> ></textarea>
</div> </div>
<!-- Attachments --> <!-- Attachments -->
<div> <div>
<label for="attachments" class="block text-sm font-bold text-navy mb-2"> <label for="attachments" class="form-label">
{% trans "Attachments (Optional)" %} {% trans "Attachments (Optional)" %}
</label> </label>
<p class="text-slate text-sm mb-3"> <p class="text-slate text-sm mb-3">
@ -209,7 +252,7 @@
id="attachments" id="attachments"
name="attachments" name="attachments"
multiple 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> </div>
<p class="text-xs text-slate mt-2"> <p class="text-xs text-slate mt-2">
@ -231,14 +274,14 @@
</div> </div>
<!-- Submit Button --> <!-- 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> <i data-lucide="send" class="w-5 h-5"></i>
{% trans "Submit Explanation" %} {% trans "Submit Explanation" %}
</button> </button>
</form> </form>
<!-- Footer --> <!-- 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> <p class="text-slate text-xs">{% trans "PX360 Complaint Management System" %}</p>
</div> </div>
</div> </div>

View File

@ -5,153 +5,246 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.form-select { .page-header-gradient {
@apply 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 bg-white text-sm; background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
} color: white;
.form-textarea { padding: 1.5rem 2rem;
@apply 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; border-radius: 1rem;
} margin-bottom: 1.5rem;
.form-checkbox { box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
@apply w-5 h-5 text-navy border-slate-300 rounded focus:ring-navy; }
}
.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;
}
.form-checkbox {
width: 1.25rem;
height: 1.25rem;
accent-color: #005696;
cursor: pointer;
}
.invalid-feedback {
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Back Button --> <div class="p-6 max-w-4xl mx-auto">
<div class="mb-6"> <!-- Back Button -->
<a href="{% url 'complaints:complaint_detail' pk=complaint.pk %}" class="inline-flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-xl text-slate hover:bg-light transition text-sm font-semibold"> <div class="mb-6">
<i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to Complaint" %} <a href="{% url 'complaints:complaint_detail' pk=complaint.pk %}" class="btn-secondary">
</a> <i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to Complaint" %}
</div> </a>
</div>
<!-- Header --> <!-- Gradient Header -->
<div class="mb-6"> <div class="page-header-gradient">
<h1 class="text-2xl font-bold text-navy">{{ title }}</h1> <h1 class="text-2xl font-bold">{{ title }}</h1>
<p class="text-slate mt-1"> <p class="text-white/80 mt-1">
{% trans "Complaint:" %} <span class="font-medium">{{ complaint.reference_number }}</span> - {{ complaint.title|truncatechars:50 }} {% trans "Complaint:" %} <span class="font-medium">{{ complaint.reference_number }}</span> - {{ complaint.title|truncatechars:50 }}
</p> </p>
</div> </div>
<!-- Form Card --> <!-- Form Section -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-8 max-w-3xl"> <div class="form-section">
<form method="post" class="space-y-6"> <form method="post" class="space-y-6">
{% csrf_token %} {% csrf_token %}
<!-- Department --> <!-- Department -->
<div>
<label for="{{ form.department.id_for_label }}" class="block text-sm font-semibold text-navy mb-2">
{% trans "Department" %} <span class="text-red-500">*</span>
</label>
<select name="{{ form.department.name }}" id="{{ form.department.id_for_label }}" 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 bg-white text-sm" required>
<option value="">{% trans "Select Department" %}</option>
{% for choice in form.department.field.choices %}
<option value="{{ choice.0 }}" {% if form.department.value == choice.0 %}selected{% endif %}>{{ choice.1 }}</option>
{% endfor %}
</select>
{% if form.department.errors %}
<div class="text-red-500 text-sm mt-2 flex items-center gap-1">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ form.department.errors }}
</div>
{% endif %}
<p class="text-slate text-xs mt-2">{% trans "Select the department to involve in this complaint." %}</p>
</div>
<!-- Role -->
<div>
<label for="{{ form.role.id_for_label }}" class="block text-sm font-semibold text-navy mb-2">
{% trans "Role" %} <span class="text-red-500">*</span>
</label>
<select name="{{ form.role.name }}" id="{{ form.role.id_for_label }}" 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 bg-white text-sm" required>
<option value="">{% trans "Select Role" %}</option>
{% for choice in form.role.field.choices %}
<option value="{{ choice.0 }}" {% if form.role.value == choice.0 %}selected{% endif %}>{{ choice.1 }}</option>
{% endfor %}
</select>
{% if form.role.errors %}
<div class="text-red-500 text-sm mt-2 flex items-center gap-1">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ form.role.errors }}
</div>
{% endif %}
<div class="mt-3 bg-light/50 rounded-xl p-4 text-sm text-slate space-y-2">
<p class="flex items-start gap-2">
<span class="font-semibold text-navy min-w-[80px]">{% trans "Primary:" %}</span>
<span>{% trans "Main responsible department for resolution" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-navy min-w-[80px]">{% trans "Secondary:" %}</span>
<span>{% trans "Assisting the primary department" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-navy min-w-[80px]">{% trans "Coordination:" %}</span>
<span>{% trans "Only for coordination purposes" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-navy min-w-[80px]">{% trans "Investigating:" %}</span>
<span>{% trans "Leading the investigation" %}</span>
</p>
</div>
</div>
<!-- Is Primary -->
<div class="flex items-start gap-3 p-4 bg-light/30 rounded-xl border border-slate-100">
<input type="checkbox" name="{{ form.is_primary.name }}" id="{{ form.is_primary.id_for_label }}" class="w-5 h-5 mt-0.5 text-navy border-slate-300 rounded focus:ring-navy" {% if form.is_primary.value %}checked{% endif %}>
<div> <div>
<label for="{{ form.is_primary.id_for_label }}" class="text-sm font-semibold text-navy"> <label for="{{ form.department.id_for_label }}" class="form-label">
{% trans "Mark as Primary Department" %} {% trans "Department" %} <span class="text-red-500">*</span>
</label> </label>
<p class="text-slate text-xs mt-1">{% trans "The primary department takes lead responsibility for this complaint." %}</p> <select name="{{ form.department.name }}" id="{{ form.department.id_for_label }}" class="form-select" required>
</div> <option value="">{% trans "Select Department" %}</option>
</div> {% for choice in form.department.field.choices %}
<option value="{{ choice.0 }}" {% if form.department.value == choice.0 %}selected{% endif %}>{{ choice.1 }}</option>
<!-- Assigned To --> {% endfor %}
<div> </select>
<label for="{{ form.assigned_to.id_for_label }}" class="block text-sm font-semibold text-navy mb-2"> {% if form.department.errors %}
{% trans "Assign To" %} <span class="text-slate font-normal">({% trans "Optional" %})</span> <div class="invalid-feedback">
</label> <i data-lucide="alert-circle" class="w-4 h-4"></i>
<select name="{{ form.assigned_to.name }}" id="{{ form.assigned_to.id_for_label }}" 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 bg-white text-sm"> {{ form.department.errors }}
<option value="">{% trans "Select User (Optional)" %}</option> </div>
{% for choice in form.assigned_to.field.choices %}
{% if choice.0 %}
<option value="{{ choice.0 }}" {% if form.assigned_to.value == choice.0 %}selected{% endif %}>{{ choice.1 }}</option>
{% endif %} {% endif %}
{% endfor %} <p class="text-sm text-gray-500 mt-2">{% trans "Select the department to involve in this complaint." %}</p>
</select>
{% if form.assigned_to.errors %}
<div class="text-red-500 text-sm mt-2 flex items-center gap-1">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ form.assigned_to.errors }}
</div> </div>
{% endif %}
<p class="text-slate text-xs mt-2">{% trans "Optionally assign a specific user from this department." %}</p>
</div>
<!-- Notes --> <!-- Role -->
<div> <div>
<label for="{{ form.notes.id_for_label }}" class="block text-sm font-semibold text-navy mb-2"> <label for="{{ form.role.id_for_label }}" class="form-label">
{% trans "Notes" %} <span class="text-slate font-normal">({% trans "Optional" %})</span> {% trans "Role" %} <span class="text-red-500">*</span>
</label> </label>
<textarea name="{{ form.notes.name }}" id="{{ form.notes.id_for_label }}" rows="3" 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" placeholder="{% trans 'Enter any additional notes...' %}">{{ form.notes.value|default:'' }}</textarea> <select name="{{ form.role.name }}" id="{{ form.role.id_for_label }}" class="form-select" required>
{% if form.notes.errors %} <option value="">{% trans "Select Role" %}</option>
<div class="text-red-500 text-sm mt-2 flex items-center gap-1"> {% for choice in form.role.field.choices %}
<i data-lucide="alert-circle" class="w-4 h-4"></i> <option value="{{ choice.0 }}" {% if form.role.value == choice.0 %}selected{% endif %}>{{ choice.1 }}</option>
{{ form.notes.errors }} {% endfor %}
</select>
{% if form.role.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ form.role.errors }}
</div>
{% endif %}
<div class="mt-3 bg-gray-50 rounded-xl p-4 text-sm text-gray-600 space-y-2">
<p class="flex items-start gap-2">
<span class="font-semibold text-[#005696] min-w-[80px]">{% trans "Primary:" %}</span>
<span>{% trans "Main responsible department for resolution" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-[#005696] min-w-[80px]">{% trans "Secondary:" %}</span>
<span>{% trans "Assisting the primary department" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-[#005696] min-w-[80px]">{% trans "Coordination:" %}</span>
<span>{% trans "Only for coordination purposes" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-[#005696] min-w-[80px]">{% trans "Investigating:" %}</span>
<span>{% trans "Leading the investigation" %}</span>
</p>
</div>
</div> </div>
{% endif %}
</div>
<!-- Actions --> <!-- Is Primary -->
<div class="flex items-center gap-4 pt-6 border-t border-slate-100"> <div class="flex items-start gap-3 p-4 bg-gray-50 rounded-xl border border-gray-200">
<button type="submit" class="bg-gradient-to-r from-navy to-blue text-white px-8 py-3 rounded-xl hover:opacity-90 transition font-semibold flex items-center gap-2 shadow-md"> <input type="checkbox" name="{{ form.is_primary.name }}" id="{{ form.is_primary.id_for_label }}" class="form-checkbox mt-0.5" {% if form.is_primary.value %}checked{% endif %}>
<i data-lucide="save" class="w-5 h-5"></i> <div>
{% trans "Save" %} <label for="{{ form.is_primary.id_for_label }}" class="text-sm font-semibold text-gray-800">
</button> {% trans "Mark as Primary Department" %}
<a href="{% url 'complaints:complaint_detail' pk=complaint.pk %}" class="px-8 py-3 border border-slate-200 rounded-xl text-slate hover:bg-light transition font-semibold"> </label>
{% trans "Cancel" %} <p class="text-gray-500 text-xs mt-1">{% trans "The primary department takes lead responsibility for this complaint." %}</p>
</a> </div>
</div> </div>
</form>
<!-- Assigned To -->
<div>
<label for="{{ form.assigned_to.id_for_label }}" class="form-label">
{% trans "Assign To" %} <span class="text-gray-500 font-normal">({% trans "Optional" %})</span>
</label>
<select name="{{ form.assigned_to.name }}" id="{{ form.assigned_to.id_for_label }}" class="form-select">
<option value="">{% trans "Select User (Optional)" %}</option>
{% for choice in form.assigned_to.field.choices %}
{% if choice.0 %}
<option value="{{ choice.0 }}" {% if form.assigned_to.value == choice.0 %}selected{% endif %}>{{ choice.1 }}</option>
{% endif %}
{% endfor %}
</select>
{% if form.assigned_to.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ form.assigned_to.errors }}
</div>
{% endif %}
<p class="text-sm text-gray-500 mt-2">{% trans "Optionally assign a specific user from this department." %}</p>
</div>
<!-- Notes -->
<div>
<label for="{{ form.notes.id_for_label }}" class="form-label">
{% trans "Notes" %} <span class="text-gray-500 font-normal">({% trans "Optional" %})</span>
</label>
<textarea name="{{ form.notes.name }}" id="{{ form.notes.id_for_label }}" rows="3" class="form-control resize-none" placeholder="{% trans 'Enter any additional notes...' %}">{{ form.notes.value|default:'' }}</textarea>
{% if form.notes.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ form.notes.errors }}
</div>
{% endif %}
</div>
<!-- Actions -->
<div class="flex items-center gap-4 pt-6 border-t border-gray-100">
<button type="submit" class="btn-primary">
<i data-lucide="save" class="w-4 h-4"></i>
{% trans "Save" %}
</button>
<a href="{% url 'complaints:complaint_detail' pk=complaint.pk %}" class="btn-secondary">
<i data-lucide="x" class="w-4 h-4"></i>
{% trans "Cancel" %}
</a>
</div>
</form>
</div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %} {% endblock %}

View File

@ -5,125 +5,214 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.form-select { .page-header-gradient {
@apply 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 bg-white text-sm; background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
} color: white;
.form-textarea { padding: 1.5rem 2rem;
@apply 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; 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;
}
.invalid-feedback {
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Back Button --> <div class="p-6 max-w-4xl mx-auto">
<div class="mb-6"> <!-- Back Button -->
<a href="{% url 'complaints:complaint_detail' pk=complaint.pk %}" class="inline-flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-xl text-slate hover:bg-light transition text-sm font-semibold"> <div class="mb-6">
<i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to Complaint" %} <a href="{% url 'complaints:complaint_detail' pk=complaint.pk %}" class="btn-secondary">
</a> <i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to Complaint" %}
</a>
</div>
<!-- Gradient Header -->
<div class="page-header-gradient">
<h1 class="text-2xl font-bold">{{ title }}</h1>
<p class="text-white/80 mt-1">
{% trans "Complaint:" %} <span class="font-medium">{{ complaint.reference_number }}</span> - {{ complaint.title|truncatechars:50 }}
</p>
</div>
<!-- Form Section -->
<div class="form-section">
<form method="post" class="space-y-6">
{% csrf_token %}
<!-- Staff Member -->
<div>
<label for="{{ form.staff.id_for_label }}" class="form-label">
{% trans "Staff Member" %} <span class="text-red-500">*</span>
</label>
<select name="{{ form.staff.name }}" id="{{ form.staff.id_for_label }}" class="form-select" required>
<option value="">{% trans "Select Staff Member" %}</option>
{% for choice in form.staff.field.choices %}
<option value="{{ choice.0 }}" {% if form.staff.value == choice.0 %}selected{% endif %}>{{ choice.1 }}</option>
{% endfor %}
</select>
{% if form.staff.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ form.staff.errors }}
</div>
{% endif %}
<p class="text-sm text-gray-500 mt-2">{% trans "Select the staff member involved in this complaint." %}</p>
</div>
<!-- Role -->
<div>
<label for="{{ form.role.id_for_label }}" class="form-label">
{% trans "Role" %} <span class="text-red-500">*</span>
</label>
<select name="{{ form.role.name }}" id="{{ form.role.id_for_label }}" class="form-select" required>
<option value="">{% trans "Select Role" %}</option>
{% for choice in form.role.field.choices %}
<option value="{{ choice.0 }}" {% if form.role.value == choice.0 %}selected{% endif %}>{{ choice.1 }}</option>
{% endfor %}
</select>
{% if form.role.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ form.role.errors }}
</div>
{% endif %}
<div class="mt-3 bg-gray-50 rounded-xl p-4 text-sm text-gray-600 space-y-2">
<p class="flex items-start gap-2">
<span class="font-semibold text-[#005696] min-w-[120px]">{% trans "Accused/Involved:" %}</span>
<span>{% trans "Staff member involved in the incident" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-[#005696] min-w-[120px]">{% trans "Witness:" %}</span>
<span>{% trans "Staff member who witnessed the incident" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-[#005696] min-w-[120px]">{% trans "Responsible:" %}</span>
<span>{% trans "Staff responsible for resolving the complaint" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-[#005696] min-w-[120px]">{% trans "Investigator:" %}</span>
<span>{% trans "Staff investigating the complaint" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-[#005696] min-w-[120px]">{% trans "Support:" %}</span>
<span>{% trans "Supporting the resolution process" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-[#005696] min-w-[120px]">{% trans "Coordinator:" %}</span>
<span>{% trans "Coordinating between departments" %}</span>
</p>
</div>
</div>
<!-- Notes -->
<div>
<label for="{{ form.notes.id_for_label }}" class="form-label">
{% trans "Notes" %} <span class="text-gray-500 font-normal">({% trans "Optional" %})</span>
</label>
<textarea name="{{ form.notes.name }}" id="{{ form.notes.id_for_label }}" rows="3" class="form-control resize-none" placeholder="{% trans 'Enter any additional notes...' %}">{{ form.notes.value|default:'' }}</textarea>
{% if form.notes.errors %}
<div class="invalid-feedback">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ form.notes.errors }}
</div>
{% endif %}
</div>
<!-- Actions -->
<div class="flex items-center gap-4 pt-6 border-t border-gray-100">
<button type="submit" class="btn-primary">
<i data-lucide="save" class="w-4 h-4"></i>
{% trans "Save" %}
</button>
<a href="{% url 'complaints:complaint_detail' pk=complaint.pk %}" class="btn-secondary">
<i data-lucide="x" class="w-4 h-4"></i>
{% trans "Cancel" %}
</a>
</div>
</form>
</div>
</div> </div>
<!-- Header --> <script>
<div class="mb-6"> document.addEventListener('DOMContentLoaded', function() {
<h1 class="text-2xl font-bold text-navy">{{ title }}</h1> lucide.createIcons();
<p class="text-slate mt-1"> });
{% trans "Complaint:" %} <span class="font-medium">{{ complaint.reference_number }}</span> - {{ complaint.title|truncatechars:50 }} </script>
</p>
</div>
<!-- Form Card -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-8 max-w-3xl">
<form method="post" class="space-y-6">
{% csrf_token %}
<!-- Staff Member -->
<div>
<label for="{{ form.staff.id_for_label }}" class="block text-sm font-semibold text-navy mb-2">
{% trans "Staff Member" %} <span class="text-red-500">*</span>
</label>
<select name="{{ form.staff.name }}" id="{{ form.staff.id_for_label }}" 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 bg-white text-sm" required>
<option value="">{% trans "Select Staff Member" %}</option>
{% for choice in form.staff.field.choices %}
<option value="{{ choice.0 }}" {% if form.staff.value == choice.0 %}selected{% endif %}>{{ choice.1 }}</option>
{% endfor %}
</select>
{% if form.staff.errors %}
<div class="text-red-500 text-sm mt-2 flex items-center gap-1">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ form.staff.errors }}
</div>
{% endif %}
<p class="text-slate text-xs mt-2">{% trans "Select the staff member involved in this complaint." %}</p>
</div>
<!-- Role -->
<div>
<label for="{{ form.role.id_for_label }}" class="block text-sm font-semibold text-navy mb-2">
{% trans "Role" %} <span class="text-red-500">*</span>
</label>
<select name="{{ form.role.name }}" id="{{ form.role.id_for_label }}" 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 bg-white text-sm" required>
<option value="">{% trans "Select Role" %}</option>
{% for choice in form.role.field.choices %}
<option value="{{ choice.0 }}" {% if form.role.value == choice.0 %}selected{% endif %}>{{ choice.1 }}</option>
{% endfor %}
</select>
{% if form.role.errors %}
<div class="text-red-500 text-sm mt-2 flex items-center gap-1">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ form.role.errors }}
</div>
{% endif %}
<div class="mt-3 bg-light/50 rounded-xl p-4 text-sm text-slate space-y-2">
<p class="flex items-start gap-2">
<span class="font-semibold text-navy min-w-[120px]">{% trans "Accused/Involved:" %}</span>
<span>{% trans "Staff member involved in the incident" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-navy min-w-[120px]">{% trans "Witness:" %}</span>
<span>{% trans "Staff member who witnessed the incident" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-navy min-w-[120px]">{% trans "Responsible:" %}</span>
<span>{% trans "Staff responsible for resolving the complaint" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-navy min-w-[120px]">{% trans "Investigator:" %}</span>
<span>{% trans "Staff investigating the complaint" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-navy min-w-[120px]">{% trans "Support:" %}</span>
<span>{% trans "Supporting the resolution process" %}</span>
</p>
<p class="flex items-start gap-2">
<span class="font-semibold text-navy min-w-[120px]">{% trans "Coordinator:" %}</span>
<span>{% trans "Coordinating between departments" %}</span>
</p>
</div>
</div>
<!-- Notes -->
<div>
<label for="{{ form.notes.id_for_label }}" class="block text-sm font-semibold text-navy mb-2">
{% trans "Notes" %} <span class="text-slate font-normal">({% trans "Optional" %})</span>
</label>
<textarea name="{{ form.notes.name }}" id="{{ form.notes.id_for_label }}" rows="3" 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" placeholder="{% trans 'Enter any additional notes...' %}">{{ form.notes.value|default:'' }}</textarea>
{% if form.notes.errors %}
<div class="text-red-500 text-sm mt-2 flex items-center gap-1">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
{{ form.notes.errors }}
</div>
{% endif %}
</div>
<!-- Actions -->
<div class="flex items-center gap-4 pt-6 border-t border-slate-100">
<button type="submit" class="bg-gradient-to-r from-navy to-blue text-white px-8 py-3 rounded-xl hover:opacity-90 transition font-semibold flex items-center gap-2 shadow-md">
<i data-lucide="save" class="w-5 h-5"></i>
{% trans "Save" %}
</button>
<a href="{% url 'complaints:complaint_detail' pk=complaint.pk %}" class="px-8 py-3 border border-slate-200 rounded-xl text-slate hover:bg-light transition font-semibold">
{% trans "Cancel" %}
</a>
</div>
</form>
</div>
{% endblock %} {% endblock %}

View File

@ -4,42 +4,143 @@
{% block title %}{{ title }} - PX 360{% endblock %} {% block title %}{{ title }} - PX 360{% 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-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.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;
}
.form-text {
font-size: 0.75rem;
color: #64748b;
margin-top: 0.25rem;
}
.info-box {
background: #eef6fb;
border-radius: 0.75rem;
padding: 1rem;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="max-w-2xl mx-auto p-6"> <div class="max-w-2xl mx-auto">
<!-- Header --> <!-- Back Button -->
<div class="mb-6"> <div class="mb-6">
<div class="flex items-center gap-4"> <a href="{% url 'complaints:oncall_schedule_detail' schedule.id %}" class="btn-secondary">
<a href="{% url 'complaints:oncall_schedule_detail' schedule.id %}" class="text-[#64748b] hover:text-[#005696]"> <i data-lucide="arrow-left" class="w-4 h-4"></i>
<i data-lucide="arrow-left" class="w-6 h-6"></i> {% trans "Back to Schedule" %}
</a> </a>
<div> </div>
<h1 class="text-2xl font-bold text-[#005696]">{{ title }}</h1>
<p class="text-[#64748b] mt-1"> <!-- Header -->
{% trans "Schedule" %}: {% if schedule.hospital %}{{ schedule.hospital.name }}{% else %}{% trans "System-wide" %}{% endif %} <div class="page-header-gradient">
</p> <h1 class="text-2xl font-bold flex items-center gap-3">
</div> <i data-lucide="user-cog" class="w-7 h-7"></i>
</div> {{ title }}
</h1>
<p class="text-white/80 mt-1">
{% trans "Schedule" %}: {% if schedule.hospital %}{{ schedule.hospital.name }}{% else %}{% trans "System-wide" %}{% endif %}
</p>
</div> </div>
<!-- Form --> <!-- Form -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"> <div class="form-section">
<form method="post" class="p-6"> <form method="post" class="space-y-6">
{% csrf_token %} {% csrf_token %}
<!-- Admin Selection (only for new) --> <!-- Admin Selection (only for new) -->
{% if not on_call_admin %} {% if not on_call_admin %}
<div class="mb-6"> <div class="form-section">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> <h2 class="text-lg font-bold text-[#1e293b] mb-4 flex items-center gap-2">
<i data-lucide="user" class="w-5 h-5 text-[#005696]"></i> <i data-lucide="user" class="w-5 h-5 text-[#005696]"></i>
{% trans "Select Admin" %} {% trans "Select Admin" %}
</h2> </h2>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="form-label">
{% trans "PX Admin User" %} <span class="text-red-500">*</span> {% trans "PX Admin User" %} <span class="text-red-500">*</span>
</label> </label>
<select name="admin_user" required <select name="admin_user" required class="form-select">
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent">
<option value="">{% trans "Select an admin..." %}</option> <option value="">{% trans "Select an admin..." %}</option>
{% for admin in available_admins %} {% for admin in available_admins %}
<option value="{{ admin.id }}"> <option value="{{ admin.id }}">
@ -50,26 +151,27 @@
{% endfor %} {% endfor %}
</select> </select>
{% if not available_admins %} {% if not available_admins %}
<p class="mt-1 text-sm text-amber-600"> <p class="mt-2 text-sm text-amber-600 flex items-center gap-2">
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
{% trans "All PX Admins are already assigned to this schedule." %} {% trans "All PX Admins are already assigned to this schedule." %}
</p> </p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="mb-6"> <div class="form-section">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> <h2 class="text-lg font-bold text-[#1e293b] mb-4 flex items-center gap-2">
<i data-lucide="user" class="w-5 h-5 text-[#005696]"></i> <i data-lucide="user" class="w-5 h-5 text-[#005696]"></i>
{% trans "Admin" %} {% trans "Admin" %}
</h2> </h2>
<div class="flex items-center gap-3 p-4 bg-gray-50 rounded-lg"> <div class="flex items-center gap-3 p-4 bg-[#f8fafc] rounded-xl border-2 border-[#e2e8f0]">
<div class="w-12 h-12 bg-[#eef6fb] rounded-full flex items-center justify-center"> <div class="w-12 h-12 bg-[#005696] rounded-full flex items-center justify-center">
<span class="text-[#005696] font-medium text-lg"> <span class="text-white font-medium text-lg">
{{ on_call_admin.admin_user.first_name|first|default:on_call_admin.admin_user.email|first|upper }} {{ on_call_admin.admin_user.first_name|first|default:on_call_admin.admin_user.email|first|upper }}
</span> </span>
</div> </div>
<div> <div>
<p class="font-medium text-gray-900">{{ on_call_admin.admin_user.get_full_name|default:on_call_admin.admin_user.email }}</p> <p class="font-bold text-[#1e293b]">{{ on_call_admin.admin_user.get_full_name|default:on_call_admin.admin_user.email }}</p>
<p class="text-sm text-[#64748b]">{{ on_call_admin.admin_user.email }}</p> <p class="text-sm text-[#64748b]">{{ on_call_admin.admin_user.email }}</p>
</div> </div>
</div> </div>
@ -77,62 +179,61 @@
{% endif %} {% endif %}
<!-- Active Period --> <!-- Active Period -->
<div class="mb-6"> <div class="form-section">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> <h2 class="text-lg font-bold text-[#1e293b] mb-4 flex items-center gap-2">
<i data-lucide="calendar" class="w-5 h-5 text-[#005696]"></i> <i data-lucide="calendar" class="w-5 h-5 text-[#005696]"></i>
{% trans "Active Period" %} {% trans "Active Period" %}
</h2> </h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="form-label">
{% trans "Start Date (Optional)" %} {% trans "Start Date (Optional)" %}
</label> </label>
<input type="date" name="start_date" <input type="date" name="start_date"
value="{{ on_call_admin.start_date|date:'Y-m-d'|default:'' }}" value="{{ on_call_admin.start_date|date:'Y-m-d'|default:'' }}"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent"> class="form-control">
<p class="mt-1 text-xs text-[#64748b]">{% trans "Leave empty for immediate activation" %}</p> <p class="form-text">{% trans "Leave empty for immediate activation" %}</p>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="form-label">
{% trans "End Date (Optional)" %} {% trans "End Date (Optional)" %}
</label> </label>
<input type="date" name="end_date" <input type="date" name="end_date"
value="{{ on_call_admin.end_date|date:'Y-m-d'|default:'' }}" value="{{ on_call_admin.end_date|date:'Y-m-d'|default:'' }}"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent"> class="form-control">
<p class="mt-1 text-xs text-[#64748b]">{% trans "Leave empty for permanent assignment" %}</p> <p class="form-text">{% trans "Leave empty for permanent assignment" %}</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Notification Settings --> <!-- Notification Settings -->
<div class="mb-6"> <div class="form-section">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> <h2 class="text-lg font-bold text-[#1e293b] mb-4 flex items-center gap-2">
<i data-lucide="bell" class="w-5 h-5 text-[#005696]"></i> <i data-lucide="bell" class="w-5 h-5 text-[#005696]"></i>
{% trans "Notification Settings" %} {% trans "Notification Settings" %}
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="form-label">
{% trans "Notification Priority" %} {% trans "Notification Priority" %}
</label> </label>
<select name="notification_priority" <select name="notification_priority" class="form-select">
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent">
{% for i in "12345" %} {% for i in "12345" %}
<option value="{{ i }}" {% if on_call_admin.notification_priority == i|add:0 %}selected{% endif %}> <option value="{{ i }}" {% if on_call_admin.notification_priority == i|add:0 %}selected{% endif %}>
{{ i }} - {% if i == "1" %}{% trans "Highest" %}{% elif i == "5" %}{% trans "Lowest" %}{% else %}{% trans "Priority" %} {{ i }}{% endif %} {{ i }} - {% if i == "1" %}{% trans "Highest" %}{% elif i == "5" %}{% trans "Lowest" %}{% else %}{% trans "Priority" %} {{ i }}{% endif %}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
<p class="mt-1 text-xs text-[#64748b]">{% trans "Lower numbers = higher priority in notification order" %}</p> <p class="form-text">{% trans "Lower numbers = higher priority in notification order" %}</p>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 p-3 bg-[#f8fafc] rounded-lg">
<input type="checkbox" name="is_active" id="is_active" <input type="checkbox" name="is_active" id="is_active"
{% if not on_call_admin or on_call_admin.is_active %}checked{% endif %} {% if not on_call_admin or on_call_admin.is_active %}checked{% endif %}
class="w-5 h-5 text-[#005696] border-gray-300 rounded focus:ring-[#005696]"> class="w-5 h-5 text-[#005696] border-2 border-[#e2e8f0] rounded focus:ring-[#005696]">
<label for="is_active" class="text-sm font-medium text-gray-700"> <label for="is_active" class="text-sm font-semibold text-[#1e293b]">
{% trans "On-call assignment is active" %} {% trans "On-call assignment is active" %}
</label> </label>
</div> </div>
@ -140,40 +241,40 @@
</div> </div>
<!-- Contact Preferences --> <!-- Contact Preferences -->
<div class="mb-6"> <div class="form-section">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> <h2 class="text-lg font-bold text-[#1e293b] mb-4 flex items-center gap-2">
<i data-lucide="mail" class="w-5 h-5 text-[#005696]"></i> <i data-lucide="mail" class="w-5 h-5 text-[#005696]"></i>
{% trans "Contact Preferences" %} {% trans "Contact Preferences" %}
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 p-3 bg-[#f8fafc] rounded-lg">
<input type="checkbox" name="notify_email" id="notify_email" <input type="checkbox" name="notify_email" id="notify_email"
{% if not on_call_admin or on_call_admin.notify_email %}checked{% endif %} {% if not on_call_admin or on_call_admin.notify_email %}checked{% endif %}
class="w-5 h-5 text-[#005696] border-gray-300 rounded focus:ring-[#005696]"> class="w-5 h-5 text-[#005696] border-2 border-[#e2e8f0] rounded focus:ring-[#005696]">
<label for="notify_email" class="text-sm font-medium text-gray-700"> <label for="notify_email" class="text-sm font-semibold text-[#1e293b]">
{% trans "Send email notifications" %} {% trans "Send email notifications" %}
</label> </label>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 p-3 bg-[#f8fafc] rounded-lg">
<input type="checkbox" name="notify_sms" id="notify_sms" <input type="checkbox" name="notify_sms" id="notify_sms"
{% if on_call_admin.notify_sms %}checked{% endif %} {% if on_call_admin.notify_sms %}checked{% endif %}
class="w-5 h-5 text-[#005696] border-gray-300 rounded focus:ring-[#005696]"> class="w-5 h-5 text-[#005696] border-2 border-[#e2e8f0] rounded focus:ring-[#005696]">
<label for="notify_sms" class="text-sm font-medium text-gray-700"> <label for="notify_sms" class="text-sm font-semibold text-[#1e293b]">
{% trans "Send SMS notifications (for high priority complaints)" %} {% trans "Send SMS notifications (for high priority complaints)" %}
</label> </label>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="form-label">
{% trans "SMS Phone Number (Optional)" %} {% trans "SMS Phone Number (Optional)" %}
</label> </label>
<input type="tel" name="sms_phone" <input type="tel" name="sms_phone"
value="{{ on_call_admin.sms_phone|default:'' }}" value="{{ on_call_admin.sms_phone|default:'' }}"
placeholder="+966501234567" placeholder="+966501234567"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent"> class="form-control">
<p class="mt-1 text-xs text-[#64748b]"> <p class="form-text">
{% trans "Leave empty to use the user's profile phone number" %} {% trans "Leave empty to use the user's profile phone number" %}
</p> </p>
</div> </div>
@ -181,11 +282,11 @@
</div> </div>
<!-- Info Box --> <!-- Info Box -->
<div class="bg-[#eef6fb] rounded-lg p-4 mb-6"> <div class="info-box">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<i data-lucide="info" class="w-5 h-5 text-[#005696] flex-shrink-0 mt-0.5"></i> <i data-lucide="info" class="w-5 h-5 text-[#005696] flex-shrink-0 mt-0.5"></i>
<div> <div>
<h3 class="font-medium text-[#005696]">{% trans "When are on-call admins notified?" %}</h3> <h3 class="font-semibold text-[#005696]">{% trans "When are on-call admins notified?" %}</h3>
<p class="text-sm text-[#64748b] mt-1"> <p class="text-sm text-[#64748b] mt-1">
{% trans "On-call admins are notified outside of working hours (as configured in the schedule) via BOTH email and SMS. During working hours, ALL PX Admins are notified via email only." %} {% trans "On-call admins are notified outside of working hours (as configured in the schedule) via BOTH email and SMS. During working hours, ALL PX Admins are notified via email only." %}
</p> </p>
@ -194,13 +295,14 @@
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="flex items-center justify-end gap-3 pt-6 border-t border-gray-100"> <div class="flex items-center justify-end gap-3 pt-6 border-t-2 border-[#e2e8f0]">
<a href="{% url 'complaints:oncall_schedule_detail' schedule.id %}" <a href="{% url 'complaints:oncall_schedule_detail' schedule.id %}"
class="px-6 py-2 border border-gray-200 text-gray-600 rounded-lg hover:bg-gray-50 transition-colors"> class="btn-secondary">
{% trans "Cancel" %} {% trans "Cancel" %}
</a> </a>
<button type="submit" class="px-6 py-2 bg-[#005696] text-white rounded-lg hover:bg-[#007bbd] transition-colors" <button type="submit" class="btn-primary"
{% if not on_call_admin and not available_admins %}disabled{% endif %}> {% if not on_call_admin and not available_admins %}disabled{% endif %}>
<i data-lucide="{% if on_call_admin %}save{% else %}plus{% endif %}" class="w-4 h-4"></i>
{% if on_call_admin %}{% trans "Update Assignment" %}{% else %}{% trans "Add Admin" %}{% endif %} {% if on_call_admin %}{% trans "Update Assignment" %}{% else %}{% trans "Add Admin" %}{% endif %}
</button> </button>
</div> </div>

View File

@ -4,41 +4,159 @@
{% block title %}{{ title }} - PX 360{% endblock %} {% block title %}{{ title }} - PX 360{% 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;
}
.form-text {
font-size: 0.75rem;
color: #64748b;
margin-top: 0.25rem;
}
.info-box {
background: #eef6fb;
border-radius: 0.75rem;
padding: 1rem;
}
.day-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.day-checkbox:hover {
border-color: #005696;
background: #f8fafc;
}
.day-checkbox input {
width: 1rem;
height: 1rem;
accent-color: #005696;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="max-w-3xl mx-auto p-6"> <div class="max-w-3xl mx-auto">
<!-- Header --> <!-- Back Button -->
<div class="mb-6"> <div class="mb-6">
<div class="flex items-center gap-4"> <a href="{% url 'complaints:oncall_schedule_list' %}" class="btn-secondary">
<a href="{% url 'complaints:oncall_schedule_list' %}" class="text-[#64748b] hover:text-[#005696]"> <i data-lucide="arrow-left" class="w-4 h-4"></i>
<i data-lucide="arrow-left" class="w-6 h-6"></i> {% trans "Back to Schedules" %}
</a> </a>
<div> </div>
<h1 class="text-2xl font-bold text-[#005696]">{{ title }}</h1>
<p class="text-[#64748b] mt-1"> <!-- Header -->
{% if schedule %}{% trans "Update on-call schedule settings" %}{% else %}{% trans "Create a new on-call schedule" %}{% endif %} <div class="page-header-gradient">
</p> <h1 class="text-2xl font-bold flex items-center gap-3">
</div> <i data-lucide="calendar-clock" class="w-7 h-7"></i>
</div> {{ title }}
</h1>
<p class="text-white/80 mt-1">
{% if schedule %}{% trans "Update on-call schedule settings" %}{% else %}{% trans "Create a new on-call schedule" %}{% endif %}
</p>
</div> </div>
<!-- Form --> <!-- Form -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"> <div class="form-section">
<form method="post" class="p-6"> <form method="post" class="space-y-6">
{% csrf_token %} {% csrf_token %}
<!-- Scope Section --> <!-- Scope Section -->
<div class="mb-8"> <div class="form-section">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> <h2 class="text-lg font-bold text-[#1e293b] mb-4 flex items-center gap-2">
<i data-lucide="building" class="w-5 h-5 text-[#005696]"></i> <i data-lucide="building" class="w-5 h-5 text-[#005696]"></i>
{% trans "Schedule Scope" %} {% trans "Schedule Scope" %}
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="form-label">
{% trans "Hospital (Optional)" %} {% trans "Hospital (Optional)" %}
</label> </label>
<select name="hospital" class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent"> <select name="hospital" class="form-select">
<option value="">{% trans "System-wide (All Hospitals)" %}</option> <option value="">{% trans "System-wide (All Hospitals)" %}</option>
{% for hospital in hospitals %} {% for hospital in hospitals %}
<option value="{{ hospital.id }}" {% if schedule.hospital_id == hospital.id %}selected{% endif %}> <option value="{{ hospital.id }}" {% if schedule.hospital_id == hospital.id %}selected{% endif %}>
@ -46,16 +164,16 @@
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
<p class="mt-1 text-sm text-[#64748b]"> <p class="form-text">
{% trans "Leave empty for system-wide configuration, or select a specific hospital." %} {% trans "Leave empty for system-wide configuration, or select a specific hospital." %}
</p> </p>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 p-3 bg-[#f8fafc] rounded-lg">
<input type="checkbox" name="is_active" id="is_active" <input type="checkbox" name="is_active" id="is_active"
{% if not schedule or schedule.is_active %}checked{% endif %} {% if not schedule or schedule.is_active %}checked{% endif %}
class="w-5 h-5 text-[#005696] border-gray-300 rounded focus:ring-[#005696]"> class="w-5 h-5 text-[#005696] border-2 border-[#e2e8f0] rounded focus:ring-[#005696]">
<label for="is_active" class="text-sm font-medium text-gray-700"> <label for="is_active" class="text-sm font-semibold text-[#1e293b]">
{% trans "Schedule is active" %} {% trans "Schedule is active" %}
</label> </label>
</div> </div>
@ -63,8 +181,8 @@
</div> </div>
<!-- Working Hours Section --> <!-- Working Hours Section -->
<div class="mb-8"> <div class="form-section">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> <h2 class="text-lg font-bold text-[#1e293b] mb-4 flex items-center gap-2">
<i data-lucide="clock" class="w-5 h-5 text-[#005696]"></i> <i data-lucide="clock" class="w-5 h-5 text-[#005696]"></i>
{% trans "Working Hours Configuration" %} {% trans "Working Hours Configuration" %}
</h2> </h2>
@ -72,56 +190,49 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Working Days --> <!-- Working Days -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="form-label">
{% trans "Working Days" %} {% trans "Working Days" %}
</label> </label>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
{% with working_days=schedule.get_working_days_list|default:"0,1,2,3,4"|slice:":" %} {% with working_days=schedule.get_working_days_list|default:"0,1,2,3,4"|slice:":" %}
<label class="flex items-center gap-2 px-3 py-2 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50"> <label class="day-checkbox">
<input type="checkbox" name="working_day_0" value="0" <input type="checkbox" name="working_day_0" value="0"
{% if schedule and 0 in schedule.get_working_days_list %}checked{% elif not schedule %}checked{% endif %} {% if schedule and 0 in schedule.get_working_days_list %}checked{% elif not schedule %}checked{% endif %}>
class="w-4 h-4 text-[#005696] border-gray-300 rounded focus:ring-[#005696]"> <span class="text-sm font-medium">{% trans "Mon" %}</span>
<span class="text-sm">{% trans "Mon" %}</span>
</label> </label>
<label class="flex items-center gap-2 px-3 py-2 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50"> <label class="day-checkbox">
<input type="checkbox" name="working_day_1" value="1" <input type="checkbox" name="working_day_1" value="1"
{% if schedule and 1 in schedule.get_working_days_list %}checked{% elif not schedule %}checked{% endif %} {% if schedule and 1 in schedule.get_working_days_list %}checked{% elif not schedule %}checked{% endif %}>
class="w-4 h-4 text-[#005696] border-gray-300 rounded focus:ring-[#005696]"> <span class="text-sm font-medium">{% trans "Tue" %}</span>
<span class="text-sm">{% trans "Tue" %}</span>
</label> </label>
<label class="flex items-center gap-2 px-3 py-2 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50"> <label class="day-checkbox">
<input type="checkbox" name="working_day_2" value="2" <input type="checkbox" name="working_day_2" value="2"
{% if schedule and 2 in schedule.get_working_days_list %}checked{% elif not schedule %}checked{% endif %} {% if schedule and 2 in schedule.get_working_days_list %}checked{% elif not schedule %}checked{% endif %}>
class="w-4 h-4 text-[#005696] border-gray-300 rounded focus:ring-[#005696]"> <span class="text-sm font-medium">{% trans "Wed" %}</span>
<span class="text-sm">{% trans "Wed" %}</span>
</label> </label>
<label class="flex items-center gap-2 px-3 py-2 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50"> <label class="day-checkbox">
<input type="checkbox" name="working_day_3" value="3" <input type="checkbox" name="working_day_3" value="3"
{% if schedule and 3 in schedule.get_working_days_list %}checked{% elif not schedule %}checked{% endif %} {% if schedule and 3 in schedule.get_working_days_list %}checked{% elif not schedule %}checked{% endif %}>
class="w-4 h-4 text-[#005696] border-gray-300 rounded focus:ring-[#005696]"> <span class="text-sm font-medium">{% trans "Thu" %}</span>
<span class="text-sm">{% trans "Thu" %}</span>
</label> </label>
<label class="flex items-center gap-2 px-3 py-2 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50"> <label class="day-checkbox">
<input type="checkbox" name="working_day_4" value="4" <input type="checkbox" name="working_day_4" value="4"
{% if schedule and 4 in schedule.get_working_days_list %}checked{% elif not schedule %}checked{% endif %} {% if schedule and 4 in schedule.get_working_days_list %}checked{% elif not schedule %}checked{% endif %}>
class="w-4 h-4 text-[#005696] border-gray-300 rounded focus:ring-[#005696]"> <span class="text-sm font-medium">{% trans "Fri" %}</span>
<span class="text-sm">{% trans "Fri" %}</span>
</label> </label>
<label class="flex items-center gap-2 px-3 py-2 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50"> <label class="day-checkbox">
<input type="checkbox" name="working_day_5" value="5" <input type="checkbox" name="working_day_5" value="5"
{% if schedule and 5 in schedule.get_working_days_list %}checked{% endif %} {% if schedule and 5 in schedule.get_working_days_list %}checked{% endif %}>
class="w-4 h-4 text-[#005696] border-gray-300 rounded focus:ring-[#005696]"> <span class="text-sm font-medium">{% trans "Sat" %}</span>
<span class="text-sm">{% trans "Sat" %}</span>
</label> </label>
<label class="flex items-center gap-2 px-3 py-2 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50"> <label class="day-checkbox">
<input type="checkbox" name="working_day_6" value="6" <input type="checkbox" name="working_day_6" value="6"
{% if schedule and 6 in schedule.get_working_days_list %}checked{% endif %} {% if schedule and 6 in schedule.get_working_days_list %}checked{% endif %}>
class="w-4 h-4 text-[#005696] border-gray-300 rounded focus:ring-[#005696]"> <span class="text-sm font-medium">{% trans "Sun" %}</span>
<span class="text-sm">{% trans "Sun" %}</span>
</label> </label>
{% endwith %} {% endwith %}
</div> </div>
<p class="mt-1 text-sm text-[#64748b]"> <p class="form-text">
{% trans "Select the days that are considered working days. Outside these days, only on-call admins will be notified." %} {% trans "Select the days that are considered working days. Outside these days, only on-call admins will be notified." %}
</p> </p>
</div> </div>
@ -129,29 +240,29 @@
<!-- Time Range --> <!-- Time Range -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="form-label">
{% trans "Work Start Time" %} {% trans "Work Start Time" %}
</label> </label>
<input type="time" name="work_start_time" <input type="time" name="work_start_time"
value="{{ schedule.work_start_time|time:'H:i'|default:'08:00' }}" value="{{ schedule.work_start_time|time:'H:i'|default:'08:00' }}"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent"> class="form-control">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="form-label">
{% trans "Work End Time" %} {% trans "Work End Time" %}
</label> </label>
<input type="time" name="work_end_time" <input type="time" name="work_end_time"
value="{{ schedule.work_end_time|time:'H:i'|default:'17:00' }}" value="{{ schedule.work_end_time|time:'H:i'|default:'17:00' }}"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent"> class="form-control">
</div> </div>
</div> </div>
<!-- Timezone --> <!-- Timezone -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="form-label">
{% trans "Timezone" %} {% trans "Timezone" %}
</label> </label>
<select name="timezone" class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#005696] focus:border-transparent"> <select name="timezone" class="form-select">
{% for tz in timezones %} {% for tz in timezones %}
<option value="{{ tz }}" {% if schedule.timezone == tz %}selected{% elif not schedule and tz == 'Asia/Riyadh' %}selected{% endif %}> <option value="{{ tz }}" {% if schedule.timezone == tz %}selected{% elif not schedule and tz == 'Asia/Riyadh' %}selected{% endif %}>
{{ tz }} {{ tz }}
@ -163,11 +274,11 @@
</div> </div>
<!-- Info Box --> <!-- Info Box -->
<div class="bg-[#eef6fb] rounded-lg p-4 mb-6"> <div class="info-box">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<i data-lucide="info" class="w-5 h-5 text-[#005696] flex-shrink-0 mt-0.5"></i> <i data-lucide="info" class="w-5 h-5 text-[#005696] flex-shrink-0 mt-0.5"></i>
<div> <div>
<h3 class="font-medium text-[#005696]">{% trans "How it works" %}</h3> <h3 class="font-semibold text-[#005696]">{% trans "How it works" %}</h3>
<p class="text-sm text-[#64748b] mt-1"> <p class="text-sm text-[#64748b] mt-1">
{% trans "During working hours, ALL PX Admins are notified of new complaints via email only. Outside working hours (after work end time, before work start time, or on non-working days), only the on-call admins assigned to this schedule will be notified via BOTH email and SMS." %} {% trans "During working hours, ALL PX Admins are notified of new complaints via email only. Outside working hours (after work end time, before work start time, or on non-working days), only the on-call admins assigned to this schedule will be notified via BOTH email and SMS." %}
</p> </p>
@ -176,12 +287,13 @@
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="flex items-center justify-end gap-3 pt-6 border-t border-gray-100"> <div class="flex items-center justify-end gap-3 pt-6 border-t-2 border-[#e2e8f0]">
<a href="{% url 'complaints:oncall_schedule_list' %}" <a href="{% url 'complaints:oncall_schedule_list' %}"
class="px-6 py-2 border border-gray-200 text-gray-600 rounded-lg hover:bg-gray-50 transition-colors"> class="btn-secondary">
{% trans "Cancel" %} {% trans "Cancel" %}
</a> </a>
<button type="submit" class="px-6 py-2 bg-[#005696] text-white rounded-lg hover:bg-[#007bbd] transition-colors"> <button type="submit" class="btn-primary">
<i data-lucide="{% if schedule %}save{% else %}plus{% endif %}" class="w-4 h-4"></i>
{% if schedule %}{% trans "Update Schedule" %}{% else %}{% trans "Create Schedule" %}{% endif %} {% if schedule %}{% trans "Update Schedule" %}{% else %}{% trans "Create Schedule" %}{% endif %}
</button> </button>
</div> </div>

View File

@ -2,24 +2,188 @@
{% load i18n %} {% load i18n %}
{% load static %} {% load static %}
{% block title %}{{ title }} - PX 360{% endblock %} {% block title %}{{ title }} - PX360{% endblock %}
{% block extra_css %}
<style>
:root {
--hh-navy: #005696;
--hh-blue: #007bbd;
--hh-light: #eef6fb;
--hh-slate: #64748b;
}
.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;
}
.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);
}
.schedule-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;
}
.schedule-card:hover {
border-color: #005696;
box-shadow: 0 10px 25px -5px rgba(0, 86, 150, 0.15);
}
.schedule-card-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(to right, #f8fafc, #f1f5f9);
display: flex;
align-items: center;
justify-content: space-between;
}
.icon-box {
width: 40px;
height: 40px;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--hh-light), #e0f2fe);
color: var(--hh-navy);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.status-dot.active {
background: linear-gradient(135deg, #10b981, #059669);
}
.status-dot.inactive {
background: linear-gradient(135deg, #cbd5e1, #94a3b8);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="p-6"> <div class="px-4 py-6">
<!-- Header --> <!-- Page Header -->
<div class="mb-6"> <div class="page-header-gradient animate-in">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a href="{% url 'complaints:oncall_dashboard' %}" class="text-[#64748b] hover:text-[#005696]"> <a href="{% url 'complaints:oncall_dashboard' %}" class="text-white/80 hover:text-white transition">
<i data-lucide="arrow-left" class="w-6 h-6"></i> <i data-lucide="arrow-left" class="w-6 h-6"></i>
</a> </a>
<div> <div>
<h1 class="text-2xl font-bold text-[#005696]">{{ title }}</h1> <h1 class="text-2xl font-bold mb-2">
<p class="text-[#64748b] mt-1">{% trans "Configure working hours and on-call admin assignments" %}</p> <i data-lucide="calendar-clock" class="w-7 h-7 inline-block me-2"></i>
{{ title }}
</h1>
<p class="text-white/90">{% trans "Configure working hours and on-call admin assignments" %}</p>
</div> </div>
</div> </div>
<a href="{% url 'complaints:oncall_schedule_create' %}" <a href="{% url 'complaints:oncall_schedule_create' %}" class="btn-secondary">
class="px-4 py-2 bg-[#005696] text-white rounded-lg hover:bg-[#007bbd] transition-colors flex items-center gap-2">
<i data-lucide="plus" class="w-4 h-4"></i> <i data-lucide="plus" class="w-4 h-4"></i>
{% trans "Create Schedule" %} {% trans "Create Schedule" %}
</a> </a>
@ -29,21 +193,21 @@
<!-- Schedules Grid --> <!-- Schedules Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for schedule in schedules %} {% for schedule in schedules %}
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow"> <div class="schedule-card animate-in">
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between"> <div class="schedule-card-header">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{% if schedule.hospital %} {% if schedule.hospital %}
<span class="font-medium text-gray-900">{{ schedule.hospital.name }}</span> <span class="font-bold text-navy">{{ schedule.hospital.name }}</span>
{% else %} {% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-[#eef6fb] text-[#005696]"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-bold bg-[#eef6fb] text-[#005696]">
{% trans "System-wide" %} {% trans "System-wide" %}
</span> </span>
{% endif %} {% endif %}
</div> </div>
{% if schedule.is_active %} {% if schedule.is_active %}
<span class="w-2.5 h-2.5 bg-green-500 rounded-full" title="{% trans 'Active' %}"></span> <span class="status-dot active" title="{% trans 'Active' %}"></span>
{% else %} {% else %}
<span class="w-2.5 h-2.5 bg-gray-300 rounded-full" title="{% trans 'Inactive' %}"></span> <span class="status-dot inactive" title="{% trans 'Inactive' %}"></span>
{% endif %} {% endif %}
</div> </div>
@ -51,12 +215,12 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Working Hours --> <!-- Working Hours -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-10 h-10 bg-[#eef6fb] rounded-lg flex items-center justify-center"> <div class="icon-box">
<i data-lucide="clock" class="w-5 h-5 text-[#005696]"></i> <i data-lucide="clock" class="w-5 h-5"></i>
</div> </div>
<div> <div>
<p class="text-sm text-[#64748b]">{% trans "Working Hours" %}</p> <p class="text-sm text-slate font-medium">{% trans "Working Hours" %}</p>
<p class="font-medium text-gray-900"> <p class="font-bold text-gray-900">
{{ schedule.work_start_time|time:"H:i" }} - {{ schedule.work_end_time|time:"H:i" }} {{ schedule.work_start_time|time:"H:i" }} - {{ schedule.work_end_time|time:"H:i" }}
</p> </p>
</div> </div>
@ -64,12 +228,12 @@
<!-- Working Days --> <!-- Working Days -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-10 h-10 bg-[#eef6fb] rounded-lg flex items-center justify-center"> <div class="icon-box">
<i data-lucide="calendar" class="w-5 h-5 text-[#005696]"></i> <i data-lucide="calendar" class="w-5 h-5"></i>
</div> </div>
<div> <div>
<p class="text-sm text-[#64748b]">{% trans "Working Days" %}</p> <p class="text-sm text-slate font-medium">{% trans "Working Days" %}</p>
<p class="font-medium text-gray-900 text-sm"> <p class="font-bold text-gray-900 text-sm">
{% for day in schedule.get_working_days_list %} {% for day in schedule.get_working_days_list %}
{% if day == 0 %}{% trans "Mon" %}{% elif day == 1 %}{% trans "Tue" %}{% elif day == 2 %}{% trans "Wed" %}{% elif day == 3 %}{% trans "Thu" %}{% elif day == 4 %}{% trans "Fri" %}{% elif day == 5 %}{% trans "Sat" %}{% elif day == 6 %}{% trans "Sun" %}{% endif %}{% if not forloop.last %}, {% endif %} {% if day == 0 %}{% trans "Mon" %}{% elif day == 1 %}{% trans "Tue" %}{% elif day == 2 %}{% trans "Wed" %}{% elif day == 3 %}{% trans "Thu" %}{% elif day == 4 %}{% trans "Fri" %}{% elif day == 5 %}{% trans "Sat" %}{% elif day == 6 %}{% trans "Sun" %}{% endif %}{% if not forloop.last %}, {% endif %}
{% endfor %} {% endfor %}
@ -79,41 +243,41 @@
<!-- Timezone --> <!-- Timezone -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-10 h-10 bg-[#eef6fb] rounded-lg flex items-center justify-center"> <div class="icon-box">
<i data-lucide="globe" class="w-5 h-5 text-[#005696]"></i> <i data-lucide="globe" class="w-5 h-5"></i>
</div> </div>
<div> <div>
<p class="text-sm text-[#64748b]">{% trans "Timezone" %}</p> <p class="text-sm text-slate font-medium">{% trans "Timezone" %}</p>
<p class="font-medium text-gray-900 text-sm">{{ schedule.timezone }}</p> <p class="font-bold text-gray-900 text-sm">{{ schedule.timezone }}</p>
</div> </div>
</div> </div>
<!-- On-Call Admins Count --> <!-- On-Call Admins Count -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center"> <div class="icon-box">
<i data-lucide="users" class="w-5 h-5 text-[#007bbd]"></i> <i data-lucide="users" class="w-5 h-5"></i>
</div> </div>
<div> <div>
<p class="text-sm text-[#64748b]">{% trans "On-Call Admins" %}</p> <p class="text-sm text-slate font-medium">{% trans "On-Call Admins" %}</p>
<p class="font-medium text-[#007bbd]">{{ schedule.on_call_admins.count }}</p> <p class="font-bold text-[#007bbd]">{{ schedule.on_call_admins.count }}</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="mt-6 pt-4 border-t border-gray-100 flex gap-2"> <div class="mt-6 pt-4 border-t border-slate-200 flex gap-2">
<a href="{% url 'complaints:oncall_schedule_detail' schedule.id %}" <a href="{% url 'complaints:oncall_schedule_detail' schedule.id %}"
class="flex-1 px-3 py-2 bg-[#005696] text-white text-sm rounded-lg hover:bg-[#007bbd] transition-colors text-center"> class="flex-1 px-3 py-2 bg-[#005696] text-white text-sm rounded-lg hover:bg-[#007bbd] transition-colors text-center font-semibold">
{% trans "View" %} {% trans "View" %}
</a> </a>
<a href="{% url 'complaints:oncall_schedule_edit' schedule.id %}" <a href="{% url 'complaints:oncall_schedule_edit' schedule.id %}"
class="px-3 py-2 border border-gray-200 text-gray-600 text-sm rounded-lg hover:bg-gray-50 transition-colors"> class="px-3 py-2 border-2 border-slate-200 text-slate-600 text-sm rounded-lg hover:bg-slate-50 transition-colors">
<i data-lucide="edit" class="w-4 h-4"></i> <i data-lucide="edit" class="w-4 h-4"></i>
</a> </a>
<form method="post" action="{% url 'complaints:oncall_schedule_delete' schedule.id %}" <form method="post" action="{% url 'complaints:oncall_schedule_delete' schedule.id %}"
class="inline" onsubmit="return confirm('{% trans "Are you sure you want to delete this schedule?" %}')"> class="inline" onsubmit="return confirm('{% trans "Are you sure you want to delete this schedule?" %}')">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="px-3 py-2 border border-red-200 text-red-600 text-sm rounded-lg hover:bg-red-50 transition-colors"> <button type="submit" class="px-3 py-2 border-2 border-red-200 text-red-600 text-sm rounded-lg hover:bg-red-50 transition-colors">
<i data-lucide="trash-2" class="w-4 h-4"></i> <i data-lucide="trash-2" class="w-4 h-4"></i>
</button> </button>
</form> </form>
@ -122,16 +286,15 @@
</div> </div>
{% empty %} {% empty %}
<div class="col-span-full"> <div class="col-span-full">
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-12 text-center"> <div class="section-card p-12 text-center animate-in">
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4"> <div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="calendar-x" class="w-8 h-8 text-gray-400"></i> <i data-lucide="calendar-x" class="w-8 h-8 text-slate-400"></i>
</div> </div>
<h3 class="text-lg font-medium text-gray-900 mb-2">{% trans "No On-Call Schedules" %}</h3> <h3 class="text-lg font-bold text-navy mb-2">{% trans "No On-Call Schedules" %}</h3>
<p class="text-[#64748b] mb-6 max-w-md mx-auto"> <p class="text-slate mb-6 max-w-md mx-auto">
{% trans "Create your first on-call schedule to configure working hours and assign on-call admins for after-hours complaint notifications." %} {% trans "Create your first on-call schedule to configure working hours and assign on-call admins for after-hours complaint notifications." %}
</p> </p>
<a href="{% url 'complaints:oncall_schedule_create' %}" <a href="{% url 'complaints:oncall_schedule_create' %}" class="btn-primary">
class="px-6 py-3 bg-[#005696] text-white rounded-lg hover:bg-[#007bbd] transition-colors inline-flex items-center gap-2">
<i data-lucide="plus" class="w-5 h-5"></i> <i data-lucide="plus" class="w-5 h-5"></i>
{% trans "Create Schedule" %} {% trans "Create Schedule" %}
</a> </a>

View File

@ -3,45 +3,127 @@
{% block title %}{% trans "Request Explanation" %} - {{ complaint.reference_number }} - PX360{% endblock %} {% block title %}{% trans "Request Explanation" %} - {{ complaint.reference_number }} - 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 %} {% block content %}
<!-- Back Button --> <!-- Back Button -->
<div class="mb-6"> <div class="mb-6">
<a href="{% url 'complaints:complaint_detail' pk=complaint.pk %}" class="inline-flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-xl text-slate hover:text-navy hover:bg-light transition text-sm font-semibold"> <a href="{% url 'complaints:complaint_detail' pk=complaint.pk %}" class="btn-secondary">
<i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to Complaint" %} <i data-lucide="arrow-left" class="w-4 h-4"></i> {% trans "Back to Complaint" %}
</a> </a>
</div> </div>
<!-- Header --> <!-- Header -->
<header class="mb-6"> <div class="page-header-gradient">
<div class="flex items-center gap-2 text-sm text-slate mb-2"> <div class="flex items-center gap-2 text-sm text-white/80 mb-2">
<a href="{% url 'complaints:complaint_list' %}">{% trans "Complaints" %}</a> <a href="{% url 'complaints:complaint_list' %}" class="hover:text-white">{% trans "Complaints" %}</a>
<i data-lucide="chevron-right" class="w-4 h-4"></i> <i data-lucide="chevron-right" class="w-4 h-4"></i>
<a href="{% url 'complaints:complaint_detail' pk=complaint.pk %}">{{ complaint.reference_number }}</a> <a href="{% url 'complaints:complaint_detail' pk=complaint.pk %}" class="hover:text-white">{{ complaint.reference_number }}</a>
<i data-lucide="chevron-right" class="w-4 h-4"></i> <i data-lucide="chevron-right" class="w-4 h-4"></i>
<span class="font-bold text-navy">{% trans "Request Explanation" %}</span> <span class="font-bold">{% trans "Request Explanation" %}</span>
</div> </div>
<h1 class="text-2xl font-bold text-navy">{% trans "Request Explanation" %}</h1> <h1 class="text-2xl font-bold">{% trans "Request Explanation" %}</h1>
<p class="text-slate mt-1">{{ complaint.title }}</p> <p class="text-white/80 mt-1">{{ complaint.title }}</p>
</header> </div>
<form method="post" class="space-y-6"> <form method="post" class="space-y-6">
{% csrf_token %} {% csrf_token %}
<!-- Recipients Selection --> <!-- Recipients Selection -->
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="form-section">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-navy text-lg flex items-center gap-2"> <h3 class="font-bold text-[#1e293b] text-lg flex items-center gap-2">
<i data-lucide="users" class="w-5 h-5 text-blue"></i> <i data-lucide="users" class="w-5 h-5 text-[#005696]"></i>
{% trans "Select Recipients" %} {% trans "Select Recipients" %}
</h3> </h3>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="button" onclick="selectAllStaff()" class="px-3 py-1.5 text-xs font-semibold text-navy border border-slate-200 rounded-lg hover:bg-light transition"> <button type="button" onclick="selectAllStaff()" class="px-3 py-1.5 text-xs font-semibold text-[#005696] border-2 border-[#e2e8f0] rounded-lg hover:border-[#005696] hover:bg-[#f1f5f9] transition">
{% trans "Select All Staff" %} {% trans "Select All Staff" %}
</button> </button>
<button type="button" onclick="selectAllManagers()" class="px-3 py-1.5 text-xs font-semibold text-navy border border-slate-200 rounded-lg hover:bg-light transition"> <button type="button" onclick="selectAllManagers()" class="px-3 py-1.5 text-xs font-semibold text-[#005696] border-2 border-[#e2e8f0] rounded-lg hover:border-[#005696] hover:bg-[#f1f5f9] transition">
{% trans "Select All Managers" %} {% trans "Select All Managers" %}
</button> </button>
<button type="button" onclick="deselectAll()" class="px-3 py-1.5 text-xs font-semibold text-slate border border-slate-200 rounded-lg hover:bg-light transition"> <button type="button" onclick="deselectAll()" class="px-3 py-1.5 text-xs font-semibold text-[#64748b] border-2 border-[#e2e8f0] rounded-lg hover:border-[#005696] hover:bg-[#f1f5f9] transition">
{% trans "Deselect All" %} {% trans "Deselect All" %}
</button> </button>
</div> </div>
@ -50,7 +132,7 @@
{% if recipients %} {% if recipients %}
<div class="space-y-4"> <div class="space-y-4">
{% for recipient in recipients %} {% for recipient in recipients %}
<div class="border border-slate-200 rounded-xl p-4 hover:border-blue-300 transition"> <div class="border-2 border-[#e2e8f0] rounded-xl p-4 hover:border-[#005696] transition">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<!-- Staff Checkbox --> <!-- Staff Checkbox -->
<div class="pt-1"> <div class="pt-1">
@ -58,7 +140,7 @@
name="selected_staff" name="selected_staff"
value="{{ recipient.staff_id }}" value="{{ recipient.staff_id }}"
id="staff_{{ recipient.staff_id }}" id="staff_{{ recipient.staff_id }}"
class="staff-checkbox w-5 h-5 text-navy border-slate-300 rounded focus:ring-navy" class="staff-checkbox w-5 h-5 text-[#005696] border-2 border-[#e2e8f0] rounded focus:ring-[#005696]"
checked checked
onchange="updateManagerCheckbox('{{ recipient.staff_id }}', '{{ recipient.manager_id }}')"> onchange="updateManagerCheckbox('{{ recipient.staff_id }}', '{{ recipient.manager_id }}')">
</div> </div>
@ -66,51 +148,51 @@
<!-- Staff Info --> <!-- Staff Info -->
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-navy rounded-full flex items-center justify-center text-white font-bold text-sm"> <div class="w-10 h-10 bg-[#005696] rounded-full flex items-center justify-center text-white font-bold text-sm">
{{ recipient.staff_name|first }} {{ recipient.staff_name|first }}
</div> </div>
<div> <div>
<h4 class="font-bold text-navy">{{ recipient.staff_name }}</h4> <h4 class="font-bold text-[#1e293b]">{{ recipient.staff_name }}</h4>
<span class="text-xs text-slate">{{ recipient.role }}</span> <span class="text-xs text-[#64748b]">{{ recipient.role }}</span>
</div> </div>
<span class="ml-auto px-2.5 py-1 bg-slate-100 rounded-lg text-xs font-bold text-slate-600"> <span class="ml-auto px-2.5 py-1 bg-[#f1f5f9] rounded-lg text-xs font-bold text-[#64748b]">
{{ recipient.department }} {{ recipient.department }}
</span> </span>
</div> </div>
{% if recipient.manager %} {% if recipient.manager %}
<div class="mt-3 pl-4 border-l-2 border-blue-200"> <div class="mt-3 pl-4 border-l-2 border-[#005696]/20">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<!-- Manager Checkbox --> <!-- Manager Checkbox -->
<input type="checkbox" <input type="checkbox"
name="selected_managers" name="selected_managers"
value="{{ recipient.manager_id }}" value="{{ recipient.manager_id }}"
id="manager_{{ recipient.manager_id }}" id="manager_{{ recipient.manager_id }}"
class="manager-checkbox w-4 h-4 text-blue border-slate-300 rounded focus:ring-blue" class="manager-checkbox w-4 h-4 text-[#005696] border-2 border-[#e2e8f0] rounded focus:ring-[#005696]"
checked checked
data-staff-id="{{ recipient.staff_id }}"> data-staff-id="{{ recipient.staff_id }}">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<i data-lucide="user-check" class="w-4 h-4 text-blue"></i> <i data-lucide="user-check" class="w-4 h-4 text-[#005696]"></i>
<span class="text-sm font-semibold text-slate">{% trans "Manager:" %}</span> <span class="text-sm font-semibold text-[#64748b]">{% trans "Manager:" %}</span>
<span class="text-sm font-bold text-navy">{{ recipient.manager_name }}</span> <span class="text-sm font-bold text-[#1e293b]">{{ recipient.manager_name }}</span>
</div> </div>
</div> </div>
<p class="text-xs text-slate mt-1 ml-7"> <p class="text-xs text-[#64748b] mt-1 ml-7">
{% trans "Will receive notification only (no explanation link)" %} {% trans "Will receive notification only (no explanation link)" %}
</p> </p>
</div> </div>
{% else %} {% else %}
<div class="mt-3 pl-4 border-l-2 border-slate-200"> <div class="mt-3 pl-4 border-l-2 border-[#e2e8f0]">
<p class="text-xs text-slate flex items-center gap-2"> <p class="text-xs text-[#64748b] flex items-center gap-2">
<i data-lucide="alert-circle" class="w-4 h-4 text-orange-500"></i> <i data-lucide="alert-circle" class="w-4 h-4 text-amber-500"></i>
{% trans "No manager assigned to this staff member" %} {% trans "No manager assigned to this staff member" %}
</p> </p>
</div> </div>
{% endif %} {% endif %}
{% if not recipient.staff_email %} {% if not recipient.staff_email %}
<div class="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg"> <div class="mt-2 p-2 bg-red-50 border-2 border-red-200 rounded-lg">
<p class="text-xs text-red-600 flex items-center gap-2"> <p class="text-xs text-red-600 flex items-center gap-2">
<i data-lucide="alert-triangle" class="w-4 h-4"></i> <i data-lucide="alert-triangle" class="w-4 h-4"></i>
{% trans "This staff member has no email address. Request cannot be sent." %} {% trans "This staff member has no email address. Request cannot be sent." %}
@ -124,36 +206,36 @@
</div> </div>
{% else %} {% else %}
<div class="text-center py-12"> <div class="text-center py-12">
<i data-lucide="users" class="w-16 h-16 mx-auto text-slate-300 mb-4"></i> <i data-lucide="users" class="w-16 h-16 mx-auto text-[#e2e8f0] mb-4"></i>
<p class="text-slate font-medium">{% trans "No staff members are involved in this complaint." %}</p> <p class="text-[#64748b] font-medium">{% trans "No staff members are involved in this complaint." %}</p>
</div> </div>
{% endif %} {% endif %}
</section> </section>
<!-- Message --> <!-- Message -->
<section class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <section class="form-section">
<h3 class="font-bold text-navy mb-4 text-lg flex items-center gap-2"> <h3 class="font-bold text-[#1e293b] mb-4 text-lg flex items-center gap-2">
<i data-lucide="message-square" class="w-5 h-5 text-blue"></i> <i data-lucide="message-square" class="w-5 h-5 text-[#005696]"></i>
{% trans "Additional Message" %} {% trans "Additional Message" %}
</h3> </h3>
<div> <div>
<label for="request_message" class="block text-sm font-semibold text-slate mb-2"> <label for="request_message" class="form-label">
{% trans "Optional message to include in the email" %} {% trans "Optional message to include in the email" %}
</label> </label>
<textarea name="request_message" <textarea name="request_message"
id="request_message" id="request_message"
rows="4" rows="4"
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 'Enter any additional context or instructions for the recipients...' %}"></textarea> placeholder="{% trans 'Enter any additional context or instructions for the recipients...' %}"></textarea>
</div> </div>
</section> </section>
<!-- Summary --> <!-- Summary -->
<section class="bg-light/30 rounded-2xl p-6 border border-slate-100"> <section class="form-section bg-[#f8fafc]">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between flex-wrap gap-4">
<div> <div>
<h4 class="font-bold text-navy mb-1">{% trans "Summary" %}</h4> <h4 class="font-bold text-[#1e293b] mb-1">{% trans "Summary" %}</h4>
<p class="text-sm text-slate"> <p class="text-sm text-[#64748b]">
<span id="staff-count">0</span> {% trans "staff will receive explanation links" %} <span id="staff-count">0</span> {% trans "staff will receive explanation links" %}
<span class="mx-2">•</span> <span class="mx-2">•</span>
<span id="manager-count">0</span> {% trans "managers will be notified" %} <span id="manager-count">0</span> {% trans "managers will be notified" %}
@ -161,11 +243,11 @@
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a href="{% url 'complaints:complaint_detail' pk=complaint.pk %}" <a href="{% url 'complaints:complaint_detail' pk=complaint.pk %}"
class="px-6 py-3 border border-slate-200 rounded-xl text-slate font-semibold hover:bg-white transition"> class="btn-secondary">
{% trans "Cancel" %} {% trans "Cancel" %}
</a> </a>
<button type="submit" <button type="submit"
class="px-8 py-3 bg-gradient-to-r from-navy to-blue text-white rounded-xl font-semibold hover:opacity-90 transition flex items-center gap-2 shadow-md" class="btn-primary"
id="submit-btn"> id="submit-btn">
<i data-lucide="send" class="w-5 h-5"></i> <i data-lucide="send" class="w-5 h-5"></i>
{% trans "Send Requests" %} {% trans "Send Requests" %}

View File

@ -3,15 +3,153 @@
{% block title %}{{ title }} - {% translate "SLA Configurations" %} - PX360{% endblock %} {% block title %}{{ title }} - {% translate "SLA Configurations" %} - 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;
}
.form-text {
font-size: 0.75rem;
color: #64748b;
margin-top: 0.25rem;
}
.form-check {
display: flex;
align-items: center;
gap: 0.75rem;
}
.form-check-input {
width: 2.5rem;
height: 1.25rem;
appearance: none;
background: #e2e8f0;
border-radius: 9999px;
position: relative;
cursor: pointer;
transition: all 0.2s ease;
}
.form-check-input:checked {
background: #005696;
}
.form-check-input::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 1rem;
height: 1rem;
background: white;
border-radius: 50%;
transition: all 0.2s ease;
}
.form-check-input:checked::after {
left: calc(100% - 1.125rem);
}
.invalid-feedback {
color: #dc2626;
font-size: 0.75rem;
margin-top: 0.25rem;
}
.timing-section {
background: #f8fafc;
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 1rem;
}
.timing-section h4 {
color: #1e293b;
font-weight: 600;
margin-bottom: 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.timing-section h4 i {
color: #005696;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="page-header"> <!-- Header -->
<div class="page-header-content"> <div class="page-header-gradient">
<div class="flex items-center justify-between">
<div> <div>
<h1 class="page-title"> <h1 class="text-2xl font-bold flex items-center gap-3">
<i class="fas fa-clock"></i> <i data-lucide="clock" class="w-7 h-7"></i>
{{ title }} {{ title }}
</h1> </h1>
<p class="page-description"> <p class="text-white/80 mt-1">
{% if sla_config %} {% if sla_config %}
{% translate "Edit SLA configuration" %} {% translate "Edit SLA configuration" %}
{% else %} {% else %}
@ -19,226 +157,344 @@
{% endif %} {% endif %}
</p> </p>
</div> </div>
<a href="{% url 'complaints:sla_config_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'complaints:sla_config_list' %}" class="btn-secondary">
<i class="fas fa-arrow-left"></i> <i data-lucide="arrow-left" class="w-4 h-4"></i>
{% translate "Back to List" %} {% translate "Back to List" %}
</a> </a>
</div> </div>
</div> </div>
<div class="page-content"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="row"> <div class="lg:col-span-2">
<div class="col-lg-8"> <div class="form-section">
<div class="card"> <form method="post" class="space-y-6">
<div class="card-body"> {% csrf_token %}
<form method="post" class="row g-3">
{% csrf_token %} <!-- Hospital (hidden for non-admins, visible for admins) -->
{% if not form.hospital.is_hidden %}
{% if request.user.is_px_admin %} <div>
<div class="col-md-12"> <label for="id_hospital" class="form-label">
<label for="id_hospital" class="form-label"> {% translate "Hospital" %} <span class="text-red-500">*</span>
{% translate "Hospital" %} <span class="text-danger">*</span> </label>
</label> <select name="hospital" id="id_hospital" class="form-select" required>
<select name="hospital" id="id_hospital" class="form-select" required> <option value="">{% translate "Select Hospital" %}</option>
<option value="">{% translate "Select Hospital" %}</option> {% for hospital in hospitals %}
{% for hospital in form.hospital.field.queryset %} <option value="{{ hospital.id }}"
<option value="{{ hospital.id }}" {% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}>
{% if form.hospital.value == hospital.id|stringformat:"s" %}selected{% endif %}> {{ hospital.name }}
{{ hospital.name }} </option>
</option> {% endfor %}
{% endfor %} </select>
</select> {% if form.hospital.errors %}
{% if form.hospital.errors %} <div class="invalid-feedback">
<div class="invalid-feedback d-block"> {{ form.hospital.errors.0 }}
{{ form.hospital.errors.0 }} </div>
</div> {% endif %}
{% endif %} </div>
{% else %}
{{ form.hospital }}
{% endif %}
<!-- Source -->
<div>
<label for="id_source" class="form-label">
{% translate "Complaint Source" %}
</label>
<select name="source" id="id_source" class="form-select">
<option value="">{% translate "Any Source" %}</option>
{% for source in sources %}
<option value="{{ source.id }}"
{% if form.source.value == source.id|stringformat:"s" %}selected{% endif %}>
{{ source.name_en }}
</option>
{% endfor %}
</select>
<div class="form-text">
{% translate "Select a specific source for source-based SLA, or leave blank for severity/priority-based SLA" %}
</div>
{% if form.source.errors %}
<div class="invalid-feedback">
{{ form.source.errors.0 }}
</div>
{% endif %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="id_severity" class="form-label">
{% translate "Severity" %}
</label>
<select name="severity" id="id_severity" class="form-select">
<option value="">{% translate "Any Severity" %}</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">
{{ form.severity.errors.0 }}
</div> </div>
{% endif %} {% endif %}
</div>
<div class="col-md-6">
<label for="id_severity" class="form-label"> <div>
{% translate "Severity" %} <span class="text-danger">*</span> <label for="id_priority" class="form-label">
</label> {% translate "Priority" %}
<select name="severity" id="id_severity" class="form-select" required> </label>
<option value="">{% translate "Select Severity" %}</option> <select name="priority" id="id_priority" class="form-select">
{% for value, label in form.severity.field.choices %} <option value="">{% translate "Any Priority" %}</option>
<option value="{{ value }}" {% for value, label in form.priority.field.choices %}
{% if form.severity.value == value %}selected{% endif %}> <option value="{{ value }}"
{{ label }} {% if form.priority.value == value %}selected{% endif %}>
</option> {{ label }}
{% endfor %} </option>
</select> {% endfor %}
{% if form.severity.errors %} </select>
<div class="invalid-feedback d-block"> {% if form.priority.errors %}
{{ form.severity.errors.0 }} <div class="invalid-feedback">
</div> {{ form.priority.errors.0 }}
{% endif %}
</div> </div>
{% endif %}
<div class="col-md-6"> </div>
<label for="id_priority" class="form-label"> </div>
{% translate "Priority" %} <span class="text-danger">*</span>
</label> <div>
<select name="priority" id="id_priority" class="form-select" required> <label for="id_sla_hours" class="form-label">
<option value="">{% translate "Select Priority" %}</option> {% translate "SLA Deadline (Hours)" %} <span class="text-red-500">*</span>
{% for value, label in form.priority.field.choices %} </label>
<option value="{{ value }}" <input type="number"
{% if form.priority.value == value %}selected{% endif %}> name="sla_hours"
{{ label }} id="id_sla_hours"
</option> class="form-control"
{% endfor %} value="{{ form.sla_hours.value|default:'' }}"
</select> min="1"
{% if form.priority.errors %} step="0.5"
<div class="invalid-feedback d-block"> required
{{ form.priority.errors.0 }} placeholder="{% translate 'e.g., 24' %}">
</div> {% if form.sla_hours.errors %}
{% endif %} <div class="invalid-feedback">
</div> {{ form.sla_hours.errors.0 }}
</div>
<div class="col-md-6"> {% else %}
<label for="id_sla_hours" class="form-label"> <div class="form-text">
{% translate "SLA Deadline (Hours)" %} <span class="text-danger">*</span> {% translate "Total hours from complaint creation until SLA deadline" %}
</div>
{% endif %}
</div>
<!-- Reminder Timing Section -->
<div class="timing-section">
<h4>
<i data-lucide="bell" class="w-5 h-5"></i>
{% translate "Reminder Timing (Source-Based)" %}
</h4>
<p class="text-sm text-slate-600 mb-4">
{% translate "Configure when reminders are sent after complaint creation. Set to 0 to use legacy timing (hours before deadline)." %}
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="id_first_reminder_hours_after" class="form-label">
{% translate "First Reminder (Hours After Creation)" %}
</label> </label>
<input type="number" <input type="number"
name="sla_hours" name="first_reminder_hours_after"
id="id_sla_hours" id="id_first_reminder_hours_after"
class="form-control" class="form-control"
value="{{ form.sla_hours.value|default:'' }}" value="{{ form.first_reminder_hours_after.value|default:'' }}"
min="1" min="0"
step="0.5" step="0.5"
required placeholder="{% translate 'e.g., 12' %}">
placeholder="{% translate 'e.g., 24' %}"> {% if form.first_reminder_hours_after.errors %}
{% if form.sla_hours.errors %} <div class="invalid-feedback">
<div class="invalid-feedback d-block"> {{ form.first_reminder_hours_after.errors.0 }}
{{ form.sla_hours.errors.0 }}
</div> </div>
{% else %} {% else %}
<div class="form-text"> <div class="form-text">
{% translate "Hours after complaint creation before deadline" %} {% translate "Send first reminder X hours after complaint creation (0 = use legacy timing)" %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="col-md-6"> <div>
<label for="id_warning_hours" class="form-label"> <label for="id_second_reminder_hours_after" class="form-label">
{% translate "Warning Threshold (Hours)" %} <span class="text-danger">*</span> {% translate "Second Reminder (Hours After Creation)" %}
</label> </label>
<input type="number" <input type="number"
name="warning_hours" name="second_reminder_hours_after"
id="id_warning_hours" id="id_second_reminder_hours_after"
class="form-control" class="form-control"
value="{{ form.warning_hours.value|default:'' }}" value="{{ form.second_reminder_hours_after.value|default:'' }}"
min="1" min="0"
step="0.5" step="0.5"
required
placeholder="{% translate 'e.g., 18' %}"> placeholder="{% translate 'e.g., 18' %}">
{% if form.warning_hours.errors %} {% if form.second_reminder_hours_after.errors %}
<div class="invalid-feedback d-block"> <div class="invalid-feedback">
{{ form.warning_hours.errors.0 }} {{ form.second_reminder_hours_after.errors.0 }}
</div> </div>
{% else %} {% else %}
<div class="form-text"> <div class="form-text">
{% translate "Hours before deadline to send warning notification" %} {% translate "Send second reminder X hours after complaint creation (0 = use legacy timing)" %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Legacy Timing Section -->
<div class="timing-section bg-gray-50">
<h4>
<i data-lucide="history" class="w-5 h-5"></i>
{% translate "Legacy Reminder Timing (Before Deadline)" %}
</h4>
<p class="text-sm text-slate-500 mb-4">
{% translate "These settings are used when the new source-based timing is set to 0. Kept for backward compatibility." %}
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="id_reminder_hours_before" class="form-label">
{% translate "First Reminder (Hours Before)" %}
</label>
<input type="number"
name="reminder_hours_before"
id="id_reminder_hours_before"
class="form-control"
value="{{ form.reminder_hours_before.value|default:'24' }}"
min="0"
step="0.5"
placeholder="{% translate 'e.g., 24' %}">
{% if form.reminder_hours_before.errors %}
<div class="invalid-feedback">
{{ form.reminder_hours_before.errors.0 }}
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="col-12"> <div>
<div class="form-check form-switch"> <div class="form-check mb-2">
<input type="checkbox" <input type="checkbox"
name="is_active" name="second_reminder_enabled"
id="id_is_active" id="id_second_reminder_enabled"
class="form-check-input" class="form-check-input"
{% if form.is_active.value == 'on' or not form.is_active.value %}checked{% endif %}> {% if form.second_reminder_enabled.value %}checked{% endif %}>
<label class="form-check-label" for="id_is_active"> <label class="form-label mb-0" for="id_second_reminder_enabled">
{% translate "Active" %} {% translate "Enable Second Reminder" %}
</label> </label>
</div> </div>
<div class="form-text"> <label for="id_second_reminder_hours_before" class="form-label">
{% translate "Only active configurations will be applied to complaints" %} {% translate "Second Reminder (Hours Before)" %}
</div>
</div>
<div class="col-12">
<label for="id_description" class="form-label">
{% translate "Description" %}
</label> </label>
<textarea name="description" <input type="number"
id="id_description" name="second_reminder_hours_before"
class="form-control" id="id_second_reminder_hours_before"
rows="3" class="form-control"
placeholder="{% translate 'Optional notes about this configuration' %}">{{ form.description.value|default:'' }}</textarea> value="{{ form.second_reminder_hours_before.value|default:'6' }}"
{% if form.description.errors %} min="0"
<div class="invalid-feedback d-block"> step="0.5"
{{ form.description.errors.0 }} placeholder="{% translate 'e.g., 6' %}">
</div>
{% endif %}
</div> </div>
</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:sla_config_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i>
{% translate "Cancel" %}
</a>
</div>
</div>
</form>
</div> </div>
</div>
<div class="flex items-center justify-between p-4 bg-[#f8fafc] rounded-lg">
<div>
<label class="form-label mb-0">{% translate "Active" %}</label>
<div class="form-text">
{% translate "Only active configurations will be applied to complaints" %}
</div>
</div>
<div class="form-check">
<input type="checkbox"
name="is_active"
id="id_is_active"
class="form-check-input"
{% if form.is_active.value == 'on' or form.is_active.value or not form.is_active.value %}checked{% endif %}>
</div>
</div>
<div class="flex items-center gap-3 pt-4 border-t border-[#e2e8f0]">
<button type="submit" class="btn-primary">
<i data-lucide="save" class="w-4 h-4"></i>
{{ action }}
</button>
<a href="{% url 'complaints:sla_config_list' %}" class="btn-secondary">
<i data-lucide="x" class="w-4 h-4"></i>
{% translate "Cancel" %}
</a>
</div>
</form>
</div> </div>
</div>
<div class="col-lg-4">
<div class="card bg-light"> <div class="lg:col-span-1">
<div class="card-body"> <div class="form-section bg-[#f8fafc]">
<h5 class="card-title"> <h5 class="font-bold text-[#1e293b] flex items-center gap-2 mb-3">
<i class="fas fa-info-circle"></i> <i data-lucide="info" class="w-5 h-5 text-[#005696]"></i>
{% translate "Help" %} {% translate "Help" %}
</h5> </h5>
<h6 class="card-subtitle mb-3 text-muted"> <h6 class="text-sm font-semibold text-[#64748b] mb-3">
{% translate "Understanding SLA Configuration" %} {% translate "Understanding SLA Configuration" %}
</h6> </h6>
<p class="card-text"> <p class="text-sm text-[#64748b] mb-4">
{% translate "Service Level Agreements (SLAs) define the timeframes within which complaints should be resolved based on their severity and priority." %} {% translate "SLA configurations define deadlines for complaints based on source, severity, and priority." %}
</p> </p>
<ul class="list-unstyled">
<li class="mb-2"> <h6 class="text-sm font-semibold text-[#64748b] mb-3">
<i class="fas fa-check text-success me-2"></i> {% translate "Source-Based vs Severity/Priority-Based" %}
{% translate "High severity complaints typically have shorter SLAs" %} </h6>
</li> <ul class="space-y-2 mb-4 text-sm text-[#64748b]">
<li class="mb-2"> <li class="flex items-start gap-2">
<i class="fas fa-check text-success me-2"></i> <i data-lucide="radio" class="w-4 h-4 text-blue-500 mt-1"></i>
{% translate "Warning threshold sends notifications before deadline" %} <span><strong>{% translate "Source-Based:" %}</strong> {% translate "Use when different complaint sources (MOH, CCHI, Internal) need different SLAs" %}</span>
</li> </li>
<li class="mb-2"> <li class="flex items-start gap-2">
<i class="fas fa-check text-success me-2"></i> <i data-lucide="sliders" class="w-4 h-4 text-green-500 mt-1"></i>
{% translate "Inactive configurations won't be applied" %} <span><strong>{% translate "Severity/Priority-Based:" %}</strong> {% translate "Use for general SLA based on complaint characteristics" %}</span>
</li> </li>
</ul> </ul>
<hr> <h6 class="text-sm font-semibold text-[#64748b] mb-3">
{% translate "Reminder Timing" %}
<h6 class="card-subtitle mb-2 text-muted"> </h6>
{% translate "Best Practices" %} <ul class="space-y-2 mb-4 text-sm text-[#64748b]">
</h6> <li class="flex items-start gap-2">
<ul class="list-unstyled"> <i data-lucide "clock" class="w-4 h-4 text-amber-500 mt-1"></i>
<li class="mb-2"> <span><strong>{% translate "Source-Based Timing:" %}</strong> {% translate "Set reminder hours after creation (e.g., remind 12h after creation for 24h SLA)" %}</span>
<i class="fas fa-lightbulb text-warning me-2"></i> </li>
{% translate "Set warning threshold at least 4-6 hours before deadline" %} <li class="flex items-start gap-2">
</li> <i data-lucide="history" class="w-4 h-4 text-slate-400 mt-1"></i>
<li class="mb-2"> <span><strong>{% translate "Legacy Timing:" %}</strong> {% translate "Set reminder hours before deadline (e.g., remind 6h before deadline)" %}</span>
<i class="fas fa-lightbulb text-warning me-2"></i> </li>
{% translate "Consider hospital-specific requirements" %} </ul>
</li>
</ul> <h6 class="text-sm font-semibold text-[#64748b] mb-3">
</div> {% translate "Best Practices" %}
</div> </h6>
<ul class="space-y-2">
<li class="flex items-center gap-2 text-sm text-[#64748b]">
<i data-lucide="lightbulb" class="w-4 h-4 text-amber-500"></i>
{% translate "Set first reminder at ~50% of SLA time" %}
</li>
<li class="flex items-center gap-2 text-sm text-[#64748b]">
<i data-lucide="lightbulb" class="w-4 h-4 text-amber-500"></i>
{% translate "Set second reminder at ~75% of SLA time or or 6h before deadline" %}
</li>
<li class="flex items-center gap-2 text-sm text-[#64748b]">
<i data-lucide="lightbulb" class="w-4 h-4 text-amber-500"></i>
{% translate "Ensure at least one admin/coordinator exists per hospital" %}
</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %} {% endblock %}

View File

@ -3,39 +3,239 @@
{% block title %}{% translate "SLA Configurations" %} - PX360{% endblock %} {% block title %}{% translate "SLA Configurations" %} - PX360{% endblock %}
{% block content %} {% block extra_css %}
<div class="page-header"> <style>
<div class="page-header-content"> :root {
<div> --hh-navy: #005696;
<h1 class="page-title"> --hh-blue: #007bbd;
<i class="fas fa-clock"></i> --hh-light: #eef6fb;
{% translate "SLA Configurations" %} --hh-slate: #64748b;
</h1> }
<p class="page-description">
{% translate "Manage Service Level Agreement settings for complaint deadlines" %}
</p>
</div>
<a href="{% url 'complaints:sla_config_create' %}" class="btn btn-primary">
<i class="fas fa-plus"></i>
{% translate "Create SLA Config" %}
</a>
</div>
</div>
<div class="page-content"> .page-header-gradient {
<!-- Filters --> background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
<div class="card mb-4"> color: white;
<div class="card-header"> padding: 1.5rem 2rem;
<h5 class="card-title"> border-radius: 1rem;
<i class="fas fa-filter"></i> margin-bottom: 1.5rem;
{% translate "Filters" %} box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2);
</h5> }
.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-success {
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
color: #166534;
}
.badge-secondary {
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
color: #475569;
}
.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="clock" class="w-7 h-7 inline-block me-2"></i>
{% translate "SLA Configurations" %}
</h1>
<p class="text-white/90">{% translate "Manage Service Level Agreement settings for complaint deadlines" %}</p>
</div>
<a href="{% url 'complaints:sla_config_create' %}" class="btn-secondary">
<i data-lucide="plus" class="w-4 h-4"></i>
{% translate "Create SLA Config" %}
</a>
</div> </div>
<div class="card-body"> </div>
<form method="get" class="row g-3">
<div class="col-md-3"> <!-- Filters -->
<label class="form-label">{% translate "Severity" %}</label> <div class="section-card mb-6 animate-in">
<select name="severity" class="form-select"> <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 "Severity" %}</label>
<select name="severity" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% translate "All Severities" %}</option> <option value="">{% translate "All Severities" %}</option>
{% for value, label in severity_choices %} {% for value, label in severity_choices %}
<option value="{{ value }}" {% if filters.severity == value %}selected{% endif %}> <option value="{{ value }}" {% if filters.severity == value %}selected{% endif %}>
@ -44,10 +244,9 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div>
<div class="col-md-3"> <label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Priority" %}</label>
<label class="form-label">{% translate "Priority" %}</label> <select name="priority" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<select name="priority" class="form-select">
<option value="">{% translate "All Priorities" %}</option> <option value="">{% translate "All Priorities" %}</option>
{% for value, label in priority_choices %} {% for value, label in priority_choices %}
<option value="{{ value }}" {% if filters.priority == value %}selected{% endif %}> <option value="{{ value }}" {% if filters.priority == value %}selected{% endif %}>
@ -56,23 +255,21 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div>
<div class="col-md-3"> <label class="block text-sm font-semibold text-slate mb-1.5">{% translate "Status" %}</label>
<label class="form-label">{% 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">
<select name="is_active" class="form-select">
<option value="">{% translate "All" %}</option> <option value="">{% translate "All" %}</option>
<option value="true" {% if filters.is_active == "true" %}selected{% endif %}>{% translate "Active" %}</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> <option value="false" {% if filters.is_active == "false" %}selected{% endif %}>{% translate "Inactive" %}</option>
</select> </select>
</div> </div>
<div class="flex items-end gap-2">
<div class="col-12 text-end"> <button type="submit" class="btn-primary h-[46px]">
<button type="submit" class="btn btn-primary"> <i data-lucide="search" class="w-4 h-4"></i>
<i class="fas fa-search"></i>
{% translate "Apply Filters" %} {% translate "Apply Filters" %}
</button> </button>
<a href="{% url 'complaints:sla_config_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'complaints:sla_config_list' %}" class="btn-secondary h-[46px]">
<i class="fas fa-times"></i> <i data-lucide="x" class="w-4 h-4"></i>
{% translate "Clear" %} {% translate "Clear" %}
</a> </a>
</div> </div>
@ -81,11 +278,17 @@
</div> </div>
<!-- SLA Configurations Table --> <!-- SLA Configurations Table -->
<div class="card"> <div class="section-card animate-in">
<div class="card-body"> <div class="section-header">
<div class="section-icon primary">
<i data-lucide="clock" class="w-5 h-5"></i>
</div>
<h2 class="text-lg font-bold text-navy m-0">{% translate "All SLA Configurations" %}</h2>
</div>
<div class="p-0">
{% if sla_configs %} {% if sla_configs %}
<div class="table-responsive"> <div class="overflow-x-auto">
<table class="table table-hover"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th>{% translate "Hospital" %}</th> <th>{% translate "Hospital" %}</th>
@ -94,7 +297,7 @@
<th>{% translate "SLA Hours" %}</th> <th>{% translate "SLA Hours" %}</th>
<th>{% translate "Warning Hours" %}</th> <th>{% translate "Warning Hours" %}</th>
<th>{% translate "Status" %}</th> <th>{% translate "Status" %}</th>
<th>{% translate "Actions" %}</th> <th class="text-right">{% translate "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -117,17 +320,23 @@
<td>{{ config.warning_hours }}h</td> <td>{{ config.warning_hours }}h</td>
<td> <td>
{% if config.is_active %} {% if config.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 %} {% 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 %} {% endif %}
</td> </td>
<td> <td class="text-right">
<div class="btn-group"> <div class="flex items-center justify-end gap-2">
<a href="{% url 'complaints:sla_config_edit' config.id %}" <a href="{% url 'complaints:sla_config_edit' config.id %}"
class="btn btn-sm btn-outline-primary" class="p-2 text-blue hover:bg-blue-50 rounded-lg transition"
title="{% translate 'Edit' %}"> title="{% translate 'Edit' %}">
<i class="fas fa-edit"></i> <i data-lucide="edit" class="w-4 h-4"></i>
</a> </a>
<form method="post" <form method="post"
action="{% url 'complaints:sla_config_delete' config.id %}" action="{% url 'complaints:sla_config_delete' config.id %}"
@ -135,9 +344,9 @@
onsubmit="return confirm('{% translate "Are you sure you want to delete this SLA configuration?" %}')"> onsubmit="return confirm('{% translate "Are you sure you want to delete this SLA configuration?" %}')">
{% csrf_token %} {% csrf_token %}
<button type="submit" <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' %}"> title="{% translate 'Delete' %}">
<i class="fas fa-trash"></i> <i data-lucide="trash-2" class="w-4 h-4"></i>
</button> </button>
</form> </form>
</div> </div>
@ -150,59 +359,42 @@
<!-- Pagination --> <!-- Pagination -->
{% if page_obj.has_other_pages %} {% if page_obj.has_other_pages %}
<nav class="mt-4"> <div class="p-4 border-t border-slate-200">
<ul class="pagination justify-content-center"> <div class="flex items-center justify-between">
{% if page_obj.has_previous %} <p class="text-sm text-slate">
<li class="page-item"> {% blocktrans with start=page_obj.start_index end=page_obj.end_index total=page_obj.paginator.count %}
<a class="page-link" href="?page=1{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"> Showing {{ start }} to {{ end }} of {{ total }} configurations
<i class="fas fa-angle-double-left"></i> {% 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> </a>
</li> {% endif %}
<li class="page-item"> {% if page_obj.has_next %}
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"> <a href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}"
<i class="fas fa-angle-left"></i> 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> </a>
</li> {% endif %}
{% endif %} </div>
</div>
{% for num in page_obj.paginator.page_range %} </div>
{% 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 %} {% endif %}
{% else %} {% else %}
<div class="text-center py-5"> <div class="text-center py-12">
<i class="fas fa-clock fa-3x text-muted mb-3"></i> <div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<p class="text-muted"> <i data-lucide="clock" class="w-8 h-8 text-slate-400"></i>
{% translate "No SLA configurations found. Create your first configuration to get started." %} </div>
</p> <p class="text-slate font-medium">{% translate "No SLA configurations found" %}</p>
<a href="{% url 'complaints:sla_config_create' %}" class="btn btn-primary"> <p class="text-slate text-sm mt-1">{% translate "Create your first configuration to get started" %}</p>
<i class="fas fa-plus"></i> <a href="{% url 'complaints:sla_config_create' %}" class="btn-primary mt-4">
<i data-lucide="plus" class="w-4 h-4"></i>
{% translate "Create SLA Config" %} {% translate "Create SLA Config" %}
</a> </a>
</div> </div>
@ -210,4 +402,10 @@
</div> </div>
</div> </div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
});
</script>
{% endblock %} {% endblock %}

View File

@ -12,19 +12,62 @@
--hh-slate: #64748b; --hh-slate: #64748b;
} }
.page-header { .page-header-gradient {
background: linear-gradient(135deg, var(--hh-navy) 0%, #0069a8 50%, var(--hh-blue) 100%); background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%);
color: white; color: white;
padding: 2rem 2.5rem; padding: 1.5rem 2rem;
border-radius: 1rem; border-radius: 1rem;
margin-bottom: 2rem; margin-bottom: 1.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); 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;
} }
.template-card { .template-card {
background: white; background: white;
border-radius: 1rem; border-radius: 1rem;
border: 1px solid #e2e8f0; border: 2px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 1.5rem; padding: 1.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer; cursor: pointer;
@ -33,7 +76,7 @@
.template-card:hover { .template-card:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.1); box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.1);
border-color: var(--hh-blue); border-color: #005696;
} }
.usage-badge { .usage-badge {
@ -69,6 +112,28 @@
box-shadow: 0 8px 16px rgba(0, 86, 150, 0.3); box-shadow: 0 8px 16px rgba(0, 86, 150, 0.3);
} }
.btn-secondary {
background: white;
color: #475569;
padding: 0.625rem 1.25rem;
border-radius: 0.75rem;
font-weight: 600;
border: 2px solid #e2e8f0;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
font-size: 0.875rem;
}
.btn-secondary:hover {
background: #f1f5f9;
border-color: #cbd5e1;
transform: translateY(-1px);
}
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); } from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
@ -83,7 +148,7 @@
{% block content %} {% block content %}
<div class="px-4 py-6"> <div class="px-4 py-6">
<!-- Page Header --> <!-- Page Header -->
<div class="page-header animate-in"> <div class="page-header-gradient animate-in">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-2xl font-bold mb-2"> <h1 class="text-2xl font-bold mb-2">
@ -92,7 +157,7 @@
</h1> </h1>
<p class="text-white/90">{% trans "Pre-defined templates for common complaints" %}</p> <p class="text-white/90">{% trans "Pre-defined templates for common complaints" %}</p>
</div> </div>
<a href="{% url 'complaints:template_create' %}" class="inline-flex items-center gap-2 bg-white text-navy px-5 py-2.5 rounded-xl font-bold hover:bg-light transition shadow-lg"> <a href="{% url 'complaints:template_create' %}" class="btn-secondary">
<i data-lucide="plus" class="w-4 h-4"></i> <i data-lucide="plus" class="w-4 h-4"></i>
{% trans "New Template" %} {% trans "New Template" %}
</a> </a>
@ -100,29 +165,37 @@
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 mb-6 animate-in"> <div class="section-card mb-6 animate-in">
<form method="get" class="flex flex-wrap gap-4"> <div class="section-header">
<div> <div class="section-icon secondary">
<label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Status" %}</label> <i data-lucide="filter" class="w-5 h-5"></i>
<select name="is_active" class="px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue bg-white">
<option value="">{% trans "All Status" %}</option>
<option value="true" {% if is_active_filter == 'true' %}selected{% endif %}>{% trans "Active" %}</option>
<option value="false" {% if is_active_filter == 'false' %}selected{% endif %}>{% trans "Inactive" %}</option>
</select>
</div> </div>
<div class="flex-1 min-w-[250px]"> <h2 class="text-lg font-bold text-navy m-0">{% trans "Filters" %}</h2>
<label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Search" %}</label> </div>
<input type="text" name="search" value="{{ search }}" <div class="p-6">
placeholder="{% trans 'Template name...' %}" <form method="get" class="flex flex-wrap gap-4">
class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue"> <div>
</div> <label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Status" %}</label>
<div class="flex items-end"> <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">
<button type="submit" class="btn-primary h-[46px]"> <option value="">{% trans "All Status" %}</option>
<i data-lucide="search" class="w-4 h-4"></i> <option value="true" {% if is_active_filter == 'true' %}selected{% endif %}>{% trans "Active" %}</option>
{% trans "Filter" %} <option value="false" {% if is_active_filter == 'false' %}selected{% endif %}>{% trans "Inactive" %}</option>
</button> </select>
</div> </div>
</form> <div class="flex-1 min-w-[250px]">
<label class="block text-sm font-semibold text-slate mb-1.5">{% trans "Search" %}</label>
<input type="text" name="search" value="{{ search }}"
placeholder="{% trans 'Template name...' %}"
class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-blue">
</div>
<div class="flex items-end">
<button type="submit" class="btn-primary h-[46px]">
<i data-lucide="search" class="w-4 h-4"></i>
{% trans "Filter" %}
</button>
</div>
</form>
</div>
</div> </div>
<!-- Templates Grid --> <!-- Templates Grid -->
@ -184,13 +257,13 @@
</div> </div>
{% empty %} {% empty %}
<div class="col-span-full"> <div class="col-span-full">
<div class="text-center py-12"> <div class="section-card p-12 text-center">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4"> <div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="file-template" class="w-8 h-8 text-slate-400"></i> <i data-lucide="file-template" class="w-8 h-8 text-slate-400"></i>
</div> </div>
<p class="text-slate font-medium">{% trans "No templates found" %}</p> <p class="text-slate font-medium">{% trans "No templates found" %}</p>
<p class="text-slate text-sm mt-1">{% trans "Create your first template to get started" %}</p> <p class="text-slate text-sm mt-1">{% trans "Create your first template to get started" %}</p>
<a href="{% url 'complaints:template_create' %}" class="inline-flex items-center gap-2 mt-4 btn-primary"> <a href="{% url 'complaints:template_create' %}" class="btn-primary mt-4">
<i data-lucide="plus" class="w-4 h-4"></i> <i data-lucide="plus" class="w-4 h-4"></i>
{% trans "Create Template" %} {% trans "Create Template" %}
</a> </a>

View File

@ -1,10 +1,56 @@
{% extends "layouts/public_base.html" %} {% extends "layouts/public_base.html" %}
{% load i18n %} {% load i18n static %}
{% block title %}{% trans "Submit Feedback" %} - PX360{% endblock %} {% block title %}{% trans "Submit Feedback" %} - PX360{% endblock %}
{% block extra_css %} {% block extra_css %}
<style> <style>
/* Hide the header on this page */
header.glass-card {
display: none !important;
}
/* Language switcher styles */
.lang-switcher {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 100;
display: flex;
gap: 0.5rem;
}
.lang-switcher a {
padding: 0.5rem 1rem;
border-radius: 0.75rem;
border: 2px solid rgba(255,255,255,0.3);
background: rgba(255,255,255,0.1);
backdrop-filter: blur(10px);
color: white;
font-weight: 600;
font-size: 0.875rem;
transition: all 0.2s ease;
text-decoration: none;
display: flex;
align-items: center;
gap: 0.5rem;
}
.lang-switcher a:hover {
background: rgba(255,255,255,0.2);
border-color: rgba(255,255,255,0.5);
}
.lang-switcher a.active {
background: white;
color: #005696;
border-color: white;
}
[dir="rtl"] .lang-switcher {
right: auto;
left: 1rem;
}
/* PX360 Theme */ /* PX360 Theme */
.selection-card { .selection-card {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
@ -216,16 +262,33 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Language Switcher -->
<div class="lang-switcher">
{% get_available_languages as LANGUAGES %}
{% get_current_language as LANGUAGE_CODE %}
{% for lang_code, lang_name in LANGUAGES %}
<a href="{% url 'core:set_language' %}?language={{ lang_code }}"
class="{% if LANGUAGE_CODE == lang_code %}active{% endif %}">
<span>{% if lang_code == 'en' %}🇬🇧{% elif lang_code == 'ar' %}🇸🇦{% endif %}</span>
<span>{{ lang_name }}</span>
</a>
{% endfor %}
</div>
<div class="max-w-6xl mx-auto"> <div class="max-w-6xl mx-auto">
<!-- Welcome Card --> <!-- Welcome Card -->
<div class="glass-card rounded-3xl shadow-2xl p-8 mb-8 text-center animate-fade-in"> <div class="rounded-3xl shadow-2xl overflow-hidden mb-8 text-center animate-fade-in">
<div class="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-blue to-navy rounded-2xl mb-4 shadow-lg"> <!-- Logo Banner - Full Width White -->
<i data-lucide="heart-handshake" class="w-10 h-10 text-white"></i> <div class="bg-white w-full py-8 px-6 flex items-center justify-center">
<img src="{% static 'img/hh-logo.png' %}" alt="Al Hammadi Hospital" class="max-h-48 w-auto object-contain">
</div>
<!-- Text Content Below - White Background -->
<div class="bg-white p-8">
<h1 class="text-3xl font-bold text-navy mb-3">{% trans "We Value Your Feedback" %}</h1>
<p class="text-slate text-base max-w-xl mx-auto">
{% trans "Your feedback helps us improve our services and provide better care for everyone. Choose a category below to get started." %}
</p>
</div> </div>
<h1 class="text-3xl font-bold text-navy mb-3">{% trans "We Value Your Feedback" %}</h1>
<p class="text-slate text-base max-w-xl mx-auto">
{% trans "Your feedback helps us improve our services and provide better care for everyone. Choose a category below to get started." %}
</p>
</div> </div>
<!-- Selection Cards --> <!-- Selection Cards -->

View File

@ -1,278 +1,246 @@
{% extends "layouts/base.html" %}
{% load i18n static %} {% load i18n static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans "Select Hospital" %} - PX360</title>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
navy: '#005696',
blue: '#007bbd',
light: '#eef6fb',
slate: '#64748b',
}
}
}
}
</script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
.hospital-card {
transition: all 0.2s ease;
}
.hospital-card:hover {
transform: translateY(-2px);
}
.hospital-card.selected {
border-color: #007bbd;
background-color: rgba(0, 123, 189, 0.05);
}
</style>
</head>
<body class="bg-gradient-to-br from-navy via-blue to-light min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-4xl">
<!-- Card -->
<div class="bg-white rounded-[2rem] shadow-2xl overflow-hidden">
<!-- Header -->
<div class="bg-gradient-to-br from-navy to-blue text-white p-8 text-center">
<div class="mb-4">
<img src="{% static 'img/hh-logo.png' %}" alt="Al Hammadi Hospital" class="h-20 w-auto mx-auto bg-white/90 backdrop-blur-sm p-2 rounded-2xl inline-block">
</div>
<h1 class="text-2xl font-bold mb-2">{% trans "Select Hospital" %}</h1>
<p class="text-white/90 text-sm max-w-lg mx-auto">
{% trans "As a PX Admin, you must select a hospital to continue. You can change your selection later from the sidebar." %}
</p>
</div>
{% block title %}{% trans "Select Hospital" %} - PX360{% endblock %} <!-- Body -->
<div class="p-8">
{% block content %} {% if messages %}
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="mb-6 space-y-3">
<!-- Header --> {% for message in messages %}
<div class="text-center mb-8"> <div class="{% if message.tags == 'error' or message.tags == 'danger' %}bg-red-50 border-red-200 text-red-700{% elif message.tags == 'warning' %}bg-amber-50 border-amber-200 text-amber-700{% elif message.tags == 'success' %}bg-green-50 border-green-200 text-green-700{% else %}bg-blue-50 border-blue-200 text-blue-700{% endif %} border rounded-xl px-4 py-3 flex items-start gap-3" role="alert">
<div class="mb-4"> {% if message.tags == 'error' or message.tags == 'danger' %}
<img src="{% static 'img/hh-logo.png' %}" alt="Al Hammadi Hospital" class="h-20 mx-auto"> <i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
</div> {% elif message.tags == 'warning' %}
<h1 class="text-3xl font-bold text-navy mb-3"> <i data-lucide="alert-circle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
{% trans "Select Hospital" %} {% elif message.tags == 'success' %}
</h1> <i data-lucide="check-circle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
<p class="text-slate text-lg max-w-2xl mx-auto">
{% trans "As a PX Admin, you can view and manage data for any hospital. Please select the hospital you want to work with:" %}
</p>
</div>
<!-- Hospital Selection Form -->
<form method="post" class="space-y-6" id="hospitalForm">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}">
{% if hospitals %}
<!-- Hospital Cards Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for hospital in hospitals %}
{% with hospital_id_str=hospital.id|stringformat:"s" %}
<div
class="hospital-card group relative cursor-pointer rounded-2xl border-2 transition-all duration-200
{% if hospital_id_str == selected_hospital_id %}
border-blue bg-blue-50/50 shadow-md selected
{% else %}
border-slate-200 bg-white hover:border-blue/50 hover:-translate-y-1
{% endif %}"
onclick="selectHospital('{{ hospital_id_str }}')"
data-hospital-id="{{ hospital_id_str }}"
>
<input
type="radio"
id="hospital_{{ hospital.id }}"
name="hospital_id"
value="{{ hospital.id }}"
{% if hospital_id_str == selected_hospital_id %}checked{% endif %}
class="hospital-radio sr-only"
>
<!-- Card Content -->
<div class="p-6">
<!-- Selection Indicator -->
<div class="absolute top-4 right-4">
<div class="selection-indicator w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all
{% if hospital_id_str == selected_hospital_id %}
border-blue bg-blue
{% else %} {% else %}
border-slate-300 <i data-lucide="info" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
{% endif %}"> {% endif %}
<i data-lucide="check" class="w-3.5 h-3.5 text-white transition-opacity <p class="text-sm flex-1">{{ message }}</p>
{% if hospital_id_str == selected_hospital_id %}
opacity-100
{% else %}
opacity-0
{% endif %}"></i>
</div> </div>
{% endfor %}
</div> </div>
{% endif %}
<!-- Hospital Icon --> <form method="post" id="hospitalForm">
<div class="hospital-icon w-14 h-14 rounded-xl flex items-center justify-center mb-4 transition-all {% csrf_token %}
{% if hospital_id_str == selected_hospital_id %} <input type="hidden" name="next" value="{{ next }}">
bg-gradient-to-br from-blue to-navy ring-4 ring-blue/20
{% else %}
bg-slate-100 group-hover:bg-blue-50
{% endif %}">
<i data-lucide="building-2" class="w-7 h-7 transition-colors
{% if hospital_id_str == selected_hospital_id %}
text-white
{% else %}
text-slate-500 group-hover:text-blue
{% endif %}"></i>
</div>
<!-- Hospital Name --> {% if hospitals %}
<h3 class="hospital-name text-lg font-bold mb-2 transition-colors pr-8 <!-- Hospital Selection Grid -->
{% if hospital_id_str == selected_hospital_id %} <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
text-blue {% for hospital in hospitals %}
{% else %} {% with hospital_id_str=hospital.id|stringformat:"s" %}
text-navy group-hover:text-blue <div
{% endif %}"> class="hospital-card cursor-pointer rounded-xl border-2 p-4
{{ hospital.name }} {% if hospital_id_str == selected_hospital_id %}
</h3> border-blue selected bg-blue-50/50
{% else %}
<!-- Location --> border-gray-200 hover:border-blue/50 hover:shadow-md
{% if hospital.city %} {% endif %}"
<div class="flex items-center gap-2 text-slate text-sm"> onclick="selectHospital('{{ hospital_id_str }}')"
<i data-lucide="map-pin" class="w-4 h-4 text-slate/70"></i> data-hospital-id="{{ hospital_id_str }}"
<span> >
{{ hospital.city }} <input
{% if hospital.country %}, {{ hospital.country }}{% endif %} type="radio"
</span> id="hospital_{{ hospital.id }}"
name="hospital_id"
value="{{ hospital.id }}"
{% if hospital_id_str == selected_hospital_id %}checked{% endif %}
class="sr-only"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0
{% if hospital_id_str == selected_hospital_id %}
bg-gradient-to-br from-blue to-navy
{% else %}
bg-gray-100
{% endif %}">
<i data-lucide="building-2" class="w-6 h-6
{% if hospital_id_str == selected_hospital_id %}
text-white
{% else %}
text-gray-500
{% endif %}"></i>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-bold text-navy truncate
{% if hospital_id_str == selected_hospital_id %}text-blue{% endif %}">
{{ hospital.name }}
</h3>
{% if hospital_id_str == selected_hospital_id %}
<i data-lucide="check-circle-2" class="w-5 h-5 text-blue flex-shrink-0"></i>
{% endif %}
</div>
{% if hospital.city %}
<p class="text-sm text-slate mt-1 flex items-center gap-1">
<i data-lucide="map-pin" class="w-3.5 h-3.5"></i>
{{ hospital.city }}{% if hospital.country %}, {{ hospital.country }}{% endif %}
</p>
{% endif %}
</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div> </div>
{% else %} {% else %}
<div class="flex items-center gap-2 text-slate/50 text-sm"> <!-- Empty State -->
<i data-lucide="map-pin" class="w-4 h-4"></i> <div class="bg-amber-50 border border-amber-200 rounded-xl p-8 text-center mb-6">
<span>{% trans "Location not specified" %}</span> <div class="inline-flex items-center justify-center w-16 h-16 bg-amber-100 rounded-full mb-4">
<i data-lucide="alert-triangle" class="w-8 h-8 text-amber-500"></i>
</div>
<h3 class="text-lg font-semibold text-navy mb-2">
{% trans "No Hospitals Available" %}
</h3>
<p class="text-slate text-sm">
{% trans "No hospitals found in the system. Please contact your administrator." %}
</p>
</div> </div>
{% endif %} {% endif %}
<!-- Selected Badge --> <!-- Submit Button -->
<div class="selected-badge mt-4 pt-4 border-t transition-all {% if hospitals %}
{% if hospital_id_str == selected_hospital_id %} <button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white py-3.5 rounded-xl font-semibold shadow-lg hover:shadow-xl transition-all duration-300 flex items-center justify-center gap-2">
block border-blue/20 <i data-lucide="check" class="w-5 h-5"></i>
{% else %} {% trans "Continue to Dashboard" %}
hidden border-slate-100 </button>
{% endif %}"> {% endif %}
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-blue"> </form>
<i data-lucide="check-circle-2" class="w-4 h-4"></i>
{% trans "Currently Selected" %}
</span>
</div>
</div>
</div> </div>
{% endwith %}
{% endfor %}
</div>
{% else %}
<!-- Empty State -->
<div class="bg-white rounded-2xl border border-slate-200 p-12 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-amber-50 rounded-full mb-4">
<i data-lucide="alert-triangle" class="w-8 h-8 text-amber-500"></i>
</div>
<h3 class="text-lg font-semibold text-navy mb-2">
{% trans "No Hospitals Available" %}
</h3>
<p class="text-slate text-sm">
{% trans "No hospitals found in the system. Please contact your administrator." %}
</p>
</div>
{% endif %}
<!-- Action Buttons --> <!-- Footer -->
{% if hospitals %} <div class="bg-gray-50 px-8 py-4 text-center border-t border-gray-100">
<div class="bg-white rounded-2xl border border-slate-200 p-6 mt-8"> <p class="text-xs text-gray-500">
<div class="flex flex-col sm:flex-row justify-between items-center gap-4"> {% trans "You must select a hospital to access the system" %}
<a href="/" class="w-full sm:w-auto px-6 py-3 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 transition flex items-center justify-center gap-2">
<i data-lucide="arrow-left" class="w-5 h-5"></i>
{% trans "Back to Dashboard" %}
</a>
<button type="submit" class="w-full sm:w-auto px-8 py-3 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition flex items-center justify-center gap-2 shadow-lg shadow-blue/20">
<i data-lucide="check" class="w-5 h-5"></i>
{% trans "Continue" %}
</button>
</div>
</div>
{% endif %}
</form>
<!-- Info Banner -->
<div class="bg-blue-50 border border-blue-200 rounded-2xl p-4 mt-6">
<div class="flex items-start gap-3">
<i data-lucide="info" class="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0"></i>
<div>
<p class="text-sm font-semibold text-blue-800 mb-1">
{% trans "Tip" %}
</p>
<p class="text-xs text-blue-700">
{% trans "You can change your selected hospital at any time by clicking on the hospital name in the top navigation bar." %}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { // Initialize Lucide icons
lucide.createIcons(); lucide.createIcons();
});
function selectHospital(hospitalId) { function selectHospital(hospitalId) {
// Check the radio button // Check the radio button
const radio = document.getElementById('hospital_' + hospitalId); const radio = document.getElementById('hospital_' + hospitalId);
if (radio) { if (radio) {
radio.checked = true; radio.checked = true;
}
// Update all cards visual state
document.querySelectorAll('.hospital-card').forEach(card => {
const cardHospitalId = card.dataset.hospitalId;
const isSelected = cardHospitalId === hospitalId;
// Update card border and background
if (isSelected) {
card.classList.remove('border-slate-200', 'bg-white');
card.classList.add('border-blue', 'bg-blue-50/50', 'shadow-md', 'selected');
} else {
card.classList.remove('border-blue', 'bg-blue-50/50', 'shadow-md', 'selected');
card.classList.add('border-slate-200', 'bg-white');
}
// Update selection indicator (check circle)
const indicator = card.querySelector('.selection-indicator');
if (indicator) {
if (isSelected) {
indicator.classList.remove('border-slate-300');
indicator.classList.add('border-blue', 'bg-blue');
} else {
indicator.classList.remove('border-blue', 'bg-blue');
indicator.classList.add('border-slate-300');
} }
// Update all cards
document.querySelectorAll('.hospital-card').forEach(card => {
const cardId = card.dataset.hospitalId;
const isSelected = cardId === hospitalId;
// Update card styling
if (isSelected) {
card.classList.add('border-blue', 'selected', 'bg-blue-50/50');
card.classList.remove('border-gray-200');
} else {
card.classList.remove('border-blue', 'selected', 'bg-blue-50/50');
card.classList.add('border-gray-200');
}
// Update icon container
const iconContainer = card.querySelector('.w-12');
const icon = iconContainer?.querySelector('i');
if (iconContainer && icon) {
if (isSelected) {
iconContainer.classList.add('bg-gradient-to-br', 'from-blue', 'to-navy');
iconContainer.classList.remove('bg-gray-100');
icon.classList.add('text-white');
icon.classList.remove('text-gray-500');
} else {
iconContainer.classList.remove('bg-gradient-to-br', 'from-blue', 'to-navy');
iconContainer.classList.add('bg-gray-100');
icon.classList.remove('text-white');
icon.classList.add('text-gray-500');
}
}
// Update title
const title = card.querySelector('h3');
if (title) {
if (isSelected) {
title.classList.add('text-blue');
} else {
title.classList.remove('text-blue');
}
}
// Update check icon
const checkWrapper = card.querySelector('.flex.items-center.gap-2');
if (checkWrapper) {
let checkIcon = checkWrapper.querySelector('i[data-lucide="check-circle-2"]');
if (isSelected && !checkIcon) {
const newIcon = document.createElement('i');
newIcon.setAttribute('data-lucide', 'check-circle-2');
newIcon.className = 'w-5 h-5 text-blue flex-shrink-0';
checkWrapper.appendChild(newIcon);
lucide.createIcons();
} else if (!isSelected && checkIcon) {
checkIcon.remove();
}
}
});
} }
</script>
// Update check icon </body>
const checkIcon = card.querySelector('.selection-indicator i'); </html>
if (checkIcon) {
if (isSelected) {
checkIcon.classList.remove('opacity-0');
checkIcon.classList.add('opacity-100');
} else {
checkIcon.classList.remove('opacity-100');
checkIcon.classList.add('opacity-0');
}
}
// Update hospital icon
const iconContainer = card.querySelector('.hospital-icon');
if (iconContainer) {
if (isSelected) {
iconContainer.classList.remove('bg-slate-100');
iconContainer.classList.add('bg-gradient-to-br', 'from-blue', 'to-navy', 'ring-4', 'ring-blue/20');
} else {
iconContainer.classList.remove('bg-gradient-to-br', 'from-blue', 'to-navy', 'ring-4', 'ring-blue/20');
iconContainer.classList.add('bg-slate-100');
}
}
// Update icon color
const icon = card.querySelector('.hospital-icon i');
if (icon) {
if (isSelected) {
icon.classList.remove('text-slate-500');
icon.classList.add('text-white');
} else {
icon.classList.remove('text-white');
icon.classList.add('text-slate-500');
}
}
// Update hospital name color
const name = card.querySelector('.hospital-name');
if (name) {
if (isSelected) {
name.classList.remove('text-navy');
name.classList.add('text-blue');
} else {
name.classList.remove('text-blue');
name.classList.add('text-navy');
}
}
// Update selected badge
const badge = card.querySelector('.selected-badge');
if (badge) {
if (isSelected) {
badge.classList.remove('hidden');
badge.classList.add('block', 'border-blue/20');
} else {
badge.classList.remove('block', 'border-blue/20');
badge.classList.add('hidden');
}
}
});
// Re-render icons
lucide.createIcons();
}
</script>
{% endblock %}

View File

@ -0,0 +1,389 @@
# Al Hammadi Hospital Email Templates
Professional, responsive email templates designed with the hospital's brand identity.
## 🎨 Brand Colors
The email templates use Al Hammadi Hospital's official color palette:
| Color | Hex Code | Usage |
|-------|----------|-------|
| **Primary Navy** | `#005696` | Main brand color, headers, primary buttons |
| **Accent Blue** | `#007bbd` | Gradients, secondary elements |
| **Light Background** | `#eef6fb` | Info boxes, highlights |
| **Slate Gray** | `#64748b` | Secondary text |
| **Success Green** | `#10b981` | Positive indicators |
| **Warning Yellow** | `#f59e0b` | Alerts, warnings |
## 📧 Available Templates
### 1. Base Template (`base_email_template.html`)
The foundation template with all common email components:
- Responsive header with logo
- Hero/title section
- Main content area
- Call-to-action button
- Information boxes
- Footer with contact info
**Usage:** Extend this template for all custom emails.
---
### Patient-Facing Templates
### 2. Survey Invitation (`survey_invitation.html`)
Patient survey invitation email with:
- Personalized greeting
- Survey benefits section
- Call-to-action button
- Survey information box
**Context Variables:**
```python
{
'patient_name': 'John Doe',
'visit_date': 'March 10, 2026',
'survey_duration': '3-5 minutes',
'survey_link': 'https://...',
'deadline': 'March 17, 2026'
}
```
### 3. Appointment Confirmation (`appointment_confirmation.html`)
Appointment confirmation email with:
- Appointment details card
- Important reminders
- Reschedule/cancel CTA
- Contact information
**Context Variables:**
```python
{
'patient_name': 'John Doe',
'appointment_id': 'APT-2026-001',
'appointment_date': 'March 15, 2026',
'appointment_time': '10:00 AM',
'department': 'Cardiology',
'doctor_name': 'Dr. Ahmed Smith',
'location': 'Al Hammadi Hospital - Main Branch',
'reschedule_link': 'https://...'
}
```
### 4. Survey Results Notification (`survey_results_notification.html`)
Department survey results notification with:
- Summary statistics cards
- Key highlights
- Action items
- Full report access
**Context Variables:**
```python
{
'recipient_name': 'Dr. Sarah Ahmed',
'department_name': 'Emergency Department',
'overall_score': '4.5/5',
'total_responses': '156',
'response_rate': '78%',
'survey_period': 'February 2026',
'results_link': 'https://...',
'deadline': 'March 31, 2026'
}
```
---
### Staff/Admin Templates
### 5. Explanation Request (`emails/explanation_request.html`)
Complaint explanation request to staff with:
- Complaint details card
- Custom message from PX team
- SLA deadline information
- Submit explanation CTA
**Context Variables:**
```python
{
'staff_name': 'Dr. Ahmed Mohammed',
'complaint_id': 'CMP-2026-00123',
'complaint_title': 'Long wait time in OPD',
'patient_name': 'Mohammed Ali',
'hospital_name': 'Al Hammadi Hospital',
'department_name': 'OPD - Internal Medicine',
'category': 'Wait Time',
'status': 'Under Review',
'created_date': 'March 10, 2026',
'description': 'Patient waited for 3 hours...',
'custom_message': 'Please provide detailed explanation',
'explanation_url': 'https://...'
}
```
### 6. New Complaint Admin Notification (`emails/new_complaint_admin_notification.html`)
Admin notification for new complaints with:
- Priority/severity badges with color coding
- Complaint details grid
- Patient information section
- Hospital/department information
- Description preview
- Action required notice
- View complaint CTA
**Context Variables:**
```python
{
'admin_name': 'Dr. Sarah Ahmed',
'priority_badge': '🚨 URGENT',
'is_high_priority': True,
'reference_number': 'CMP-2026-00123',
'complaint_title': 'Long wait time in OPD',
'priority': 'high', # low, medium, high, critical
'severity': 'high', # low, medium, high, critical
'status': 'New',
'patient_name': 'Mohammed Ali',
'mrn': 'MRN123456',
'contact_phone': '+966501234567',
'contact_email': 'patient@email.com',
'hospital_name': 'Al Hammadi Hospital',
'department_name': 'OPD - Internal Medicine',
'description': 'Patient waited for 3 hours...',
'complaint_url': 'https://...',
'notification_type': 'Working Hours',
'current_time': '2026-03-12 10:30:00'
}
```
**Integration:**
- **File:** `apps/complaints/tasks.py`
- **Function:** `notify_admins_new_complaint()`
- **Line:** 2436
### 7. User Invitation (`accounts/onboarding/invitation_email.html`)
New user onboarding invitation with:
- Welcome message
- Onboarding process overview
- Account setup CTA
- Expiry notice
**Context Variables:**
```python
{
'user': user_instance,
'activation_url': 'https://...',
'expires_at': datetime_object
}
```
### 7. Invitation Reminder (`accounts/onboarding/reminder_email.html`)
Onboarding reminder for pending users with:
- Friendly reminder message
- Benefits highlights
- Setup CTA
- Urgency notice
**Context Variables:**
```python
{
'user': user_instance,
'activation_url': 'https://...',
'expires_at': datetime_object
}
```
### 8. Onboarding Completion (`accounts/onboarding/completion_email.html`)
Admin notification for completed onboarding with:
- User information card
- Completion timestamp
- View details CTA
**Context Variables:**
```python
{
'user': user_instance,
'user_detail_url': 'https://...'
}
```
### 9. Password Reset (`accounts/email/password_reset_email.html`)
Password reset request with:
- Secure reset link
- Expiry warning
- Support contact info
**Context Variables:**
```python
{
'user': user_instance,
'protocol': 'https',
'domain': 'px360.alhammadihospital.com',
'uid': uidb64,
'token': token
}
```
## 📱 Features
### Responsive Design
- Mobile-optimized layout
- Adapts to all screen sizes (320px - 1920px)
- Touch-friendly buttons and links
### Email Client Compatibility
- ✅ Gmail (web, iOS, Android)
- ✅ Outlook (2016+, Office 365)
- ✅ Apple Mail
- ✅ Yahoo Mail
- ✅ AOL Mail
### Accessibility
- Semantic HTML structure
- Alt text for images
- High contrast colors
- Readable font sizes (minimum 14px)
### Dark Mode Support
- Automatic adaptation to dark mode preferences
- Maintains brand integrity in dark mode
## 🛠️ Usage
### Django Template Rendering
```python
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
def send_survey_invitation(patient_email, context):
# Render HTML template
html_content = render_to_string('emails/survey_invitation.html', context)
# Create plain text alternative
text_content = f"""
Dear {context['patient_name']},
We value your feedback! Please take {context['survey_duration']} to complete our survey.
Survey Link: {context['survey_link']}
Thank you,
Al Hammadi Hospital
"""
# Send email
msg = EmailMultiAlternatives(
subject='Patient Survey Invitation - Al Hammadi Hospital',
body=text_content,
from_email='noreply@alhammadihospital.com',
to=[patient_email],
)
msg.attach_alternative(html_content, 'text/html')
msg.send()
```
### Customizing Templates
1. **Extend the base template:**
```django
{% extends 'emails/base_email_template.html' %}
{% block title %}Your Custom Title{% endblock %}
{% block hero_title %}Your Hero Title{% endblock %}
{% block content %}
<!-- Your custom content -->
{% endblock %}
```
2. **Override specific blocks:**
```django
{% block cta_text %}Custom Button Text{% endblock %}
{% block cta_url %}https://your-link.com{% endblock %}
{% block info_title %}Custom Info Title{% endblock %}
```
3. **Add custom styles:**
```django
{% block extra_styles %}
<style>
.custom-class { color: #005696; }
</style>
{% endblock %}
```
## 📋 Best Practices
### Content Guidelines
- Keep subject lines under 50 characters
- Use preheader text effectively (35-50 characters)
- Include clear, single call-to-action
- Keep email width under 600px
### Image Guidelines
- Use PNG for logos (transparent background)
- Optimize images for web (under 100KB)
- Always include alt text
- Host images on reliable CDN
### Testing
Before sending:
1. Test on multiple email clients
2. Check mobile responsiveness
3. Verify all links work
4. Test with dark mode enabled
5. Check spam score
### Recommended Tools
- **Testing:** Litmus, Email on Acid
- **Images:** TinyPNG, Squoosh
- **Spam Check:** Mail-Tester, GlockApps
## 📁 File Structure
```
templates/emails/
├── base_email_template.html # Base template
├── survey_invitation.html # Survey invitation
├── appointment_confirmation.html # Appointment confirmation
├── survey_results_notification.html # Results notification
├── explanation_request.html # Complaint explanation request
├── new_complaint_admin_notification.html # Admin complaint notification ⭐ NEW
├── email_templates_preview.html # Visual preview page
└── README_EMAIL_TEMPLATES.md # This file
templates/accounts/
├── onboarding/
│ ├── invitation_email.html # User invitation
│ ├── reminder_email.html # Invitation reminder
│ └── completion_email.html # Completion notification
└── email/
└── password_reset_email.html # Password reset
```
## 🔧 Logo Setup
Update the logo URL in your Django settings or pass it in context:
```python
# settings.py
EMAIL_LOGO_URL = 'https://your-cdn.com/images/HH_P_H_Logo(hospital)_.png'
# Or in view context
context = {
'logo_url': request.build_absolute_uri(
static('images/HH_P_H_Logo(hospital)_.png')
)
}
```
## 📞 Support
For questions or custom template requests, contact:
- **Email:** px-team@alhammadihospital.com
- **Department:** Patient Experience Management
---
**Version:** 1.0
**Last Updated:** March 12, 2026
**Maintained by:** PX360 Development Team

View File

@ -0,0 +1,167 @@
{% extends 'emails/base_email_template.html' %}
{% block title %}Appointment Confirmation - Al Hammadi Hospital{% endblock %}
{% block preheader %}Your appointment has been confirmed. Please review the details.{% endblock %}
{% block hero_title %}Appointment Confirmed{% endblock %}
{% block hero_subtitle %}Your healthcare appointment at Al Hammadi Hospital has been successfully scheduled{% 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;">
Dear <strong>{{ patient_name|default:'Valued Patient' }}</strong>,
</p>
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
Your appointment has been confirmed. Please find the details below and save this email for your records.
</p>
</td>
</tr>
</table>
<!-- Appointment Details Card -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0; background-color: #eef6fb; border-radius: 8px; border: 1px solid #cbd5e1;">
<tr>
<td style="padding: 25px;">
<h3 style="margin: 0 0 20px 0; font-size: 18px; font-weight: 600; color: #005696; text-align: center;">
Appointment Details
</h3>
<!-- Detail Row 1 -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 15px;">
<tr>
<td width="120" style="padding-bottom: 10px; font-size: 14px; color: #64748b; font-weight: 500;">
Patient Name:
</td>
<td style="padding-bottom: 10px; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ patient_name|default:'N/A' }}
</td>
</tr>
<tr>
<td style="padding-bottom: 10px; font-size: 14px; color: #64748b; font-weight: 500;">
Appointment ID:
</td>
<td style="padding-bottom: 10px; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ appointment_id|default:'N/A' }}
</td>
</tr>
<tr>
<td style="padding-bottom: 10px; font-size: 14px; color: #64748b; font-weight: 500;">
Date:
</td>
<td style="padding-bottom: 10px; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ appointment_date|default:'N/A' }}
</td>
</tr>
<tr>
<td style="padding-bottom: 10px; font-size: 14px; color: #64748b; font-weight: 500;">
Time:
</td>
<td style="padding-bottom: 10px; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ appointment_time|default:'N/A' }}
</td>
</tr>
<tr>
<td style="padding-bottom: 10px; font-size: 14px; color: #64748b; font-weight: 500;">
Department:
</td>
<td style="padding-bottom: 10px; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ department|default:'N/A' }}
</td>
</tr>
<tr>
<td style="padding-bottom: 10px; font-size: 14px; color: #64748b; font-weight: 500;">
Doctor:
</td>
<td style="padding-bottom: 10px; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ doctor_name|default:'N/A' }}
</td>
</tr>
<tr>
<td style="font-size: 14px; color: #64748b; font-weight: 500;">
Location:
</td>
<td style="font-size: 14px; color: #1e293b; font-weight: 600;">
{{ location|default:'Al Hammadi Hospital' }}
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Important Reminders -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td>
<h3 style="margin: 0 0 15px 0; font-size: 18px; font-weight: 600; color: #005696;">
Important Reminders:
</h3>
</td>
</tr>
<tr>
<td>
<!-- Reminder 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>Arrive Early:</strong> Please arrive 15 minutes before your appointment time for registration
</p>
</td>
</tr>
</table>
<!-- Reminder 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>Bring Documents:</strong> Please bring your ID and any relevant medical records
</p>
</td>
</tr>
</table>
<!-- Reminder 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>Need to Reschedule?</strong> Contact us at least 24 hours in advance
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
{% endblock %}
{% block cta_url %}{{ reschedule_link|default:'#' }}{% endblock %}
{% block cta_text %}Reschedule or Cancel{% endblock %}
{% block info_title %}Contact Information{% endblock %}
{% block info_content %}
<strong>Phone:</strong> {{ hospital_phone|default:'+966 XXX XXX XXXX' }}<br>
<strong>Email:</strong> {{ hospital_email|default:'appointments@alhammadihospital.com' }}<br>
<strong>Emergency:</strong> For emergencies, please call 997 or visit our ER immediately
{% endblock %}
{% block footer_address %}
Patient Experience Management Department<br>
Al Hammadi Hospital
{% endblock %}

View File

@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no, address=no, email=no, date=no, url=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>{% block title %}Al Hammadi Hospital{% endblock %}</title>
<style>
/* Reset Styles */
html, body { margin: 0; padding: 0; width: 100% !important; height: 100% !important; }
* { -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
/* Client-specific resets */
#outlook a { padding: 0; }
.ExternalClass { width: 100%; }
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; }
/* Body centering */
body, table, td { margin: 0 auto; }
/* Responsive styles */
@media only screen and (max-width: 600px) {
.email-container { width: 100% !important; max-width: 100% !important; }
.fluid { width: 100% !important; height: auto !important; }
.stack-column { display: block !important; width: 100% !important; padding-bottom: 20px; }
.padding-mobile { padding-left: 20px !important; padding-right: 20px !important; }
.heading-mobile { font-size: 22px !important; }
.button-full { width: 100% !important; display: block !important; }
.content-padding { padding-left: 20px !important; padding-right: 20px !important; }
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.bg-light { background-color: #1e293b !important; }
.text-dark { color: #f8fafc !important; }
}
</style>
<!-- Block for additional custom styles -->
{% block extra_styles %}{% endblock %}
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
<!-- Preheader Text (invisible preview text) -->
<div style="display: none; font-size: 1px; line-height: 1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden; mso-hide: all;">
{% block preheader %}Al Hammadi Hospital - Patient Experience Management{% endblock %}
</div>
<!-- Email Container - Full width background -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f8fafc;">
<tr>
<td align="center" style="padding: 20px 10px;">
<!-- Main Email Wrapper - Centered with max-width -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" class="email-container" style="width: 600px; max-width: 600px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<!-- Header Section -->
<tr>
<td style="background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px 40px; text-align: center;">
<!-- Logo -->
<a href="#" style="text-decoration: none;">
<img src="{{ logo_url|default:'https://your-domain.com/static/images/HH_P_H_Logo(hospital)_.png' }}"
alt="Al Hammadi Hospital"
width="400"
height="120"
style="display: block; margin: 0 auto; max-width: 100%; font-family: sans-serif; color: #ffffff;">
</a>
</td>
</tr>
<!-- Hero/Title Section -->
{% block hero %}
<tr>
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #ffffff;">
<h1 class="heading-mobile" style="margin: 0 0 10px 0; font-size: 26px; font-weight: 700; color: #005696; line-height: 1.4;">
{% block hero_title %}Welcome to Al Hammadi Hospital{% endblock %}
</h1>
<p style="margin: 0; font-size: 16px; color: #64748b; line-height: 1.6;">
{% block hero_subtitle %}Your health and satisfaction are our priority{% endblock %}
</p>
</td>
</tr>
{% endblock %}
<!-- Main Content Section -->
<tr>
<td class="padding-mobile" style="padding: 0 40px 30px 40px; background-color: #ffffff;">
{% block content %}
<!-- Content goes here -->
{% endblock %}
</td>
</tr>
<!-- Call-to-Action Section -->
{% block cta_section %}
<tr>
<td align="center" style="padding: 0 40px 40px 40px; background-color: #ffffff;">
<!-- Primary Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td style="border-radius: 8px; background: linear-gradient(135deg, #005696 0%, #007bbd 100%);">
<a href="{% block cta_url %}#{% endblock %}"
class="button-full"
style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 8px; text-align: center;">
{% block cta_text %}Take Action{% endblock %}
</a>
</td>
</tr>
</table>
</td>
</tr>
{% endblock %}
<!-- Info Box Section -->
{% block info_box %}
<tr>
<td class="padding-mobile" style="padding: 0 40px 40px 40px; background-color: #ffffff;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #eef6fb; border-radius: 8px; border-left: 4px solid #005696;">
<tr>
<td style="padding: 20px;">
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #005696;">
ℹ️ {% block info_title %}Important Information{% endblock %}
</p>
<p style="margin: 0; font-size: 14px; color: #64748b; line-height: 1.6;">
{% block info_content %}Please read this information carefully.{% endblock %}
</p>
</td>
</tr>
</table>
</td>
</tr>
{% endblock %}
<!-- Divider -->
<tr>
<td style="padding: 0 40px; background-color: #ffffff;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="border-top: 1px solid #e2e8f0;"></td>
</tr>
</table>
</td>
</tr>
<!-- Footer Section -->
<tr>
<td style="padding: 30px 40px; background-color: #005696; text-align: center;">
<!-- Contact Info -->
<p style="margin: 0 0 15px 0; font-size: 14px; color: #ffffff; line-height: 1.6;">
<strong>Al Hammadi Hospital</strong><br>
{% block footer_address %}
Patient Experience Management Department
{% endblock %}
</p>
<!-- Social Links / Quick Links -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin-bottom: 15px;">
<tr>
{% block footer_links %}
<td style="padding: 0 10px;">
<a href="#" style="font-size: 13px; color: #ffffff; text-decoration: underline;">Website</a>
</td>
<td style="padding: 0 10px;">
<a href="#" style="font-size: 13px; color: #ffffff; text-decoration: underline;">Contact Us</a>
</td>
<td style="padding: 0 10px;">
<a href="#" style="font-size: 13px; color: #ffffff; text-decoration: underline;">Privacy Policy</a>
</td>
{% endblock %}
</tr>
</table>
<!-- Copyright -->
<p style="margin: 0; font-size: 12px; color: #eef6fb; line-height: 1.5;">
{% block copyright %}
&copy; {% now "Y" %} Al Hammadi Hospital. All rights reserved.
{% endblock %}
</p>
<!-- Unsubscribe -->
{% block unsubscribe %}
<p style="margin: 15px 0 0 0; font-size: 12px; color: #eef6fb;">
<a href="{{ unsubscribe_url|default:'#' }}" style="color: #eef6fb; text-decoration: underline;">Unsubscribe</a> from these emails
</p>
{% endblock %}
</td>
</tr>
</table>
<!-- End Main Email Wrapper -->
</td>
</tr>
</table>
<!-- End Email Container -->
</body>
</html>

View File

@ -0,0 +1,582 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Templates Preview - Al Hammadi Hospital</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.preview-container {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
color: #ffffff;
margin-bottom: 40px;
}
.header h1 {
font-size: 36px;
margin-bottom: 10px;
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.header p {
font-size: 18px;
opacity: 0.95;
}
.templates-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 30px;
margin-bottom: 40px;
}
.template-card {
background: #ffffff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
transition: transform 0.3s, box-shadow 0.3s;
}
.template-card:hover {
transform: translateY(-5px);
box-shadow: 0 15px 50px rgba(0,0,0,0.3);
}
.template-preview {
height: 400px;
overflow: hidden;
background: #f8fafc;
position: relative;
}
.template-preview iframe {
width: 100%;
height: 100%;
border: none;
}
.template-info {
padding: 25px;
}
.template-badge {
display: inline-block;
padding: 6px 12px;
background: linear-gradient(135deg, #005696 0%, #007bbd 100%);
color: #ffffff;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
margin-bottom: 15px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.template-title {
font-size: 22px;
font-weight: 700;
color: #005696;
margin-bottom: 10px;
}
.template-description {
color: #64748b;
line-height: 1.6;
margin-bottom: 20px;
}
.template-features {
list-style: none;
margin-bottom: 20px;
}
.template-features li {
padding: 8px 0;
color: #1e293b;
font-size: 14px;
display: flex;
align-items: center;
}
.template-features li:before {
content: '✓';
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: #d1fae5;
color: #10b981;
border-radius: 50%;
margin-right: 10px;
font-size: 12px;
font-weight: bold;
}
.template-actions {
display: flex;
gap: 10px;
}
.btn {
flex: 1;
padding: 12px 20px;
border-radius: 8px;
text-decoration: none;
text-align: center;
font-weight: 600;
font-size: 14px;
transition: all 0.3s;
display: inline-block;
}
.btn-primary {
background: linear-gradient(135deg, #005696 0%, #007bbd 100%);
color: #ffffff;
}
.btn-primary:hover {
box-shadow: 0 4px 12px rgba(0, 86, 150, 0.4);
}
.btn-secondary {
background: #eef6fb;
color: #005696;
}
.btn-secondary:hover {
background: #dbe7f3;
}
.color-palette {
background: #ffffff;
border-radius: 16px;
padding: 30px;
margin-top: 40px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.color-palette h2 {
color: #005696;
margin-bottom: 25px;
font-size: 24px;
}
.colors-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
}
.color-swatch {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.color-preview {
height: 100px;
width: 100%;
}
.color-info {
padding: 15px;
background: #ffffff;
}
.color-name {
font-weight: 600;
color: #1e293b;
margin-bottom: 5px;
font-size: 14px;
}
.color-hex {
font-family: 'Courier New', monospace;
color: #64748b;
font-size: 13px;
}
.footer {
text-align: center;
color: #ffffff;
margin-top: 40px;
padding-top: 30px;
border-top: 1px solid rgba(255,255,255,0.2);
}
@media (max-width: 768px) {
.templates-grid {
grid-template-columns: 1fr;
}
.header h1 {
font-size: 28px;
}
}
</style>
</head>
<body>
<div class="preview-container">
<!-- Header -->
<div class="header">
<h1>🏥 Al Hammadi Hospital Email Templates</h1>
<p>Professional email templates with hospital branding</p>
</div>
<!-- Templates Grid -->
<div class="templates-grid">
<!-- Template 1: Base Template -->
<div class="template-card">
<div class="template-preview">
<iframe srcdoc='
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: sans-serif; margin: 0; padding: 20px; background: #f8fafc; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; }
.logo { color: white; font-size: 24px; font-weight: bold; }
.content { padding: 30px; }
h1 { color: #005696; margin: 0 0 10px 0; font-size: 24px; }
p { color: #64748b; line-height: 1.6; }
.btn { display: inline-block; padding: 12px 30px; background: linear-gradient(135deg, #005696 0%, #007bbd 100%); color: white; text-decoration: none; border-radius: 8px; margin-top: 20px; }
.info-box { background: #eef6fb; padding: 15px; border-left: 4px solid #005696; border-radius: 8px; margin-top: 20px; }
.footer { background: #005696; color: white; padding: 20px; text-align: center; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">Al Hammadi Hospital</div>
</div>
<div class="content">
<h1>Base Email Template</h1>
<p>This is the foundation template with all common components.</p>
<a href="#" class="btn">Call to Action</a>
<div class="info-box">
<strong>ℹ️ Information:</strong> This is an info box component.
</div>
</div>
<div class="footer">© 2026 Al Hammadi Hospital</div>
</div>
</body>
</html>'></iframe>
</div>
<div class="template-info">
<span class="template-badge">Foundation</span>
<h3 class="template-title">Base Template</h3>
<p class="template-description">
The foundation template with responsive design, header, content areas, and footer.
</p>
<ul class="template-features">
<li>Responsive layout</li>
<li>Brand gradient header</li>
<li>Multiple content blocks</li>
<li>Dark mode support</li>
</ul>
<div class="template-actions">
<a href="base_email_template.html" class="btn btn-primary">View Code</a>
</div>
</div>
</div>
<!-- Template 2: Survey Invitation -->
<div class="template-card">
<div class="template-preview">
<iframe srcdoc='
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: sans-serif; margin: 0; padding: 20px; background: #f8fafc; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; }
.logo { color: white; font-size: 24px; font-weight: bold; }
.content { padding: 30px; }
h1 { color: #005696; margin: 0 0 10px 0; font-size: 24px; }
p { color: #64748b; line-height: 1.6; margin: 15px 0; }
.benefit { display: flex; align-items: center; margin: 12px 0; }
.icon { width: 32px; height: 32px; background: #eef6fb; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-right: 12px; font-size: 16px; }
.btn { display: inline-block; padding: 14px 32px; background: linear-gradient(135deg, #005696 0%, #007bbd 100%); color: white; text-decoration: none; border-radius: 8px; margin-top: 20px; font-weight: 600; }
.info-box { background: #eef6fb; padding: 15px; border-left: 4px solid #005696; border-radius: 8px; margin-top: 20px; font-size: 14px; }
.footer { background: #005696; color: white; padding: 20px; text-align: center; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">Al Hammadi Hospital</div>
</div>
<div class="content">
<h1>We Value Your Feedback</h1>
<p>Dear Valued Patient,</p>
<p>Help us improve our services by sharing your experience.</p>
<div class="benefit">
<div class="icon">✓</div>
<span>Improve Patient Care</span>
</div>
<div class="benefit">
<div class="icon">★</div>
<span>Better Experience</span>
</div>
<div class="benefit">
<div class="icon">❤</div>
<span>Quality Standards</span>
</div>
<a href="#" class="btn">Start Survey</a>
<div class="info-box">
<strong>ℹ️ Survey Info:</strong> Duration: 3-5 minutes<br>
<strong>Confidentiality:</strong> Completely confidential
</div>
</div>
<div class="footer">© 2026 Al Hammadi Hospital</div>
</div>
</body>
</html>'></iframe>
</div>
<div class="template-info">
<span class="template-badge">Patient Communication</span>
<h3 class="template-title">Survey Invitation</h3>
<p class="template-description">
Engaging survey invitation with benefits and clear call-to-action.
</p>
<ul class="template-features">
<li>Personalized greeting</li>
<li>Benefits highlights</li>
<li>Survey details box</li>
<li>Mobile-optimized</li>
</ul>
<div class="template-actions">
<a href="survey_invitation.html" class="btn btn-primary">View Code</a>
</div>
</div>
</div>
<!-- Template 3: Appointment Confirmation -->
<div class="template-card">
<div class="template-preview">
<iframe srcdoc='
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: sans-serif; margin: 0; padding: 20px; background: #f8fafc; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; }
.logo { color: white; font-size: 24px; font-weight: bold; }
.content { padding: 30px; }
h1 { color: #005696; margin: 0 0 10px 0; font-size: 24px; }
p { color: #64748b; line-height: 1.6; margin: 15px 0; }
.details-card { background: #eef6fb; padding: 20px; border-radius: 8px; border: 1px solid #cbd5e1; margin: 20px 0; }
.detail-row { display: flex; margin-bottom: 10px; font-size: 14px; }
.detail-label { width: 100px; color: #64748b; font-weight: 500; }
.detail-value { color: #1e293b; font-weight: 600; }
.btn { display: inline-block; padding: 14px 32px; background: linear-gradient(135deg, #005696 0%, #007bbd 100%); color: white; text-decoration: none; border-radius: 8px; margin-top: 20px; font-weight: 600; }
.footer { background: #005696; color: white; padding: 20px; text-align: center; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">Al Hammadi Hospital</div>
</div>
<div class="content">
<h1>Appointment Confirmed</h1>
<p>Dear Patient,</p>
<p>Your appointment has been confirmed.</p>
<div class="details-card">
<div class="detail-row">
<span class="detail-label">Date:</span>
<span class="detail-value">March 15, 2026</span>
</div>
<div class="detail-row">
<span class="detail-label">Time:</span>
<span class="detail-value">10:00 AM</span>
</div>
<div class="detail-row">
<span class="detail-label">Department:</span>
<span class="detail-value">Cardiology</span>
</div>
<div class="detail-row">
<span class="detail-label">Doctor:</span>
<span class="detail-value">Dr. Ahmed Smith</span>
</div>
</div>
<a href="#" class="btn">Reschedule or Cancel</a>
</div>
<div class="footer">© 2026 Al Hammadi Hospital</div>
</div>
</body>
</html>'></iframe>
</div>
<div class="template-info">
<span class="template-badge">Appointment Management</span>
<h3 class="template-title">Appointment Confirmation</h3>
<p class="template-description">
Professional appointment confirmation with detailed information card.
</p>
<ul class="template-features">
<li>Appointment details card</li>
<li>Important reminders</li>
<li>Reschedule CTA</li>
<li>Contact information</li>
</ul>
<div class="template-actions">
<a href="appointment_confirmation.html" class="btn btn-primary">View Code</a>
</div>
</div>
</div>
<!-- Template 4: Survey Results -->
<div class="template-card">
<div class="template-preview">
<iframe srcdoc='
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: sans-serif; margin: 0; padding: 20px; background: #f8fafc; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; }
.logo { color: white; font-size: 24px; font-weight: bold; }
.content { padding: 30px; }
h1 { color: #005696; margin: 0 0 10px 0; font-size: 24px; }
p { color: #64748b; line-height: 1.6; margin: 15px 0; }
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 20px 0; }
.stat-card { background: #eef6fb; padding: 15px; border-radius: 8px; text-align: center; }
.stat-value { font-size: 24px; font-weight: 700; color: #005696; }
.stat-label { font-size: 11px; color: #64748b; text-transform: uppercase; margin-top: 5px; }
.btn { display: inline-block; padding: 14px 32px; background: linear-gradient(135deg, #005696 0%, #007bbd 100%); color: white; text-decoration: none; border-radius: 8px; margin-top: 20px; font-weight: 600; }
.alert { background: #fef3c7; padding: 15px; border-left: 4px solid #f59e0b; border-radius: 8px; margin-top: 20px; font-size: 14px; }
.footer { background: #005696; color: white; padding: 20px; text-align: center; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">Al Hammadi Hospital</div>
</div>
<div class="content">
<h1>Survey Results Ready</h1>
<p>Your department results are available.</p>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">4.5/5</div>
<div class="stat-label">Overall Score</div>
</div>
<div class="stat-card">
<div class="stat-value">156</div>
<div class="stat-label">Responses</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#10b981;">78%</div>
<div class="stat-label">Response Rate</div>
</div>
</div>
<a href="#" class="btn">View Full Report</a>
<div class="alert">
<strong>⚠️ Action Required:</strong> Please review and prepare action plan by end of month.
</div>
</div>
<div class="footer">© 2026 Al Hammadi Hospital</div>
</div>
</body>
</html>'></iframe>
</div>
<div class="template-info">
<span class="template-badge">Analytics & Reporting</span>
<h3 class="template-title">Survey Results Notification</h3>
<p class="template-description">
Department survey results with statistics and actionable insights.
</p>
<ul class="template-features">
<li>Statistics dashboard</li>
<li>Key highlights</li>
<li>Action items alert</li>
<li>Report access link</li>
</ul>
<div class="template-actions">
<a href="survey_results_notification.html" class="btn btn-primary">View Code</a>
</div>
</div>
</div>
</div>
<!-- Color Palette -->
<div class="color-palette">
<h2>🎨 Brand Color Palette</h2>
<div class="colors-grid">
<div class="color-swatch">
<div class="color-preview" style="background: #005696;"></div>
<div class="color-info">
<div class="color-name">Primary Navy</div>
<div class="color-hex">#005696</div>
</div>
</div>
<div class="color-swatch">
<div class="color-preview" style="background: #007bbd;"></div>
<div class="color-info">
<div class="color-name">Accent Blue</div>
<div class="color-hex">#007bbd</div>
</div>
</div>
<div class="color-swatch">
<div class="color-preview" style="background: #eef6fb;"></div>
<div class="color-info">
<div class="color-name">Light Background</div>
<div class="color-hex">#eef6fb</div>
</div>
</div>
<div class="color-swatch">
<div class="color-preview" style="background: #64748b;"></div>
<div class="color-info">
<div class="color-name">Slate Gray</div>
<div class="color-hex">#64748b</div>
</div>
</div>
<div class="color-swatch">
<div class="color-preview" style="background: #10b981;"></div>
<div class="color-info">
<div class="color-name">Success Green</div>
<div class="color-hex">#10b981</div>
</div>
</div>
<div class="color-swatch">
<div class="color-preview" style="background: #f59e0b;"></div>
<div class="color-info">
<div class="color-name">Warning Yellow</div>
<div class="color-hex">#f59e0b</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<p><strong>Al Hammadi Hospital Email Templates</strong></p>
<p>Patient Experience Management Department</p>
<p style="margin-top: 15px; opacity: 0.8; font-size: 14px;">
Created with ❤️ for better patient communication
</p>
</div>
</div>
</body>
</html>

View File

@ -1,193 +1,163 @@
{% extends 'emails/base_email_template.html' %}
{% load i18n %} {% load i18n %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE|default:'en' }}" dir="{% if LANGUAGE_CODE == 'ar' %}rtl{% else %}ltr{% endif %}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans "Explanation Request" %}</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 20px;
background-color: #f4f4f4;
}
.container {
max-width: 600px;
margin: 0 auto;
background: #fff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
padding: 30px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 28px;
}
.content {
padding: 30px;
}
.complaint-box {
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 15px;
margin: 20px 0;
border-radius: 5px;
}
.complaint-box h3 {
margin-top: 0;
color: #667eea;
}
.info-row {
display: flex;
margin-bottom: 10px;
}
.info-label {
font-weight: 600;
min-width: 100px;
color: #555;
}
.info-value {
flex: 1;
}
.button {
display: inline-block;
padding: 12px 30px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
text-decoration: none;
border-radius: 5px;
font-weight: 600;
margin: 20px 0;
}
.button:hover {
opacity: 0.9;
}
.note {
background: #fff3cd;
border: 1px solid #ffc107;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
}
.footer {
background: #f8f9fa;
padding: 20px;
text-align: center;
font-size: 12px;
color: #666;
border-top: 1px solid #dee2e6;
}
.custom-message {
background: #e3f2fd;
border-left: 4px solid #2196f3;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
}
.attachment-info {
font-size: 14px;
color: #666;
margin-top: 10px;
}
.attachment-info i {
color: #667eea;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{% trans "Explanation Request" %}</h1>
</div>
<div class="content"> {% block title %}{% trans "Explanation Request - Al Hammadi Hospital" %}{% endblock %}
<p>{% trans "Dear" %} {{ staff_name }},</p>
<p>{% trans "You have been assigned to provide an explanation for the following patient complaint. Please review the details and submit your response using the link below." %}</p> {% block preheader %}{% trans "You have been assigned to provide an explanation for a patient complaint" %}{% endblock %}
{% if custom_message %} {% block hero_title %}{% trans "Explanation Request" %}{% endblock %}
<div class="custom-message">
<strong>{% trans "Note from PX Team:" %}</strong>
<p>{{ custom_message }}</p>
</div>
{% endif %}
<div class="complaint-box"> {% block hero_subtitle %}{% trans "Please review the complaint details and submit your response" %}{% endblock %}
<h3>{% trans "Complaint Details" %}</h3>
<div class="info-row">
<div class="info-label">{% trans "Reference:" %}</div>
<div class="info-value">#{{ complaint_id }}</div>
</div>
<div class="info-row">
<div class="info-label">{% trans "Title:" %}</div>
<div class="info-value">{{ complaint_title }}</div>
</div>
<div class="info-row">
<div class="info-label">{% trans "Patient:" %}</div>
<div class="info-value">{{ patient_name }}</div>
</div>
<div class="info-row">
<div class="info-label">{% trans "Hospital:" %}</div>
<div class="info-value">{{ hospital_name }}</div>
</div>
<div class="info-row">
<div class="info-label">{% trans "Department:" %}</div>
<div class="info-value">{{ department_name }}</div>
</div>
<div class="info-row">
<div class="info-label">{% trans "Category:" %}</div>
<div class="info-value">{{ category }}</div>
</div>
<div class="info-row">
<div class="info-label">{% trans "Status:" %}</div>
<div class="info-value">{{ status }}</div>
</div>
<div class="info-row">
<div class="info-label">{% trans "Date:" %}</div>
<div class="info-value">{{ created_date }}</div>
</div>
{% 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 "Dear" %} <strong>{{ staff_name }}</strong>,
</p>
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
{% trans "You have been assigned to provide an explanation for the following patient complaint. Please review the details and submit your response using the button below." %}
</p>
</td>
</tr>
</table>
{% if custom_message %}
<!-- Custom Message -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 20px 0;">
<tr>
<td style="padding: 15px; background-color: #e3f2fd; border-left: 4px solid #2196f3; border-radius: 5px;">
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #1976d2;">
{% trans "Note from PX Team:" %}
</p>
<p style="margin: 0; font-size: 14px; color: #1e293b; line-height: 1.6;">
{{ custom_message }}
</p>
</td>
</tr>
</table>
{% endif %}
<!-- Complaint Details 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;">
{% trans "Complaint Details" %}
</h3>
<!-- Detail Row 1 -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 10px;">
<tr>
<td width="120" style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
{% trans "Reference:" %}
</td>
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
#{{ complaint_id }}
</td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
{% trans "Title:" %}
</td>
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ complaint_title }}
</td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
{% trans "Patient:" %}
</td>
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ patient_name }}
</td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
{% trans "Hospital:" %}
</td>
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ hospital_name }}
</td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
{% trans "Department:" %}
</td>
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ department_name }}
</td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
{% trans "Category:" %}
</td>
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ category }}
</td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
{% trans "Status:" %}
</td>
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ status }}
</td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
{% trans "Date:" %}
</td>
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; font-weight: 600;">
{{ created_date }}
</td>
</tr>
{% if description %} {% if description %}
<div class="info-row" style="display: block;"> <tr>
<div class="info-label">{% trans "Description:" %}</div> <td valign="top" style="padding: 8px 0; font-size: 14px; color: #64748b; font-weight: 500;">
<div class="info-value" style="margin-top: 5px;">{{ description }}</div> {% trans "Description:" %}
</div> </td>
<td style="padding: 8px 0; font-size: 14px; color: #1e293b; line-height: 1.6;">
{{ description }}
</td>
</tr>
{% endif %} {% endif %}
</div> </table>
</td>
</tr>
</table>
<div style="text-align: center;"> <!-- Important Information -->
<a href="{{ explanation_url }}" class="button">{% trans "Submit Your Explanation" %}</a> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
</div> <tr>
<td style="padding: 15px; background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 8px;">
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #92400e;">
⚠️ {% trans "Important Information:" %}
</p>
<ul style="margin: 0; padding-left: 20px; font-size: 14px; color: #78350f; line-height: 1.8;">
<li>{% trans "This link is unique and can only be used once" %}</li>
<li>{% trans "You can attach supporting documents to your explanation" %}</li>
<li>{% trans "Your response will be reviewed by the PX team" %}</li>
<li>{% trans "Please submit your explanation at your earliest convenience" %}</li>
</ul>
</td>
</tr>
</table>
{% endblock %}
<div class="note"> {% block cta_url %}{{ explanation_url }}{% endblock %}
<strong>{% trans "Important Information:" %}</strong> {% block cta_text %}{% trans "Submit Your Explanation" %}{% endblock %}
<ul style="margin-top: 10px; padding-left: 20px;">
<li>{% trans "This link is unique and can only be used once" %}</li>
<li>{% trans "You can attach supporting documents to your explanation" %}</li>
<li>{% trans "Your response will be reviewed by the PX team" %}</li>
<li>{% trans "Please submit your explanation at your earliest convenience" %}</li>
</ul>
</div>
<p>{% trans "If you have any questions or concerns, please contact the PX team directly." %}</p> {% block info_title %}{% trans "Need Assistance?" %}{% endblock %}
{% block info_content %}
{% trans "If you have any questions or concerns, please contact the PX team directly." %}<br>
<strong>{% trans "Note:" %}</strong> {% trans "This is an automated email. Please do not reply directly to this message." %}
{% endblock %}
<p>{% trans "Thank you for your cooperation." %}</p> {% block footer_address %}
</div> PX360 Complaint Management System<br>
Al Hammadi Hospital
<div class="footer"> {% endblock %}
<p><strong>PX360 Complaint Management System</strong></p>
<p>{% trans "This is an automated email. Please do not reply directly to this message." %}</p>
<p>{% trans "If you need assistance, contact your PX administrator." %}</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,431 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Templates - Implementation Status</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
text-align: center;
color: #ffffff;
margin-bottom: 40px;
}
.header h1 {
font-size: 42px;
margin-bottom: 15px;
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.header p {
font-size: 20px;
opacity: 0.95;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 25px;
margin-bottom: 40px;
}
.status-card {
background: #ffffff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.status-header {
padding: 25px;
background: linear-gradient(135deg, #005696 0%, #007bbd 100%);
color: #ffffff;
}
.status-header h3 {
font-size: 20px;
margin-bottom: 5px;
}
.status-header p {
font-size: 14px;
opacity: 0.9;
}
.status-body {
padding: 25px;
}
.template-list {
list-style: none;
}
.template-item {
padding: 15px;
margin-bottom: 15px;
background: #f8fafc;
border-radius: 8px;
border-left: 4px solid #10b981;
}
.template-name {
font-weight: 600;
color: #005696;
margin-bottom: 5px;
font-size: 16px;
}
.template-path {
font-family: 'Courier New', monospace;
font-size: 13px;
color: #64748b;
margin-bottom: 8px;
}
.template-integration {
font-size: 13px;
color: #1e293b;
line-height: 1.5;
}
.template-integration strong {
color: #005696;
}
.color-section {
background: #ffffff;
border-radius: 16px;
padding: 30px;
margin-bottom: 30px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.color-section h2 {
color: #005696;
margin-bottom: 25px;
font-size: 24px;
}
.colors-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 20px;
}
.color-swatch {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.color-preview {
height: 120px;
width: 100%;
}
.color-info {
padding: 15px;
background: #ffffff;
}
.color-name {
font-weight: 600;
color: #1e293b;
margin-bottom: 5px;
font-size: 14px;
}
.color-hex {
font-family: 'Courier New', monospace;
color: #64748b;
font-size: 13px;
}
.integration-section {
background: #ffffff;
border-radius: 16px;
padding: 30px;
margin-bottom: 30px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.integration-section h2 {
color: #005696;
margin-bottom: 25px;
font-size: 24px;
}
.integration-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.integration-card {
padding: 20px;
background: #f8fafc;
border-radius: 8px;
border-left: 4px solid #005696;
}
.integration-card h4 {
color: #005696;
margin-bottom: 10px;
font-size: 16px;
}
.integration-card code {
display: block;
background: #1e293b;
color: #10b981;
padding: 12px;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
margin-top: 10px;
overflow-x: auto;
}
.footer {
text-align: center;
color: #ffffff;
padding-top: 30px;
border-top: 1px solid rgba(255,255,255,0.2);
}
.badge {
display: inline-block;
padding: 4px 12px;
background: #10b981;
color: #ffffff;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
margin-left: 10px;
}
@media (max-width: 768px) {
.status-grid {
grid-template-columns: 1fr;
}
.header h1 {
font-size: 32px;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<h1>🏥 Email Template System</h1>
<p>Al Hammadi Hospital - Unified Brand Identity</p>
</div>
<!-- Color Palette -->
<div class="color-section">
<h2>🎨 Brand Color Palette</h2>
<div class="colors-grid">
<div class="color-swatch">
<div class="color-preview" style="background: #005696;"></div>
<div class="color-info">
<div class="color-name">Primary Navy</div>
<div class="color-hex">#005696</div>
</div>
</div>
<div class="color-swatch">
<div class="color-preview" style="background: #007bbd;"></div>
<div class="color-info">
<div class="color-name">Accent Blue</div>
<div class="color-hex">#007bbd</div>
</div>
</div>
<div class="color-swatch">
<div class="color-preview" style="background: #eef6fb;"></div>
<div class="color-info">
<div class="color-name">Light Background</div>
<div class="color-hex">#eef6fb</div>
</div>
</div>
<div class="color-swatch">
<div class="color-preview" style="background: #64748b;"></div>
<div class="color-info">
<div class="color-name">Slate Gray</div>
<div class="color-hex">#64748b</div>
</div>
</div>
<div class="color-swatch">
<div class="color-preview" style="background: #10b981;"></div>
<div class="color-info">
<div class="color-name">Success Green</div>
<div class="color-hex">#10b981</div>
</div>
</div>
<div class="color-swatch">
<div class="color-preview" style="background: #f59e0b;"></div>
<div class="color-info">
<div class="color-name">Warning Yellow</div>
<div class="color-hex">#f59e0b</div>
</div>
</div>
</div>
</div>
<!-- Status Grid -->
<div class="status-grid">
<!-- Patient Templates -->
<div class="status-card">
<div class="status-header">
<h3>👥 Patient-Facing Templates</h3>
<p>Emails sent to patients</p>
</div>
<div class="status-body">
<ul class="template-list">
<li class="template-item">
<div class="template-name">Survey Invitation</div>
<div class="template-path">templates/emails/survey_invitation.html</div>
<div class="template-integration">
<strong>Usage:</strong> Sent after patient visits for feedback collection
</div>
</li>
<li class="template-item">
<div class="template-name">Appointment Confirmation</div>
<div class="template-path">templates/emails/appointment_confirmation.html</div>
<div class="template-integration">
<strong>Usage:</strong> Sent when appointments are booked
</div>
</li>
<li class="template-item">
<div class="template-name">Survey Results Notification</div>
<div class="template-path">templates/emails/survey_results_notification.html</div>
<div class="template-integration">
<strong>Usage:</strong> Department heads notified of survey results
</div>
</li>
</ul>
</div>
</div>
<!-- Staff Templates -->
<div class="status-card">
<div class="status-header">
<h3>👨‍⚕️ Staff/Admin Templates</h3>
<p>Internal communication emails</p>
</div>
<div class="status-body">
<ul class="template-list">
<li class="template-item">
<div class="template-name">Explanation Request <span class="badge">INTEGRATED</span></div>
<div class="template-path">templates/emails/explanation_request.html</div>
<div class="template-integration">
<strong>Integration:</strong> apps/complaints/tasks.py<br>
<strong>Function:</strong> send_explanation_request_email()
</div>
</li>
<li class="template-item">
<div class="template-name">User Invitation <span class="badge">INTEGRATED</span></div>
<div class="template-path">templates/accounts/onboarding/invitation_email.html</div>
<div class="template-integration">
<strong>Integration:</strong> apps/accounts/services.py<br>
<strong>Function:</strong> EmailService.send_invitation_email()
</div>
</li>
<li class="template-item">
<div class="template-name">Invitation Reminder <span class="badge">INTEGRATED</span></div>
<div class="template-path">templates/accounts/onboarding/reminder_email.html</div>
<div class="template-integration">
<strong>Integration:</strong> apps/accounts/services.py<br>
<strong>Function:</strong> EmailService.send_reminder_email()
</div>
</li>
<li class="template-item">
<div class="template-name">Onboarding Completion <span class="badge">INTEGRATED</span></div>
<div class="template-path">templates/accounts/onboarding/completion_email.html</div>
<div class="template-integration">
<strong>Integration:</strong> apps/accounts/services.py<br>
<strong>Function:</strong> EmailService.send_completion_notification()
</div>
</li>
<li class="template-item">
<div class="template-name">Password Reset <span class="badge">INTEGRATED</span></div>
<div class="template-path">templates/accounts/email/password_reset_email.html</div>
<div class="template-integration">
<strong>Integration:</strong> Django Authentication System
</div>
</li>
</ul>
</div>
</div>
</div>
<!-- Integration Points -->
<div class="integration-section">
<h2>🔌 Integration Points</h2>
<div class="integration-grid">
<div class="integration-card">
<h4>Complaints System</h4>
<p style="color: #64748b; font-size: 14px; margin-bottom: 10px;">apps/complaints/tasks.py</p>
<code>send_explanation_request_email(explanation_id)</code>
<p style="color: #64748b; font-size: 13px; margin-top: 10px;">
Sends branded explanation request emails to staff when complaints are filed.
</p>
</div>
<div class="integration-card">
<h4>Accounts Service</h4>
<p style="color: #64748b; font-size: 14px; margin-bottom: 10px;">apps/accounts/services.py</p>
<code>EmailService.send_*_email()</code>
<p style="color: #64748b; font-size: 13px; margin-top: 10px;">
All onboarding emails (invitation, reminder, completion) use branded templates.
</p>
</div>
<div class="integration-card">
<h4>Notifications Service</h4>
<p style="color: #64748b; font-size: 14px; margin-bottom: 10px;">apps/notifications/services.py</p>
<code>NotificationService.send_email()</code>
<p style="color: #64748b; font-size: 13px; margin-top: 10px;">
Unified email service with HTML support and database logging.
</p>
</div>
<div class="integration-card">
<h4>Base Template</h4>
<p style="color: #64748b; font-size: 14px; margin-bottom: 10px;">templates/emails/</p>
<code>{% extends 'emails/base_email_template.html' %}</code>
<p style="color: #64748b; font-size: 13px; margin-top: 10px;">
All email templates extend the base template for consistent branding.
</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<p><strong>Al Hammadi Hospital Email Template System</strong></p>
<p>Patient Experience Management Department</p>
<p style="margin-top: 15px; opacity: 0.8; font-size: 14px;">
Created on March 12, 2026
</p>
<p style="margin-top: 10px; opacity: 0.7; font-size: 13px;">
Documentation: templates/emails/README_EMAIL_TEMPLATES.md
</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,134 @@
{% extends 'emails/base_email_template.html' %}
{% load i18n %}
{% block title %}{% trans "New Complaint Notification - Al Hammadi Hospital" %}{% endblock %}
{% block preheader %}{{ priority_badge|default:"New" }} complaint: {{ complaint_title|truncatechars:50 }}{% endblock %}
{% block hero_title %}{{ priority_badge|default:"New" }} Complaint{% endblock %}
{% block hero_subtitle %}A new complaint requires your attention{% 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 0 15px 0; font-size: 16px; color: #1e293b; line-height: 1.6;">
{% trans "Dear" %} <strong>{{ admin_name|default:'Admin' }}</strong>,
</p>
<p style="margin: 0; font-size: 16px; color: #64748b; line-height: 1.6;">
{% trans "A new complaint has been submitted and requires your attention. Please review the details below." %}
</p>
</td>
</tr>
</table>
<!-- Complaint Details Box -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0;">
<tr>
<td style="padding: 20px; background-color: #f8fafc; border-left: 4px solid #005696; border-radius: 8px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Reference" %}:</strong> {{ reference_number }}
</p>
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-size: 14px; color: #1e293b;">
<strong style="color: #005696;">{% trans "Title" %}:</strong> {{ complaint_title }}
</p>
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Priority" %}:</strong>
<span style="color: {% if priority == 'critical' %}#dc2626{% elif priority == 'high' %}#f97316{% elif priority == 'medium' %}#f59e0b{% else %}#10b981{% endif %}; font-weight: 600;">
{{ priority|title }}
</span>
</p>
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Severity" %}:</strong>
<span style="color: {% if severity == 'critical' %}#dc2626{% elif severity == 'high' %}#f97316{% elif severity == 'medium' %}#f59e0b{% else %}#10b981{% endif %}; font-weight: 600;">
{{ severity|title }}
</span>
</p>
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Patient" %}:</strong> {{ patient_name|default:"N/A" }}
</p>
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Hospital" %}:</strong> {{ hospital_name|default:"N/A" }}
</p>
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<p style="margin: 0; font-size: 14px; color: #64748b;">
<strong style="color: #005696;">{% trans "Department" %}:</strong> {{ department_name|default:"N/A" }}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Description -->
{% if description %}
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0;">
<tr>
<td style="padding: 20px; background-color: #f8fafc; border-radius: 8px;">
<p style="margin: 0 0 10px 0; font-size: 14px; font-weight: 600; color: #005696;">
{% trans "Description" %}
</p>
<p style="margin: 0; font-size: 14px; color: #1e293b; line-height: 1.6; white-space: pre-wrap;">
{{ description }}
</p>
</td>
</tr>
</table>
{% endif %}
<!-- Action Note -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 25px 0;">
<tr>
<td style="padding: 15px 20px; background-color: #eef6fb; border-radius: 8px;">
<p style="margin: 0; font-size: 14px; color: #005696; line-height: 1.6;">
{% trans "Please review and activate this complaint at your earliest convenience." %}
</p>
</td>
</tr>
</table>
{% endblock %}
{% block cta_url %}{{ complaint_url }}{% endblock %}
{% block cta_text %}{% trans "View Complaint" %}{% endblock %}
{% block info_title %}{% trans "Notification Details" %}{% endblock %}
{% block info_content %}
<strong>{% trans "Type:" %}</strong> {{ notification_type|default:"Working Hours" }}<br>
<strong>{% trans "Time:" %}</strong> {{ current_time }}<br>
{% trans "This is an automated notification from the PX 360 system." %}
{% endblock %}
{% block footer_address %}
PX360 Complaint Management System<br>
Al Hammadi Hospital
{% endblock %}

View File

@ -0,0 +1,99 @@
{% extends 'emails/base_email_template.html' %}
{% block title %}Patient Survey Invitation - Al Hammadi Hospital{% endblock %}
{% block preheader %}We value your feedback! Please share your experience with us.{% endblock %}
{% block hero_title %}We Value Your Feedback{% endblock %}
{% block hero_subtitle %}Help us improve our services by sharing your recent experience at Al Hammadi Hospital{% 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;">
Dear <strong>{{ patient_name|default:'Valued Patient' }}</strong>,
</p>
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
Thank you for choosing Al Hammadi Hospital for your healthcare needs. We hope your recent visit on <strong>{{ visit_date|default:'your recent visit' }}</strong> met your expectations.
</p>
<p style="margin: 15px 0 0 0; font-size: 16px; color: #64748b; line-height: 1.6;">
Your feedback is invaluable in helping us maintain and improve the quality of care we provide. Would you mind taking {{ survey_duration|default:'3-5' }} minutes to complete our patient experience survey?
</p>
</td>
</tr>
</table>
<!-- Survey 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 Your Feedback Matters:
</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>Improve Patient Care:</strong> Your insights help us enhance our services
</p>
</td>
</tr>
</table>
<!-- 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>Better Experience:</strong> Help us create a better experience for all patients
</p>
</td>
</tr>
</table>
<!-- 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>Quality Standards:</strong> Contribute to our commitment to excellence
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
{% endblock %}
{% block cta_url %}{{ survey_link|default:'#' }}{% endblock %}
{% block cta_text %}Start Survey{% endblock %}
{% block info_title %}Survey Information{% endblock %}
{% block info_content %}
<strong>Duration:</strong> Approximately {{ survey_duration|default:'3-5' }} minutes<br>
<strong>Confidentiality:</strong> Your responses are completely confidential<br>
<strong>Deadline:</strong> Please complete by {{ deadline|default:'the end of this week' }}
{% endblock %}
{% block footer_address %}
Patient Experience Management Department<br>
Al Hammadi Hospital
{% endblock %}

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